payroll — overview
Staff payroll with encrypted bank/tax data (issue #229). Admin-only compliance module: per-staff profiles, monthly periods with a status lifecycle, raw per-employee entries, and pure-aggregation reports. No tax computation and no country rules in v1; no agent tools — the agent layer never sees payroll.
What it is
Admin-authenticated endpoints under /api/v1/payroll/ (JWT + payroll.* RBAC, admin role only). A clinic keeps one profile per staff user (salary/hourly base, currency, encrypted bank account and tax ID), opens one period per month (YYYY-MM), records raw entries (gross/deductions/net as entered), and reads monthly/annual rollups.
Routes:
GET /api/v1/payroll/profiles— list masked profilesPOST /api/v1/payroll/profiles— create a profile (201)GET /api/v1/payroll/profiles/{id}— one masked profilePATCH /api/v1/payroll/profiles/{id}— edit terms / rotate secretsGET /api/v1/payroll/periods— list periodsPOST /api/v1/payroll/periods— open a draft period (201)GET /api/v1/payroll/periods/{id}— one periodPOST /api/v1/payroll/periods/{id}/status— draft → closed → paidDELETE /api/v1/payroll/periods/{id}— delete an empty draft period (204)GET /api/v1/payroll/periods/{id}/entries— entries of a periodPOST /api/v1/payroll/entries— raw entry (201, draft only)GET /api/v1/payroll/entries/{id}— one entryPATCH /api/v1/payroll/entries/{id}— edit a draft entryDELETE /api/v1/payroll/entries/{id}— delete a draft entry (204)GET /api/v1/payroll/reports/monthly?month=— period rollupGET /api/v1/payroll/reports/annual?year=— year rollup
Data model
Three tables, all clinic_id-scoped and indexed on clinic_id:
| Table | Purpose |
|---|---|
payroll_profiles | profile + Fernet-encrypted bank/tax, unique per clinic+user |
payroll_periods | month + status, unique per clinic+month |
payroll_entries | gross/deductions/net as entered, unique per period+user |
Migration payr_0001_initial on own Alembic branch (payroll), no depends_on (core-auth FKs need none).
Service layer
ProfileService— create (user must be a member of the clinic → 404 otherwise; duplicate → 409), masked reads, replace-to-edit updates.PeriodService— strictly draft → closed → paid (409 on skips), publishespayroll.period.status_changed.EntryService— draft-period gate (409),net == gross - deductionsvalidation (422), duplicate → 409.ReportService— monthly/annual sums in the clinic currency.
Plaintext boundary
Bank/tax values are write-only: encrypted at the service boundary, never logged, published, or serialized. Responses and events carry last_4 / has_* / ids only. Rotating SECRET_KEY requires re-encrypting stored values (same trade-off as verifactu).
Tenancy
Every query filters by clinic_id. Users are global rows, so profiles and entries only accept users with a clinic_memberships row in the current clinic. Unknown or foreign users are a 404, cross-clinic ids are invisible (404, never 403).
Constraints
Own Alembic branch (payroll); manifest.depends = []. No agent tools. No hard deletes except draft corrections (issue #390): DELETE /entries/{id} and DELETE /periods/{id} (both 204, payroll.write) work only while the period is draft — 409 once closed/paid, 409 for a period that still has entries. Profiles deactivate; closed/paid records stay immutable.
See ./permissions.md and ./events.md for full detail.