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>
15 KiB
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 aPayrollRecordand emails a payslip whenlog_count == 0as long as there is a pending adjustment.- The payroll-dashboard pending loop (
core/views.py:3064) includes any active worker wherelog_count > 0 OR pending_adjs. Loan,PayrollAdjustment,PayrollRecordare all FK'd toWorker, so loans/advances/history/worker-lookup work for anyWorkerwith 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:
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_salaryon a fixed worker is their real salary (used as the default pay amount).daily_ratestill exists but is never read for them (they never reach aWorkLog).
§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.pyPayrollAdjustment.TYPE_CHOICES: add('Salary', 'Salary')(DB value == label — no UI-vs-DB drift, unlike theNew Loanfamily).core/views.pyADDITIVE_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_slugalready 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
amountdefaulting to the worker'smonthly_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+ adescriptionlike"Salary — May 2026"(no new period model — YAGNI)
Behaviour:
- Pay Immediately checked → existing
_process_single_paymentpath creates thePayrollRecord+_send_payslip_email. No new payment code — the chokepoint already handles an additive adjustment withlog_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 fromSalary-typed adjustments filtered byproject_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");
WorkerFormgains thepay_typeselect with help text. Optional/workers/?type=fixedfilter (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 inCLAUDE.md.
Edge cases & error handling
Salaryadjustment requires a Project — validation error if missing (same rule asBonus).daily → fixedswitch 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.Salaryis 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)
Worker.pay_typedefaults to'daily';is_salariedreflects the value.AttendanceLogForm(admin branch) excludes apay_type='fixed'worker.AttendanceLogForm(supervisor branch) excludes a fixed worker.- Single-worker Absence form excludes a fixed worker.
- Multi-worker Absence form excludes a fixed worker.
_build_team_workers_mapexcludes fixed workers from the attendance map.'Salary'is inADDITIVE_TYPES._process_single_paymenton a fixed worker whose only pending item is aSalaryadjustment (log_count == 0) →PayrollRecordcreated,amount_paid == salary, payslip path invoked.add_adjustmentSalary branch: requires a Project (error without one).add_adjustmentSalary with "Pay Immediately" →PayrollRecordlinked; without → pending, manager appears in payroll-dashboard pending list._build_report_context: aSalaryadjustment on Project P shows in P's "Management / Salaried Cost" line.- 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.
- Regression (proves reuse): a
New Loanadjustment attaches to a fixed worker and flows through_process_single_paymentwith no new code. - Edge: switching
daily → fixedkeeps 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+descriptionis enough for v1). - Any change to the daily-worker attendance, absence, or payment flow.
- Renaming the
WorkerDB table or model (Path-A display-only only). - Deep
_company_cost_velocityintegration of salaried cost beyond the per-project report line (revisit only if trivially additive).
Verification (manual, local — Konrad) — HARD STOP before any push
- Create a worker with Type = Manager / Salaried (e.g. "Fitz"), salary set.
/attendance/log/and/absences/log/→ Fitz does not appear in the worker picker (admin and, if testable, supervisor).- Existing daily workers + their dashboard/report numbers are unchanged (spot-check Outstanding + a project's Labour Cost before/after Fitz exists).
- "Pay Salary" for Fitz → pick project, amount defaults to his salary,
Pay Immediately → payslip generated,
PayrollRecordvisible in history. - Add a Loan and a Deduction to Fitz → both appear in his worker-lookup card and the Loans / Adjustments tabs; net pay correct.
/report/→ Fitz's salary shows under the project's "Management / Salaried Cost" line, not merged into daily labour cost.- 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.