38686-vm/docs/plans/2026-05-15-manager-salaried-pay-design.md
Konrad du Plessis 4d06b83e30 docs: correct Salary verification step (manual amount entry, not auto-filled)
Final whole-feature review flagged the design doc's verification
checklist step 4 over-promised an auto-filled amount. Manual entry is
intentional; corrected so Konrad's local verification expectations match
actual behaviour. Docs-only, local-only — feature still NOT pushed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:00:26 +02:00

271 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 → 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.