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 to | infrastructure/rti.py (the deterministic document), infrastructure/filing.py (the capability port: NO provider by default), application/filing.py |
| the previous provider's year to date | application/ytd.py, the reader port infrastructure/readers.py (NO reader by default) |
| a rate, band or threshold | NEVER 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 shape | domain/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 rows | application/use_cases.py - a permission on the declaration (or _need), _visible_slip; never a role's name |
| how another module is reached | infrastructure/seams.py only (hr incl. HR-61 identity, time-off, gl, payments) |
| a column or table | infrastructure/tables.py AND schema.sql AND a new migrations/NNNNN_<name>/ (append only); tests/test_schema.py keeps them equal |
| the process | never 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_claimscopesapprove(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_outboxrowshr(extract sent) andgl(the journal,derived_id("prl:<run>", "approve")) in its own transaction; thepaymentsrow (all the run's payouts,prl:<run>:<person_id>:net) is written in the transaction that records the journal posted._drainsends them;POST /outbox/drainis the worker's tick. - States never go backwards (
MOVES); an approved run's payslips are frozen; a recalculation writes the nextcalc_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_staleuntil 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 toprl_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 areprl_statutory_raterows 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_p45directionout, the document, the employeeleft. 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 aprl_rti_submissionrow, built from COMMITTED rows into a deterministic document (same bytes, same sha256), and handed over by_drainfrom a pending outbox row. The capability port has no provider by default (a manual task with the file and its deadline);complete_manual_submissionrecords 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 yearytd:<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
fpsandpensionfrom 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 sayscontracted_minutes > 0and 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 isfps:<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:readsees 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 guardsput_paye_scheme,put_pay_scheduleandput_pension_schemeand the default roles give it to the payroll manager only.prl:rate:write/prl:element:writehave 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_holidaycould 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.pynames 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.pyruns 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_submitorpension.submit_contributionsin erp-platform'scapabilities.yaml, and the platform does not read thecapabilities.local.yamlthe spec names: the port isinfrastructure/filing.pywith the spec's input shape checked before a hand-over. Move it to aCapabilitySeamwhen the platform has the names. - No human permission for completing a manual RTI task or correcting an FPS:
complete_manual_submissionandresend_fpsuseprl:pay_run:calculate. - FPS content: the XML is this brick's own document of RTI's facts, NOT certified against HMRC's schema;
hmrc_genderanddirector_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_piwswitch. 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 rowsssp_waiting_days0 /ssp_lel_test0 from 2026-04-06. Statutory rows are vendor-published, never a customer edit. - Move document building to the tick.
approve_pay_runstops building the FPS and pension file; the new stepbuild_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:readno longer sees every company's runs. Mirror hr v4's rule. - Permissions.
complete_manual_submissionusesprl:filing:complete_manualandresend_fpsusesprl:fps:correct(both the payroll admin), notprl:pay_run:calculate;prl:scheme:writestays 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>andfps:<run>:<n+1>; the named rounding rules. New: a pay date on a bank holiday (hr HR-62public_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