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:
parent
9713b89ede
commit
325c59d4a1
266
docs/plans/2026-05-15-manager-salaried-pay-design.md
Normal file
266
docs/plans/2026-05-15-manager-salaried-pay-design.md
Normal 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.** ~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.
|
||||
Loading…
x
Reference in New Issue
Block a user