# 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.** ~250–300 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 (~800–1200 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, ~10–14 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` | ~10–14 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:** ~250–300 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 → modal opens pre-set to type=Salary with Pay Immediately ticked; **select Fitz, pick a project, and type the amount manually** (the amount is NOT auto-filled from his stored salary — manual entry is intentional, since the actual monthly figure can differ and the modal supports multi-select) → payslip generated (clean single Salary line, no "0 days worked"), `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.