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>
267 lines
15 KiB
Markdown
267 lines
15 KiB
Markdown
# 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.
|