docs: add Manager / Salaried Pay design (Approach A, approved)

Models a manager as a Worker with a pay_type discriminator, reusing the
existing loan/adjustment/payslip/payroll pipeline. New 'Salary'
adjustment type, project-attributed; managers excluded from
attendance/absence pickers so daily-worker math is provably untouched.
HARD STOP after implementation for local verification before any push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-05-15 18:57:53 +02:00
parent 9713b89ede
commit 325c59d4a1

View File

@ -0,0 +1,266 @@
# Manager / Salaried Pay — Design
**Date:** 15 May 2026
**Status:** Approved by Konrad on 15 May 2026 ("Approve with a hard-stop"); ready for implementation plan.
**Branch:** `ai-dev`. **HARD STOP after implementation — Konrad runs local verification BEFORE any push/deploy.** This touches the payroll + reporting money path, so it gets the same hands-on local check as the post-attendance flow.
## Goal (one sentence)
Let Konrad log monthly pay for **managers / salaried staff** (people like Fitz
who are *not* logged per day and have no work logs) so their payments appear in
payroll records and project costs, and so loans / advances / deductions can be
attached to their names — **without changing anything about how daily-rated
workers are logged, paid, or costed.**
## Why
Today a manager's pay is captured as an `ExpenseReceipt`. Structurally that is
the wrong table: `ExpenseReceipt` is FK'd to `User`, has a `vendor_name`, and has
no link to `Worker`, `Project`-attributable labour cost, `Loan`, or
`PayrollAdjustment`. So manager pay is invisible to payroll records and project
cost, and there is no way to attach a manager's loans/advances/adjustments to
their name. It is the band-aid this design removes.
## Key realisation from codebase exploration
`Worker` is misleadingly named — structurally it is already *"a person we pay,
who has a salary, can take loans/advances, and gets payslips."* A manager fits
that definition exactly; the **only** difference is how the pay amount is
computed: a fixed monthly figure instead of `days_logged × daily_rate`.
Evidence the existing pipeline already supports adjustment-only payees:
- `_process_single_payment` (`core/views.py:3696`) creates a `PayrollRecord` and
emails a payslip when `log_count == 0` as long as there is a pending
adjustment.
- The payroll-dashboard pending loop (`core/views.py:3064`) includes any active
worker where `log_count > 0 OR pending_adjs`.
- `Loan`, `PayrollAdjustment`, `PayrollRecord` are all FK'd to `Worker`, so
loans/advances/history/worker-lookup work for *any* `Worker` with no new code.
The only structural risk is a manager reaching a `WorkLog` (their
`monthly_salary / 20` `daily_rate` would then inflate daily wage, outstanding,
and project labour cost). Every one of those figures is derived purely from
`WorkLog`, so if a manager can never be added to a `WorkLog`, the existing
numbers are **provably**, not hopefully, untouched.
## Approaches considered
| Approach | Summary | Verdict |
|---|---|---|
| **A — `Worker.pay_type` flag + reuse the adjustment/payment pipeline** | One discriminator field; managers are excluded from daily pickers; pay logged as a new `Salary` adjustment through the existing rails. | **CHOSEN.** ~250300 LOC, money-path code unmodified, one unified ledger. |
| B — separate `Manager` model + parallel payment flow | Conceptually clean separation. | Rejected. `Loan`/`PayrollAdjustment`/`PayrollRecord` are all `Worker`-FK'd; B forces nullable/parallel FKs + UNION/branch logic into the atomic payment chokepoint and every per-project / loans / history view (~8001200 LOC, real-money regression risk, and makes "see it all together" *harder*, not easier). |
| C — extend `ExpenseReceipt` with a salary category | Lightweight. | Rejected. Still cannot carry loans/advances (those need `Worker` + `PayrollAdjustment`); fails a core requirement. |
Decisive point for A over B: a manager's Salary/Bonus/Loan/Advance rows live in
the **same tables** as workers', so "all money paid per project" and "all loans"
remain a single query. B's separation is exactly what would make the unified
view harder.
## Decisions locked in (from the brainstorm)
| # | Question | Decision |
|---|----------|----------|
| 1 | Overall approach | **Approach A**`Worker.pay_type` flag, reuse the loan/payslip/payroll machinery. |
| 2 | How is manager pay recorded? | **New `Salary` `PayrollAdjustment` type** (additive), attributed to a Project. |
| 3 | Selectable on daily Attendance/Absence forms? | **Excluded from BOTH.** A manager can never be on a `WorkLog` or `Absence`. |
| 4 | Project attribution | **One project per payment** (default to most recent). Split-across-projects deferred (YAGNI v1). |
| 5 | Deploy gating | **Hard-stop after implementation** — Konrad verifies locally before any push. |
## §1 — Model change (zero behavioural change to existing workers)
`core/models.py`, `Worker`:
```python
PAY_TYPE_CHOICES = [
('daily', 'Daily-rated'),
('fixed', 'Fixed salary'),
]
pay_type = models.CharField(max_length=10, choices=PAY_TYPE_CHOICES, default='daily')
@property
def is_salaried(self):
return self.pay_type == 'fixed'
```
- One migration; **every existing worker defaults to `'daily'`** → identical
behaviour on day one.
- `monthly_salary` on a fixed worker is their real salary (used as the default
pay amount). `daily_rate` still exists but is never read for them (they never
reach a `WorkLog`).
## §2 — Keep managers off the daily system (the only real integration point)
Add a `pay_type='fixed'` exclusion (i.e. `.exclude(pay_type='fixed')`) at
exactly these picker layers, identified during exploration:
| Location | What it feeds |
|---|---|
| `core/forms.py:114``AttendanceLogForm` admin branch worker queryset | Daily attendance worker picker |
| `core/forms.py:106``AttendanceLogForm` supervisor branch worker queryset | Daily attendance worker picker (scoped) |
| `core/forms.py:170` — single-worker Absence form queryset | Absence picker |
| `core/forms.py:661` — multi-worker Absence form queryset | Absence picker (multi) |
| `core/views.py:588``_build_team_workers_map` worker prefetch | Attendance team→workers auto-check JS |
| `core/views.py:791` — attendance cost-estimate `Worker.objects.filter(active=True)` | Cost-estimate JS rates map |
This is the single server-side chokepoint making it **impossible** to attach a
manager to a `WorkLog` or `Absence`. Consequence: `_compute_outstanding` (wage
side), `_get_labour_costs`, `_company_cost_velocity`, daily-wage math are all
WorkLog-derived and therefore mathematically untouched.
Note: the Team edit form (`core/forms.py:427`) deliberately still lists all
workers (admin parity). Even if a manager were somehow added to `Team.workers`,
the attendance/absence exclusion above is the real safety net — team membership
alone never places a worker on a `WorkLog`.
## §3 — New `Salary` adjustment type
- `core/models.py` `PayrollAdjustment.TYPE_CHOICES`: add `('Salary', 'Salary')`
(DB value == label — no UI-vs-DB drift, unlike the `New Loan` family).
- `core/views.py` `ADDITIVE_TYPES`: add `'Salary'` (it increases net pay).
- `static/css/custom.css`: add `--badge-salary-bg` / `--badge-salary-fg`
(dark + light theme) and `.badge-type-salary`. `type_slug` already
lower-cases `'Salary'``salary`.
- Adjustments-tab Type pill-filter: a "Salary" entry appears (the filter
enumerates `TYPE_CHOICES`; confirm during implementation).
## §4 — Logging a manager payment (reuses the New-Loan rails)
A dedicated **"Pay Salary"** affordance — a button on the payroll dashboard and
on a salaried worker's worker-lookup card — opens the **existing Add-Adjustment
modal** pre-configured:
- `type = Salary`
- manager pre-selected
- `amount` defaulting to the worker's `monthly_salary` (editable — months vary)
- a **required Project** picker (default = the manager's most recent project)
- the same **"Pay Immediately"** checkbox the New-Loan / Advance flow already has
- period captured via the adjustment `date` + a `description` like
`"Salary — May 2026"` (no new period model — YAGNI)
Behaviour:
- **Pay Immediately checked** → existing `_process_single_payment` path creates
the `PayrollRecord` + `_send_payslip_email`. **No new payment code** — the
chokepoint already handles an additive adjustment with `log_count == 0`.
- **Unchecked** → sits in the pending list; the manager appears in the
payroll-dashboard pending loop automatically.
Implementation route: extend `add_adjustment` (`core/views.py:4204`) with a
`type == 'Salary'` branch modelled on the existing `New Loan` branch (standalone,
requires Project, honours "Pay Immediately"). No second payment function.
## §5 — Loans / advances / other adjustments for managers — zero new code
Once a manager is `Worker(pay_type='fixed')`, the existing Add-Adjustment /
New-Loan / Advance / Deduction / Loan-Repayment flows attach to them by `Worker`
FK and show in the worker-lookup card, Loans tab, Adjustments tab, and payroll
history with **no new code**. Critical constraint: the §2 exclusion is applied
**only** to attendance/absence pickers. The payroll modal worker pickers
(`Worker.objects.filter(active=True)` with no `pay_type` filter) keep including
managers — do **not** add the exclusion there.
## §6 — One unified ledger; "Manager / Salaried" in the UI (Path-A)
- `_build_report_context` (`core/views.py:2416`) gains a distinct
**"Management / Salaried Cost"** line per project, computed from `Salary`-typed
adjustments filtered by `project_id`, shown **alongside** — never merged into —
the WorkLog-derived daily labour cost. "All money per project" and "all loans"
remain a single query because managers' rows are already in the same tables.
- Worker list / detail / worker-lookup show a **Type** indicator
("Daily" vs "Manager / Salaried"); `WorkerForm` gains the `pay_type` select
with help text. Optional `/workers/?type=fixed` filter (nice-to-have, may
defer).
- **No DB rename.** Table stays `Worker`; UI says "Manager / Salaried." This is
another Path-A display-only entry (same pattern as `"Site Report"→
"Site Journal"`), documented in `CLAUDE.md`.
## Edge cases & error handling
- `Salary` adjustment requires a Project — validation error if missing
(same rule as `Bonus`).
- `daily → fixed` switch with existing unpaid WorkLogs: history is left
untouched (still valid, still payable); the worker just stops appearing in
*new* daily pickers. `fixed → daily`: re-appears, no cleanup needed.
- `Salary` is additive, so a manager's same-month loan repayment / deduction
still nets correctly in `_process_single_payment` (a manager with a loan gets
net pay — already handled, no special case).
- Payslip path unchanged (payslips go to the Spark Receipt address, not a
per-worker email — no new requirement introduced).
## Tests (TDD, ~1014 in `core/tests.py`)
1. `Worker.pay_type` defaults to `'daily'`; `is_salaried` reflects the value.
2. `AttendanceLogForm` (admin branch) excludes a `pay_type='fixed'` worker.
3. `AttendanceLogForm` (supervisor branch) excludes a fixed worker.
4. Single-worker Absence form excludes a fixed worker.
5. Multi-worker Absence form excludes a fixed worker.
6. `_build_team_workers_map` excludes fixed workers from the attendance map.
7. `'Salary'` is in `ADDITIVE_TYPES`.
8. `_process_single_payment` on a fixed worker whose only pending item is a
`Salary` adjustment (`log_count == 0`) → `PayrollRecord` created,
`amount_paid == salary`, payslip path invoked.
9. `add_adjustment` Salary branch: requires a Project (error without one).
10. `add_adjustment` Salary with "Pay Immediately" → `PayrollRecord` linked;
without → pending, manager appears in payroll-dashboard pending list.
11. `_build_report_context`: a `Salary` adjustment on Project P shows in P's
"Management / Salaried Cost" line.
12. Regression: with a manager present and a daily worker on a WorkLog for the
same project, the WorkLog-derived labour cost / outstanding / worker-days
are byte-for-byte what they were without the manager.
13. Regression (proves reuse): a `New Loan` adjustment attaches to a fixed
worker and flows through `_process_single_payment` with no new code.
14. Edge: switching `daily → fixed` keeps a pre-existing WorkLog payable;
the worker no longer appears in the attendance picker.
## Files touched
| File | Change |
|---|---|
| `core/models.py` | `Worker.pay_type` field + `is_salaried` property; `('Salary','Salary')` in `PayrollAdjustment.TYPE_CHOICES` |
| `core/migrations/00XX_worker_pay_type.py` | New migration (field default `'daily'`) |
| `core/forms.py` | Exclude `pay_type='fixed'` in `AttendanceLogForm` (×2 branches) + both Absence worker querysets; add `pay_type` to `WorkerForm` |
| `core/views.py` | `'Salary'` in `ADDITIVE_TYPES`; `add_adjustment` Salary branch (project required, pay-immediately, payslip — mirrors New Loan); exclude fixed in `_build_team_workers_map` + attendance cost JS; `_build_report_context` per-project salaried-cost line; worker list/detail type display |
| `static/css/custom.css` | `--badge-salary-bg/fg` (dark + light) + `.badge-type-salary` |
| Templates | Add-Adjustment modal Salary option + project field + "Pay Salary" entry points; worker list/detail "Type" indicator; report project-section salaried line |
| `core/tests.py` | ~1014 tests above |
| `CLAUDE.md` | `Worker.pay_type` + `Salary` type + Path-A label note + "managers excluded from attendance/absence pickers" rule |
| `docs/plans/parked-work.md` | "Recently shipped" entry on completion |
**Scope:** ~250300 LOC incl. tests. The atomic payment chokepoint logic is
**not modified** (only `'Salary'` added to `ADDITIVE_TYPES` + a new
`add_adjustment` type branch). Daily-worker math is provably untouched
(managers cannot reach a `WorkLog`).
## Out of scope (deliberately)
- Splitting one manager payment across multiple projects (YAGNI v1 — one
project per payment).
- A manager-specific pay-period / payslip-schedule model (the adjustment
`date` + `description` is enough for v1).
- Any change to the daily-worker attendance, absence, or payment flow.
- Renaming the `Worker` DB table or model (Path-A display-only only).
- Deep `_company_cost_velocity` integration of salaried cost beyond the
per-project report line (revisit only if trivially additive).
## Verification (manual, local — Konrad) — HARD STOP before any push
1. Create a worker with **Type = Manager / Salaried** (e.g. "Fitz"), salary set.
2. `/attendance/log/` and `/absences/log/` → Fitz does **not** appear in the
worker picker (admin and, if testable, supervisor).
3. Existing daily workers + their dashboard/report numbers are unchanged
(spot-check Outstanding + a project's Labour Cost before/after Fitz exists).
4. "Pay Salary" for Fitz → pick project, amount defaults to his salary,
Pay Immediately → payslip generated, `PayrollRecord` visible in history.
5. Add a **Loan** and a **Deduction** to Fitz → both appear in his
worker-lookup card and the Loans / Adjustments tabs; net pay correct.
6. `/report/` → Fitz's salary shows under the project's
"Management / Salaried Cost" line, **not** merged into daily labour cost.
7. Full suite green: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2`.
Then — and only then — Konrad decides whether to push to `ai-dev` + deploy.
## Branch / deploy
Build on `ai-dev`. **Do NOT push to origin or deploy** until Konrad has run the
local verification above and explicitly approves. This is a money-path change to
a live payroll system — it warrants a hands-on local check before it reaches
production, exactly like the post-attendance flow.