dodil/erp-brick-payroll

DODIL ERP brick: payroll

AGENTS.md

Instructions for agents working on this brick

Built from the payroll module spec (erp-method modules/payroll/module.yaml, version 1, signed 2026-10-25; BPMN in modules/payroll/signoff/payroll/). Read erp-platform's BRICKS.md before changing anything. The shape is erp-brick-expenses'. Slices A and B are built (0.2.0): the pay run from hr's extract to the journal and the payouts, then SSP and SMP, weekly pay, starters and leavers (P45 in and out), the FPS, EPS and pension file as documents handed to a capability, and the year-to-date load. See "What is left".

People's pay: correctness and privacy come first. Money is Decimal (never float), NUMERIC(18,2); rates NUMERIC(19,4); fractions are exact (fractions.Fraction) and rounded ONCE by HMRC's rule for that figure (domain/money.py names each rule).

Where a change goes

You are changing...It goes in
HMRC's method (tax codes, cumulative PAYE, NI, pension, rounding, the tax period of a pay date)domain/paye.py - and tests/test_rules.py, tests/test_weekly.py
statutory sick and maternity pay (waiting days, linked periods, the 28 weeks, SMP's 90% then flat rate)domain/statutory.py (pure) and application/statutory_pay.py (turns the facts into lines) - tests/test_statutory.py
an FPS, EPS or pension file, and who it is handed toinfrastructure/rti.py (the deterministic document), infrastructure/filing.py (the capability port: NO provider by default), application/filing.py
the previous provider's year to dateapplication/ytd.py, the reader port infrastructure/readers.py (NO reader by default)
a rate, band or thresholdNEVER code: a dated prl_statutory_rate row (seeded 2026/27 rows are to_confirm)
the run's arrows, gross to net, the gates, the journal's shapedomain/pay_run.py (MOVES is checked against process.yaml)
what a calculation reads and each person's fingerprint (staleness)application/gather.py
who may do something, or which rowsapplication/use_cases.py - a permission on the declaration (or _need), _visible_slip; never a role's name
how another module is reachedinfrastructure/seams.py only (hr incl. HR-61 identity, time-off, gl, payments)
a column or tableinfrastructure/tables.py AND schema.sql AND a new migrations/NNNNN_<name>/ (append only); tests/test_schema.py keeps them equal
the processnever here first: erp-compose change propose, a new signature, then application/process.yaml; tests/test_process.py pins the signed BPMN

Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Never read or write another brick's tables. Business time is work.now() in business_timezone (_today(repo)); audit stamps (the access log) are real time.

The rules that must not be undone (each has a test, and a mutation check was run against each)

  • One claim key per pay run or person operation: prl_period_claim (a period paid by one regular run; released only by cancel; a re-opened period is <run>-R2), prl_claim scopes approve (run), payslip_approval (run:payroll_id:calc), paid/returned (payout_ref), cancel. A loser is a 409, never a second effect.
  • Side effects only after the commit, from pending rows: approval writes prl_outbox rows hr (extract sent) and gl (the journal, derived_id("prl:<run>", "approve")) in its own transaction; the payments row (all the run's payouts, prl:<run>:<person_id>:net) is written in the transaction that records the journal posted. _drain sends them; POST /outbox/drain is the worker's tick.
  • States never go backwards (MOVES); an approved run's payslips are frozen; a recalculation writes the next calc_no (nothing deleted).
  • No self-approval, email AND sub: the preparer never approves the run; the approver's own payslip (their person in hr by sub) is approved by a peer first (approve_approver_payslip); nobody completes their own payroll record or pension membership.
  • Stale (PRL-25): approval re-reads hr, time-off and our rows; a payslip whose fingerprint changed is flagged with its cause and the run is refused not_stale until recalculated.
  • Bounds: 422 {gate: bounds} for a bad amount, percentage, date, period, tax code or reference - never a 500.
  • Visibility: payroll data only to prl:pay_run:read / prl:employee:read; an employee sees only their own payslip from its pay date; anything else is a 404; a payroll user's read of another person's payslip is written to prl_access_log.
  • Flat statements (tests/test_statement_budget.py): calculate 17, approve (with journal, payouts, documents) 44, read 4, My payslips 2 - the same for 4 or 20 payslips.

Slice B's rules (each has a test, and a mutation check was run against each)

  • Statutory pay is a function of time-off's facts and dated rows (tests/test_statutory.py): waiting days, the LEL test, the 80% cap, the PIW requirement, the 28 weeks, the fit-note cut-off, SMP's weeks and recovery are prl_statutory_rate rows read on the pay date - never code. Linked periods waive the waiting days; a day is paid only if it is a qualifying day in hr's pattern.
  • A period is paid on its own table: paye.tax_period_of (month, week, fortnight, four weeks; week 53 runs on), PERIODS, and the NI / pension bands per frequency (tests/test_weekly.py). Schedules are monthly or weekly; fortnightly and four-weekly exist in the tables only.
  • A P45 is issued once, with the leaver's final payslip approval (_issue_p45s, same transaction): prl_p45 direction out, the document, the employee left. Someone already left is taxed on 0T week 1 / month 1 and gets no second P45.
  • Nothing goes out inside the approval's transaction, and nothing goes out twice: the FPS (fps:<run>:<n>), the pension file (pension:<scheme>:<run>) and the EPS (eps:<paye ref>:<tax year>:M<nn>:<n>) are claimed by a plain INSERT on a prl_rti_submission row, built from COMMITTED rows into a deterministic document (same bytes, same sha256), and handed over by _drain from a pending outbox row. The capability port has no provider by default (a manual task with the file and its deadline); complete_manual_submission records the person's reference. Nothing here ever calls HMRC or a scheme. A rejected FPS keeps the run approved; the corrected one is n+1 (resend_fps).
  • A run takes its later arrows by itself (pr.advance, file_out.advance_run): FPS accepted -> submitted, every payout settled -> paid, journal posted and the pension file answered -> closed. Never backwards.
  • Opening figures count only once SIGNED (tests/test_ytd.py): one row per employment and tax year ytd:<payroll_id>:<tax year> (plain INSERT: loading twice writes nothing), reconciled to the penny against the provider's last FPS, signed only when every check is equal; a signature after a calculation marks the payslip stale (year_to_date).
  • Flat statements: calculate and read as before (read cap 7); approve is now 85 statements for 4 or 20 payslips (the FPS and pension file are built and handed over in the approval's drain: ~40 of them) - if approval latency matters, drain fps and pension from the worker's tick instead.

Statutory figures: checked against gov.uk on 2026-10-26 (0.2.1)

Every figure is a dated prl_statutory_rate row (migrations 00002-00004), never code. Each was checked on 2026-10-26 against gov.uk Rates and thresholds for employers 2026 to 2027; rows stay to_confirm until the vendor-published-rows rule (backlog) lands.

  • Matches gov.uk: the tax bands (20/40/45%, 37,700, 125,140); PT 242/1,048; ST 96/417; UEL 967/4,189; LEL 129; NI 8%/2% and 15%; SSP 123.25 or 80% of AWE; SMP 90% for 6 weeks then 194.32; recovery 92%; NMW 12.71 / 10.85 / 8.00; emergency code 1257L.
  • Corrected in 0.2.1: small employers' relief recovers 109%, not 103% (the spec's figure; gov.uk: 109% when last year's Class 1 total was 45,000 or less). Multi-week Class 1 bands are the weekly band times the weeks (HMRC's period tables): four-weekly PT 968, ST 384, UEL 3,868; fortnightly UEL 1,934. Auto-enrolment fortnightly upper 1,934 (DWP's pay reference period figure; four-weekly 480 / 3,867).
  • From the law, not that page: SSP from day one and no LEL test from 6 April 2026 (Employment Rights Act 2025), 3 waiting days and the LEL test before; a period of incapacity is 4+ days; 28 weeks; self-certification 7 days; SMP 39 weeks; auto-enrolment 10,000 / 6,240 / 50,270.
  • Not modelled yet: student loans (plans 1, 2, 4, 5, PGL), Scottish bands, SPP/SAP/ShPP.

Spec gaps and the choice made (do not fix the spec; record, build the smallest honest thing)

The six you named:

  • HR-62 contracted_minutes: a day is a working day when hr says contracted_minutes > 0 and the person is employed; the minutes are not used (a 4-hour day counts as a day, FTE is not applied). Proration, qualifying days for SSP and the leave-day count all use that.
  • The rerun id rule: a cancelled period is opened again as <run>-R2, -R3 (the cancelled run keeps its id); a corrected FPS is fps:<run>:<n+1>; an EPS corrected after changed facts is ...:M<nn>:<n+1>; the spec's off-cycle id -X<n> is not built (PRL-26).
  • Payroll company scope: payroll roles are NOT scoped to companies - a payroll user with prl:pay_run:read sees every company's runs (company is a filter, not a boundary). An EPS across companies that share one PAYE reference is refused (company_scope).
  • prl:scheme:write: it is in the spec's catalog but no story names a role for it; it guards put_paye_scheme, put_pay_schedule and put_pension_scheme and the default roles give it to the payroll manager only. prl:rate:write / prl:element:write have no use case yet (PRL-23).
  • Pay date vs bank holidays: the monthly pay date is the last Monday-to-Friday, the weekly one the Friday after the week; bank holidays are NOT consulted (hr's work-pattern public_holiday could be); a pay date on a bank holiday is the payroll manager's to move.
  • Rounding rules: the spec names none beyond HMRC's. domain/money.py names each: free pay up to the penny, taxable pay down to the pound, bands up to the pound, tax down to the penny, NI nearest penny with an exact half down, everything else half up ONCE per line. Chosen here: average weekly earnings to the penny half up; an SSP daily rate to four places, then x days; a SMP week's pay to the penny, a day one seventh summed exactly then rounded once; recovery half up to the penny.

Found in slice B:

  • PRL-07's example pays SSP for a 3-day sickness; a period of incapacity is 4 or more days (time-off's piw). Default follows the law (ssp_requires_piw = 1); set it to 0 from a date to follow the example (tests/test_statutory.py runs both). Decision for seemo.
  • The brief's SSP rules (waiting days, the LEL test) are the old ones: the spec (2026) pays from the first qualifying day with an 80% cap. Both are dated rows: 3 / 1 before 6 April 2026, 0 / 0 after.
  • Average weekly earnings: from this payroll's own approved payslips in the relevant period; with none there (a starter, a migrated employee) from hr's contracted pay (annual / 52), and the payslip says awe_basis: contract. Not in the spec.
  • SMP: the leave start is the first day time-off serves for the absence (looked back in 93-day windows from 11 weeks before the EWC); a start on a weekend would be a day late. Salary on maternity leave comes off pro rata by working days (so a whole month on leave is 0.00, as PRL-09 says), not at 1/260 as sickness does. Keeping-in-touch days are not paid or deducted separately. Paternity, adoption and shared parental pay are refused by name.
  • No hmrc.rti_submit or pension.submit_contributions in erp-platform's capabilities.yaml, and the platform does not read the capabilities.local.yaml the spec names: the port is infrastructure/filing.py with the spec's input shape checked before a hand-over. Move it to a CapabilitySeam when the platform has the names.
  • No human permission for completing a manual RTI task or correcting an FPS: complete_manual_submission and resend_fps use prl:pay_run:calculate.
  • FPS content: the XML is this brick's own document of RTI's facts, NOT certified against HMRC's schema; hmrc_gender and director_since (spec entity fields) are not held, so the FPS carries none. A leaver's P45 carries the identity hr serves (HR-61), not a student-loan figure (no student loans yet).
  • The emergency code is applied by a person (use_emergency_code), not silently by the calculation: PRL-02 says the run applies it with a warning.
  • YTD load: the person is taken from the payroll id (<person_id>-<n>) because hr serves no migration map for it; the reader port has no default; the first run's tax code for a migrated employee comes from the provider's report.

What is left (slices A and B are done)

Not started: PRL-03 coding notices (P6), PRL-10 auto-enrolment assessment and opt-out (membership is entered by enrol_pension), PRL-17 year end / P60, PRL-18 gl cost centre / period / company reads, PRL-19 owner controls (the settings route), PRL-22 timesheets and hourly pay (hourly lines are refused by name), PRL-23 rate maintenance (rows are entered in the table; no use case), PRL-26 off-cycle, PRL-27 HMRC liability, student loans, NI categories other than A, Scottish and Welsh tax codes, relief at source, SPP / SAP / SHPP, the 28-week SSP count across linked periods that pre-date this payroll, fortnightly and four-weekly schedules (hr's extract has no multi-week period), the nmw_met gate (no hourly pay yet), a leaver's deduction of overtaken holiday below the NMW. Partly: PRL-01 (the P45 is data, not parsed from a document), PRL-11 (payslips and P45; P60 later), PRL-25 (checked at approval, not on read). Not built: scenarios SC-PRL-01..04 as named tests (SC-PRL-01 is covered by test_filing.py's manual-task walk to closed; SC-PRL-03 by the stale tests; SC-PRL-04 needs timesheets), company scoping of payroll roles.

Backlog (decided 2026-10-26, pending the spec PR)

seemo decided these on 2026-10-26; they are written into erp-method branch spec/gaps-wave-4. Build them once that PR is signed (the module's processes need a re-sign first), not before; until then the spec on main stands.

  • Drop the ssp_requires_piw switch. SSP follows the law: a period of incapacity is 4 or more days, so a 3-day sickness pays none (PRL-07's example now says so); keep the dated rows ssp_waiting_days 0 / ssp_lel_test 0 from 2026-04-06. Statutory rows are vendor-published, never a customer edit.
  • Move document building to the tick. approve_pay_run stops building the FPS and pension file; the new step build_pay_run_documents (approved -> approved, by system) builds them from the run's pending row after the approval commits, claimed once; the EPS stays the tick's monthly pass.
  • Per-company scope. Payroll roles become company-scoped (a role row names its company codes; none = every company): prl:pay_run:read no longer sees every company's runs. Mirror hr v4's rule.
  • Permissions. complete_manual_submission uses prl:filing:complete_manual and resend_fps uses prl:fps:correct (both the payroll admin), not prl:pay_run:calculate; prl:scheme:write stays the payroll manager's.
  • The rules now in the spec (already built; keep them): working days from HR-62 contracted_minutes > 0; rerun ids -R<n> and fps:<run>:<n+1>; the named rounding rules. New: a pay date on a bank holiday (hr HR-62 public_holiday) moves to the working day before.
  • hr seam. The YTD load matches the previous provider's employees through hr's roster (HR-64) and HR-61, not <person_id>-<n> from the payroll id; the migration stages and only sign writes YTD openings.

Before you commit

pytest -q --tb=short     # rules, stories, doors, seams, invariants, budget, process, schema, composition
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_payroll/roles.yaml