From 325c59d4a19617df9fc37e5408a0238c39dd5d42 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Fri, 15 May 2026 18:57:53 +0200 Subject: [PATCH] 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) --- .../2026-05-15-manager-salaried-pay-design.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/plans/2026-05-15-manager-salaried-pay-design.md diff --git a/docs/plans/2026-05-15-manager-salaried-pay-design.md b/docs/plans/2026-05-15-manager-salaried-pay-design.md new file mode 100644 index 0000000..2629291 --- /dev/null +++ b/docs/plans/2026-05-15-manager-salaried-pay-design.md @@ -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.** ~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 → 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.