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

15 KiB
Raw Blame History

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 AWorker.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_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:114AttendanceLogForm admin branch worker queryset Daily attendance worker picker
core/forms.py:106AttendanceLogForm 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.