# Payroll: the module source.
#
# PROVENANCE. Written 2026-10-25 in the wave-4 spec pass (erp-method-w4, spec/wave4-v1). Version 1 SIGNED
# by seemo 2026-10-25 (signoff/payroll/; wave 4). hr v2 hands a payroll extract to "a payroll provider (outside the platform)"; this module is
# that provider, inside the platform. UK first. Written from:
# - the neighbours' specs at erp-method v0.1.35: hr v3 (the payroll extract HR-60 with starters, leavers, pay slices
# and proration dates; payroll identity HR-61; work pattern HR-62; bank details read by payments only HR-59),
# time-off v1 (approved absences with pay treatment TOF-20; a leaver's balance TOF-22), timesheets v1 (approved
# hours and overtime TS-20), payments v6 (POST /payouts PAY-51, payout runs as Bacs files, statement matching;
# matched_kind tax_liability), gl v7 (POST /journal-entry/journals, cost centres, periods)
# - UK law and HMRC guidance: the Income Tax (Earnings and Pensions) Act 2003 and the Income Tax (Pay As You Earn)
# Regulations 2003 (reg. 67B: a Full Payment Submission on or before each payment; reg. 67D: the Employer Payment
# Summary; reg. 97: records kept 3 years after the tax year); HMRC CWG2 Further guide to PAYE and NICs and the RTI
# specifications (FPS, EPS); the Social Security Contributions and Benefits Act 1992 (Class 1 NICs; Part XI SSP;
# Part XII SMP) and the Statutory Maternity Pay (General) Regulations 1986; the Employment Rights Act 2025 (SSP
# from the first day of sickness and without the lower earnings limit, from 2026-04-06; to confirm against the
# commencement order); the Pensions Act 2008 ss. 3, 5, 8 and 13 (auto-enrolment, opt-out, qualifying earnings);
# the Employment Rights Act 1996 s. 8 (an itemised pay statement); the National Minimum Wage Act 1998; PAPDIS (the
# Payroll and Pension Data Interface Standard) for contribution files
# - SAP HCM Payroll GB (PY-GB: schemas, wage types, RTI, the posting to FI) and SuccessFactors Employee Central
# Payroll as parity references only; there is no Odoo UK payroll worth migrating from, so the migration source is
# the previous provider's year-to-date figures
#
# The 2026/27 figures in the stories are DATED ROWS a payroll manager checks against HMRC's published rates before
# the first run; the code holds the method, never a rate. Rates used here: personal allowance 12,570 (code 1257L:
# 12,579 of free pay a year in HMRC's tables), basic rate 20% to 37,700, higher 40% to 125,140, additional 45%;
# Class 1 primary threshold 12,570 (1,048 a month, 242 a week), upper earnings limit 50,270 (4,189 a month),
# employee 8% / 2%; secondary threshold 5,000 (417 a month, 96 a week), employer 15%; employment allowance 10,500;
# auto-enrolment trigger 10,000, qualifying earnings 6,240 to 50,270 (520 to 4,189 a month, 120 to 967 a week);
# SSP 123.25 a week, SMP 194.32 a week after the first six weeks (both to confirm for 2026/27).
#
# The decisions a payroll manager checks first:
# - a run is calculated by one person and approved by another (email AND sub); the approver's own payslip is
# approved by a third person, never inside their own approval
# - a payslip is a pure function of what hr, time-off and timesheets answered and the dated rows in force; when any
# of them changes before approval the payslip is marked stale and must be recalculated; after approval nothing is
# recalculated: a correction is the next period's (cumulative tax corrects itself) or an off-cycle run
# - HMRC is reached only through a capability (hmrc.rti_submit): no live HMRC call is built here; without a provider
# the FPS is a file and a manual task, with the deadline (on or before the pay date) shown
# - net pay is paid by payments as payouts (one Bacs run per pay date); the bank details never reach this module
# - one journal per approved run to gl; the HMRC and pension liabilities are cleared by the bank lines payments
# matches, with no route needed
# Version 2 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 1 stays the signed one until it is signed):
# - permissions: prl:scheme:write says what it guards (the PAYE scheme, pay schedules, pension schemes; the
# payroll manager); prl:filing:complete_manual records a manual FPS / EPS / pension task done; prl:fps:correct
# sends a corrected FPS (both the payroll admin; the build borrowed prl:pay_run:calculate)
# - hr seam (decided answer 2): the YTD load (PRL-24) matches the previous provider's employees through HR-64 and
# HR-61 (was the person taken from the payroll id); HR-63 for the company and work email by person id
# - PROCESS change (the migration run, seemo's decided answer 4, pattern §11): load writes staging rows only; sign
# is the first write to the ledger, through the normal use cases; discard deletes staging rows only; a signed
# run is reversed, never deleted. Effects added; no step renamed or removed
# - SSP follows the law (decided answer 1): PRL-07's 3-day sickness pays no SSP (a period of incapacity is 4 or
# more days); a 4-day example shows the SSP; from 2026-04-06 no waiting days and no LEL test, as dated rows; the
# ssp_requires_piw switch goes
# - statutory rates are vendor-published dated rows, never the customer's (decided answer 1)
# - PROCESS change (pay_run): build_pay_run_documents, a new step after the approval commits (the worker tick)
# builds the FPS and the pension file; approve_pay_run no longer names them (decided answer 7). No step renamed
# or removed
# - rules written as decisions: working days from HR-62 contracted_minutes > 0; rerun ids (-R<n>, -X<n>,
# fps:<run>:<n+1>, EPS :M<nn>:<n+1>); the pay date moves to the previous working day on a bank holiday; the
# named rounding rules
id: payroll
name: Payroll
short: Payroll
version: 2
maturity: tested # version 1 signed by seemo 2026-10-25; erp-brick-payroll 0.2.0 builds it (slices A and B, tested, not deployed); the latest version is a draft for re-sign
summary: >-
Pay your people correctly and on time: monthly and weekly pay runs from the HR record, holidays, sickness and
approved hours, with tax, National Insurance, pension and statutory pay worked out for you. HMRC gets its
submission on payday, employees get their payslips, P45s and P60s, the bank pays the net, and the books get one
clean journal for every run.
decisions:
- proposed: "payroll reads hr's payroll extract (HR-60) for pay, starters and leavers; it keeps no second copy of salaries and marks the extract sent when the run is approved"
status: "for sign-off"
- proposed: "two approvals of money leaving the bank: the run in payroll (payroll manager), then the payout batch in payments (finance); the owner may let payments trust payroll's approval (payouts_auto_approve_source)"
status: "for sign-off: question for the reference customer (customer_varies)"
- proposed: "corrections after approval go into the next regular period by default; an off-cycle run (with its own FPS) is for a missed starter or a payment that cannot wait"
status: "for sign-off"
- proposed: "RTI (FPS, EPS) and pension contribution files go out through capabilities; with no provider each is a file and a manual task with its deadline"
status: "for sign-off"
- decided: "statutory sick pay follows the law: SSP is paid only in a period of incapacity for work (4 or more consecutive days, linked spells within 8 weeks); a 3-day sickness pays none. From 2026-04-06 there are no waiting days and no lower earnings limit test (dated rows). This is not a setting (v1's builder offered ssp_requires_piw; it goes)"
status: "seemo 2026-10-26, decided answer 1 (SAP: statutory rules are delivered, not configured); for re-sign"
- decided: "statutory rates, bands and thresholds are vendor-published dated rows (prl_statutory_rate), never edited by the customer"
status: "seemo 2026-10-26, decided answer 1 (SAP: legal changes ship as support packages); for re-sign"
- decided: "the FPS, the EPS and the pension file are built in a step after the approval commits (build_pay_run_documents, by the worker tick), never inside the approval transaction"
status: "seemo 2026-10-26, decided answer 7 (SAP: the posting run and the RTI/pension files are separate steps after the payroll is released); for re-sign"
- decided: "a working day is a day hr's work pattern (HR-62) answers with contracted_minutes > 0 while employed; proration, SSP qualifying days and the leave-day count count days, never minutes (a 4-hour day is a day; FTE is already in the salary)"
status: "for re-sign (the rule erp-brick-payroll 0.2.0 builds)"
- decided: "rerun ids: a cancelled run keeps its id and its period is opened again as <run>-R<n> (n from 2); an off-cycle run is <regular>-X<n>; a corrected FPS is fps:<run>:<n+1>; an EPS corrected after changed facts is <eps>:M<nn>:<n+1>"
status: "for re-sign (the rule erp-brick-payroll 0.2.0 builds; -X<n> is not built yet)"
- decided: "pay date: monthly, the last working day of the month; weekly, the Friday after the week; a pay date on a bank holiday of the company's holiday calendar (hr HR-62 public_holiday) moves to the previous working day; a manual move is the payroll manager's"
status: "seemo 2026-10-26 (SAP: the payroll calendar's pay date shifted by the factory calendar); for re-sign"
- decided: "named rounding rules (domain/money.py): free pay up to the penny; taxable pay down to the pound; tax bands up to the pound; tax down to the penny; NI to the nearest penny, an exact half down; average weekly earnings to the penny half up; an SSP daily rate to four places, then x days, rounded once; a SMP week to the penny, a day one seventh summed exactly then rounded once; recovery half up to the penny; everything else half up once per line"
status: "for re-sign (HMRC's where HMRC names one; the rest as erp-brick-payroll 0.2.0 builds them)"
defaults_to_confirm: # the business would choose these; chosen here so there is something to react to (seemo)
- "monthly schedule: calendar-month pay periods, pay date the last working day of the month (2026-11-30); weekly schedule: Monday to Sunday worked, paid the Friday after, a week in arrears"
- "part-period pay prorated by working days in the period from hr's work pattern (alternatives: calendar days, 260ths)"
- "a day's pay for unpaid leave and sickness deductions = annual salary / 260 (NUMERIC 19,4), rounded once per line"
- "sick pay: statutory only (SSP); a company sick pay scheme is a dated row the customer adds"
- "maternity: statutory only (SMP); enhanced maternity pay is a scheme row"
- "pension: one qualifying scheme, qualifying earnings basis, employee 5%, employer 3%, net pay arrangement; relief at source is the alternative row; no postponement"
- "not a small employer for SMP recovery (92%); small employers' relief (103%) when the owner confirms the last tax year's Class 1 NICs were at most 45,000"
- "employment allowance claimed (10,500) when the owner confirms eligibility"
- "two approvals of salary payouts (payroll run, then payments batch); payouts_auto_approve_source off"
- "approval at least 3 working days before the pay date (Bacs); an FPS on or before the pay date"
- "corrections in the next period; off-cycle runs allowed for payroll managers"
- "a payslip and P60 are published to the employee's self-service on the pay date; nothing is emailed"
- "holiday owed to a leaver is paid in lieu at the day rate; holiday overtaken is deducted only where the contract allows (deduct_overtaken_holiday false)"
facets:
domain: people
industries: [any]
customer_words:
- "payroll"
- "run the payroll"
- "payslips"
- "PAYE"
- "RTI"
- "FPS"
- "auto-enrolment"
- "P45"
- "P60"
- "statutory sick pay"
- "maternity pay"
- "bring payroll in-house"
- "Lohnabrechnung"
replaces: [the accountant's payroll bureau, Sage Payroll, BrightPay, Moneysoft, Xero Payroll, a payroll spreadsheet, HMRC Basic PAYE Tools]
pillars: [sql]
first_module_fit: medium
depends_on:
- module: hr
why: who is employed, on what pay, since when and until when (the payroll extract), the identity HMRC needs, and the work pattern for proration all come from hr
required: true
- module: gl
why: every approved run posts one journal; cost centres and periods are gl's
required: recommended
- module: payments
why: net pay is paid as payouts in a Bacs run and confirmed from the statement; without payments finance pays by hand and marks the payslips paid
required: recommended
- module: time-off
why: unpaid leave, sickness (SSP) and maternity (SMP) reach the payslip from approved absences
required: recommended
- module: timesheets
why: hourly-paid and overtime hours come from approved timesheets
required: optional
feeds: [gl, payments, hr]
owns_master_data: [pay_element]
references_master_data: [person, company, cost_centre, absence, approved_hours, business_partner]
actors:
- id: payroll_admin
name: Payroll administrator
does: keeps each employee's tax code, NI category, starter declaration and pension membership; opens and calculates runs; records HMRC coding notices; fixes what the calculation flags
sees: every payroll record and payslip of their companies
permissions: [prl:employee:read, prl:employee:write, prl:tax_code:write, prl:pay_run:read, prl:pay_run:calculate, prl:filing:complete_manual, prl:fps:correct, prl:pay_run:open, prl:document:read, prl:rti:read, prl:pension:write, prl:payslip:read_own, prl:migration:read]
note: "a payroll admin never calculates and approves the same run, and never changes their own tax code or pay (email AND sub)"
- id: payroll_manager
name: Payroll manager / finance director
does: approves pay runs, approves the approver's own payslip when a peer is the approver, runs off-cycle payments, keeps elements, schemes and statutory rates, reads the HMRC liability
sees: every payroll record and payslip of their companies
permissions: [prl:employee:read, prl:pay_run:read, prl:pay_run:approve, prl:pay_run:off_cycle, prl:pay_run:cancel, prl:element:write, prl:rate:write, prl:scheme:write, prl:document:read, prl:rti:read, prl:liability:read, prl:payslip:read_own, prl:migration:read]
- id: employee
name: Anyone on the payroll
does: reads their own payslips, P60s and P45, and their tax code and pension membership; opts out of the pension
sees: only their own payroll documents
permissions: [prl:payslip:read_own, prl:pension:opt_out_own]
- id: owner
name: The owner (or whoever the owner names)
does: holds the controls (who may approve, the small employer and employment allowance claims, whether payments trusts payroll's approval, the proration method), signs the year-to-date migration
sees: everything
permissions: [prl:pay_run:read, prl:liability:read, prl:settings:controls, prl:migration:read, prl:migration:sign]
note: "anyone else holding prl:settings:controls is a configuration error the access test refuses"
- id: system
name: The system
does: opens periods, reads hr, time-off and timesheets, marks payslips stale, posts journals, requests payouts, sends FPS, EPS and pension files through capabilities, publishes payslips, P45s and P60s, runs the year end, takes payments' confirmations, loads year-to-date figures
sees: everything, through its own service account
permissions: [prl:pay_run:open_auto, prl:payslip:derive, prl:journal:post_auto, prl:payout:request, prl:payout:mark_paid, prl:rti:submit, prl:pension:submit, prl:document:publish, prl:year_end:run, prl:migration:load, prl:migration:reconcile]
note: "exact grants, never a wildcard: prl:payout:mark_paid to payments' service client alone. This module's own client holds hr:employment:read, hr:work_pattern:read, hr:payroll_extract:read, hr:payroll_extract:mark_sent, hr:payroll_identity:read, tof:absence:read_for_payroll, tof:balance:read_for_payroll, ts:hours:read_approved, pay:payout:intake, gl:journal:post, gl:period:read, gl:cost_centre:read and gl:company:read"
stories:
# ── employees on the payroll ────────────────────────────────────────────────────────────
- id: PRL-01
kind: person
actor: payroll_admin
story: As a payroll administrator, I want each starter on the payroll with the tax code, NI category and pension membership HMRC and the scheme expect, so that the first payslip is right.
given: 'hr''s extract for UK01|2026-11 names starter 4450 (start 2026-11-16, salary 30,000.00 a year, employee, monthly schedule); they bring a P45 from their last employer: tax code 1257L, leaving date 2026-10-31, pay to date 15,200.00, tax to date 1,363.40'
when: I complete their payroll record
then: >-
a payroll employee 4450-1 (payroll_id "4450-1", the HMRC payroll ID of the employment) with tax code 1257L
cumulative from 2026-11-16 (source p45), the previous pay and tax to date kept as P45 figures (never added to this
employer''s year to date, used only in the cumulative calculation), NI category A (age 22 to state pension age, no
other category applies), starter declaration not needed (a P45 was given), student loan none; the record is
dated rows; a starter with no P45 gives a starter declaration instead (PRL-02); a record with no tax code is
refused by the run with the person named
screen: Employees
reference: not built
- id: PRL-02
kind: person
actor: payroll_admin
story: As a payroll administrator, I want the tax code for a starter without a P45 set from their starter declaration, so that they pay the right tax until HMRC sends a code.
given: 'three starters without a P45 in 2026-11: 4451 statement A (first job since 6 April), 4452 statement B (only job now, had another since 6 April), 4453 statement C (has another job or a pension)'
when: I record each statement
then: 'tax codes 1257L cumulative (A), 1257L month 1 (B), BR (C), each dated from the start date with source starter_checklist; no statement by the first run gives 1257L month 1 (the emergency rule) and a warning on the run; the statement letter goes on the first FPS'
screen: Employees
reference: not built
- id: PRL-03
kind: person
actor: payroll_admin
story: As a payroll administrator, I want a tax code change from HMRC recorded from the date HMRC says, so that the next payslip uses it and the past is never rewritten.
given: 'employee 4410 on 1257L cumulative; an HMRC P6 coding notice issued 2026-11-10 changes it to 1100L, to be used from the next pay day'
when: I record the notice (or the HMRC data provisioning capability delivers it)
then: 'a dated tax code row 1100L cumulative from 2026-11-10 with source p6 and the notice reference; the November run (pay date 2026-11-30) uses it and the cumulative calculation refunds or collects the difference; an approved payslip is never recalculated; I cannot change my own tax code (email AND sub)'
screen: Employees
reference: not built
# ── calculating and approving ───────────────────────────────────────────────────────────
- id: PRL-04
kind: person
actor: payroll_admin
story: As a payroll administrator, I want the monthly run calculated from the HR record with every figure shown, so that I check a payslip by reading it, not by redoing it.
given: >-
run UK01-M-2026-27-M08 (monthly, period 2026-11, pay date 2026-11-30, tax month 8: 6 November to 5 December);
employee 4410: salary 36,000.00 a year (hr), tax code 1257L cumulative, NI category A, pension 5% employee / 3%
employer on qualifying earnings by net pay arrangement, paid the same every month since April (taxable pay to
date at month 7: 20,132.00, tax to date 2,558.80); starter 4450 from 2026-11-16 at 30,000.00 (PRL-01)
when: I calculate the run
then: >-
4410: gross 3,000.00; pension on qualifying earnings 3,000.00 - 520.00 = 2,480.00: employee 124.00, employer
74.40; taxable pay 2,876.00, to date 23,008.00, free pay to date 8,386.00 (1257L, month 8), taxable to date 14,622
(rounded down to the pound), tax to date 2,924.40, tax this month 365.60; employee NI (3,000.00 - 1,048.00) x 8% =
156.16, employer NI (3,000.00 - 417.00) x 15% = 387.45; net pay 3,000.00 - 124.00 - 365.60 - 156.16 = 2,354.24;
cost to the employer 3,461.85. 4450: 2,500.00 x 11 / 21 working days (16 to 30 November in hr''s work pattern) =
1,309.52. Each payslip line names its source (hr extract line, absence_ref, sheet) and the rate row it used; the
calculation is a pure function, so calculating again gives the same payslips and replaces the previous draft
screen: Pay runs
path: { process: payroll.pay_run, choices: { Gateway_pay_run_stale: fresh } }
reference: not built
- id: PRL-05
kind: person
actor: payroll_admin
story: As a payroll administrator, I want the weekly run for hourly staff paid from their approved hours, so that nobody is paid for time that was not approved or missed for time that was.
given: >-
run UK01-W-2026-27-W33 (weekly, worked 2026-11-09 to 2026-11-15, pay date Friday 2026-11-20, tax week 33);
worker 4470: 13.0000 an hour (hr), statement C so tax code BR, NI category A, eligible jobholder; timesheets
approved 32.00 hours for 2026-W46
when: I calculate the run
then: >-
gross 32.00 x 13.0000 = 416.00; qualifying earnings 416.00 - 120.00 = 296.00: employee pension 14.80, employer
8.88; taxable 401.20 x 20% (BR) = 80.24; employee NI (416.00 - 242.00) x 8% = 13.92, employer NI (416.00 - 96.00)
x 15% = 48.00; net 416.00 - 14.80 - 80.24 - 13.92 = 307.04; the payslip shows the hours and the rate (an
itemised statement for a time worker); hours approved after the calculation mark the payslip stale (PRL-25)
screen: Pay runs
reference: not built
- id: PRL-06
kind: person
actor: payroll_manager
story: As the payroll manager, I want to approve a run that somebody else calculated, so that no one person can pay themselves or anyone else unchecked.
given: 'run UK01-M-2026-27-M08 calculated by payroll admin A (sub a-1); 46 payslips, net 98,412.60; one of them is my own (payroll manager M, person 2101); the pay date is 2026-11-30'
when: 'I try to approve the run on 2026-11-25; my peer payroll manager P approves my payslip; I approve the run'
then: >-
gates: no payslip stale, every employee with a tax code, every hourly rate at or above the national minimum wage
for the worker''s age band on the pay date (a dated row), no net pay below 0.00, approval at least
bacs_lead_working_days (3) before the pay date, the approver is not the preparer (email AND sub), and the
approver''s own payslip already approved by a peer; my first try is refused {gate: own_payslip_by_peer}; P
approves my payslip (approve_approver_payslip), then my approval approves the run; after commit (from pending
rows): hr''s extract is marked sent, the journal posts (PRL-16), the payouts are requested (PRL-12), the FPS is
sent (PRL-13), the pension contributions are sent (PRL-15); approved payslips are frozen
screen: Pay runs
reference: not built
- id: PRL-07
kind: person
actor: payroll_admin
story: As a payroll administrator, I want approved sickness and unpaid leave turned into deductions and statutory sick pay, so that the payslip follows what time-off approved.
given: >-
time-off: 4410 sick 2026-11-10 to 2026-11-12 (3 days, self-certified, pay treatment sick); 4412 sick 2026-11-16
to 2026-11-19 (4 days, a period of incapacity for work); 4480 unpaid leave on 2026-11-16 (1 day, salary
26,000.00); 4410 and 4412 on 36,000.00 a year, average weekly earnings 692.31; company sick pay none (statutory
only); SSP 123.25 a week from 2026-04-06, payable from the first qualifying day of a period of incapacity (4 or
more consecutive days), the lower of the rate and 80% of average weekly earnings, with no waiting days and no
lower earnings limit test from that date (dated rows ssp_waiting_days 0 and ssp_lel_test 0 from 2026-04-06; 3
and 1 before)
when: the November run is calculated
then: >-
4410: sickness deduction 3 x 138.4615 (36,000.00 / 260) = 415.38 and no SSP: 3 days are not a period of
incapacity for work (v2: the law, not a setting; the v1 example paid 73.95, which the law does not), unless a
later spell within 8 weeks links to it; gross 3,000.00 - 415.38 = 2,584.62; 4412: sickness deduction 4 x
138.4615 = 553.85, SSP 4 x 24.65 (123.25 / 5 qualifying days) = 98.60 (80% of 692.31 is 553.85, above 123.25, so
the flat rate applies); gross 3,000.00 - 553.85 + 98.60 = 2,544.75; tax, NI and pension follow from each; 4480: unpaid deduction 1 x 100.0000 (26,000.00 / 260) = 100.00; each line names its
absence_ref and revision; SSP is never paid on a day that is not a qualifying day in hr''s pattern
screen: Pay runs
reference: not built
customer_varies: "a company sick pay scheme (full pay for N days, then SSP) is a dated scheme row; SSP is offset against it"
- id: PRL-08
kind: person
actor: payroll_admin
story: As a payroll administrator, I want a leaver's final pay worked out with their untaken holiday and a P45 produced, so that they leave paid in full and with the right paperwork.
given: 'leaver 4490 in hr''s extract for UK01|2026-10 (last day 2026-10-16, salary 36,000.00, 1257L cumulative); time-off answers 4.79 days of annual leave owed on the last day (TOF-22)'
when: the October run (pay date 2026-10-30) is calculated and approved
then: >-
salary to the last day 3,000.00 x 12 / 22 working days = 1,636.36; holiday in lieu 4.79 x 138.4615 = 663.23; gross
2,299.59; the FPS carries the leaving date 2026-10-16; on approval a P45 (parts 1A to the employee, the figures to
date) is produced as a document and the payroll employee moves to left; a payment after the P45 (a late bonus) is
taxed on 0T month 1 and reported with the leaving date; a negative holiday balance is deducted only when
deduct_overtaken_holiday is set, and never below the national minimum wage
screen: Pay runs
reference: not built
- id: PRL-09
kind: person
actor: payroll_admin
story: As a payroll administrator, I want maternity pay assessed and paid from the facts time-off recorded, so that SMP is right and the company recovers it from HMRC.
given: >-
time-off: 4431 maternity from 2026-11-02, expected week of childbirth from Sunday 2026-11-15, MATB1 received
2026-06-01; hr: salary 33,800.00 a year, employed since 2023; her two paydays in the relevant period paid 2,816.67
each; SMP 90% of average weekly earnings for 6 weeks, then the lower of 194.32 and 90%; recovery 92% (not a small
employer)
when: the November run is calculated
then: >-
eligibility met (26 weeks'' service by the qualifying week starting 2026-08-02; average weekly earnings 5,633.34 x 6
/ 52 = 650.00, above the lower earnings limit row); 90% = 585.00 a week; November: 4 whole weeks (2 to 29 November)
2,340.00 plus 1 day of week 5 at 585.00 / 7 = 83.57, total 2,423.57; salary 0.00 for days on maternity leave
(statutory only); weeks 7 to 39 at 194.32 are scheduled on the payroll record; the recovery 2,229.68 (92%) goes on
the EPS for tax month 8 (PRL-14); an absence change in time-off (an earlier birth) marks the payslip stale
screen: Pay runs
reference: not built
- id: PRL-10
kind: person
actor: payroll_admin
story: As a payroll administrator, I want each person assessed for pension auto-enrolment every pay period, and opt-outs refunded, so that we meet our duties without a spreadsheet.
given: 'scheme UK01-QS (qualifying earnings, 5% / 3%, net pay arrangement, no postponement); starter 4450 aged 31 earning 1,309.52 in November (above the monthly trigger 833.00); employee 4460 enrolled on 2026-10-01 opts out on 2026-10-20 (within one month of enrolment)'
when: the November run is calculated
then: >-
4450 is assessed eligible jobholder and enrolled from 2026-11-16 (the enrolment communication and the scheme''s
enrolment record go with the contribution file, PRL-15); 4460''s October contributions (employee 62.00, employer
37.20) are refunded to 4460 through the November payslip (employee part, taxable pay adjusted) and the scheme is
told; an opt-out after one month ceases membership from the next period with no refund; an entitled or
non-eligible worker may ask to join; re-enrolment every three years is a job on the scheme
screen: Employees
reference: not built
- id: PRL-11
kind: person
actor: employee
story: As an employee, I want my payslips, P60s and P45 in one place, so that I can prove my income without asking anyone.
given: 'employee 4410 with payslips for 2026-04 to 2026-11 and a P60 for 2025-26'
when: I open My payslips on 2026-11-30
then: >-
the November payslip appears on the pay date (never before: publish_on pay_date) with every line, the year to date
and the tax code; the P60 for 2025-26 is there; each is a stored document (raw/payroll/UK01/2026-27/payslip/4410/
UK01-M-2026-27-M08.pdf); I see only my own; a payroll admin opening my payslip is recorded in the access log
screen: My payslips
reference: not built
- id: PRL-26
kind: person
actor: payroll_manager
story: As the payroll manager, I want an off-cycle payment for someone the regular run missed, so that a starter is paid this week rather than next month.
given: 'run UK01-M-2026-27-M08 approved; starter 4454 (start 2026-11-23) reached hr after the run was approved; pay date of the off-cycle 2026-11-30'
when: I open an off-cycle run for 4454 and approve it after admin A calculates it
then: >-
run UK01-M-2026-27-M08-X1 (kind off_cycle, the same tax period) with one payslip calculated cumulatively with the
regular run; its own FPS, payouts and journal; the person may be in only one regular run per period
(prl_period_claim), so a second regular run for M08 is refused 409; an off-cycle run never contains a person
already paid in the regular run unless the payslip says why (a correction)
screen: Pay runs
reference: not built
- id: PRL-23
kind: person
actor: payroll_manager
story: As the payroll manager, I want the statutory rates and thresholds kept as dated rows, so that a new tax year is entered and checked, never programmed.
given: 'rates for 2026-27 from 2026-04-06; HMRC publishes 2027-28 rates'
when: 'I enter the 2027-28 rows from 2027-04-06 (tax bands for the rest of the UK and for Scotland, NI thresholds per frequency, SSP, SMP, the lower earnings limit, auto-enrolment thresholds, student loan thresholds, the national minimum wage bands)'
then: 'each is a row with valid_from after today and its source named; a run uses the rows in force on its pay date; a row that leaves a band empty (a threshold above the next one) is refused 422 {gate: bands_ordered}; a payslip shows which rows it used'
screen: Settings
reference: not built
- id: PRL-27
kind: person
actor: payroll_manager
story: As the payroll manager, I want what we owe HMRC for each tax month and when it is due, so that it is paid by the 22nd and matches the bank.
given: 'tax month 8 (6 November to 5 December 2026): runs M08 and W31 to W34 approved; SMP recovered 2,229.68; employment allowance remaining 0.00 (used by July)'
when: I open HMRC liability for 2026-27 month 8
then: >-
tax + employee NI + employer NI + student loans of every run whose pay date falls in the tax month, less SMP
recovered, less employment allowance used, with each run listed; due 2026-12-22 (paid electronically); the payment
from the bank is matched in payments by its reconciliation rule to purpose paye_ni_payable (matched_kind
tax_liability), with no route between the two modules; the month shows paid when the gl balance of
paye_ni_payable for it is zero
screen: HMRC
reference: not built
- id: PRL-19
kind: person
actor: owner
story: As the owner, I want the payroll controls held by me, so that nobody can switch off the second approval of salaries.
given: 'payouts_auto_approve_source off; proration working_days; small_employer_relief false'
when: 'the payroll manager tries to turn payouts_auto_approve_source on; I set small_employer_relief true from 2027-04-06 after confirming 2026-27''s Class 1 NICs were 38,400.00'
then: 'the first is refused 403 (prl:settings:controls); mine is a dated row after today with my note; runs before 2027-04-06 keep 92% recovery'
screen: Settings
reference: not built
- id: PRL-24
kind: person
actor: owner
story: As the owner moving payroll in-house mid-year, I want each employee's year-to-date figures from the previous provider loaded and reconciled, so that cumulative tax and the P60 are right.
given: 'the previous provider''s last FPS for 2026-27 month 7 and its year-to-date report: 46 employees, taxable pay to date 811,432.00, tax 98,220.40, employee NI 52,118.24, employer NI 101,903.71'
when: the year-to-date load runs, reconciles and I sign
then: >-
one opening row per payroll employee and tax year keyed "ytd:<payroll_id>:2026-27" with taxable pay, tax, NI per
category letter, employer NI, statutory payments and pension to date; the totals equal the provider''s FPS to the
penny or the run is not signable; the first run here (M08) calculates cumulatively from them; the payroll IDs are
kept as HMRC knows them (or changed with the old ID on the first FPS); loading twice writes nothing
screen: Migration
reference: not built
# ── system ──────────────────────────────────────────────────────────────────────────────
- id: PRL-16
kind: system
actor: system
trigger: when a pay run is approved
story: The system posts the run's journal, so that pay cost, the HMRC and pension liabilities and the net pay owed are in the books on the pay date.
given: 'run UK01-M-2026-27-M08 approved; for 4410 alone (cost centre SALES-N): gross 3,000.00, employer NI 387.45, employer pension 74.40, tax 365.60, employee NI 156.16, employee pension 124.00, net 2,354.24'
when: the post runs, and runs again after a timeout
then: >-
gl POST /journal-entry/journals with journal_id derived_id("prl:UK01-M-2026-27-M08", "approve"), source_module
payroll, journal_date 2026-11-30 (the pay date), lines summed per purpose and cost centre: debit gross_pay
3,000.00, employer_ni 387.45, employer_pension 74.40 (SALES-N); credit paye_ni_payable 909.21 (365.60 + 156.16 +
387.45), pension_payable 198.40 (124.00 + 74.40), net_pay_payable 2,354.24; 3,461.85 each side; the retry is gl''s
replay; a closed period leaves the journal pending with gl''s reason and holds the payouts until it posts
reference: not built
- id: PRL-17
kind: system
actor: system
trigger: after the last pay date of the tax year (5 April), in the composition's worker
story: The system closes the tax year, so that P60s are out by 31 May and the new year starts from zero.
given: 'tax year 2026-27 ends 2027-04-05; 44 employees employed on 2027-04-05; the last runs M12 and W52 approved'
when: the year-end pass runs on 2027-04-06, and again
then: >-
the last FPS of each schedule was sent with the final submission indicator; 44 P60s (keyed "p60:<payroll_id>:2026-27")
are produced and published by 2027-05-31; week1/month1 codes are not carried into 2027-28 (they become cumulative)
unless HMRC says otherwise; year-to-date figures start at zero for 2027-28; the second pass writes nothing
reference: not built
- id: PRL-25
kind: system
actor: system
trigger: when hr's extract, an absence, approved hours, a tax code or a rate row changes for a person in a calculated, not yet approved run
story: The system marks payslips stale when what they were calculated from changes, so that nobody approves a payslip built on yesterday's facts.
given: 'run M08 calculated on 2026-11-24; on 2026-11-25 hr records a pay change for 4410 from 2026-11-16 and time-off extends 4425''s sickness'
when: payroll reads hr's extract and time-off's absences with changed_since
then: 'the payslips of 4410 and 4425 are flagged stale with the cause; approval is refused {gate: not_stale} until they are recalculated; an approved payslip is never flagged: the change lands in the next period as a retro line (hr) or an absence correction'
reference: not built
# ── integration ─────────────────────────────────────────────────────────────────────────
- id: PRL-12
kind: integration
actor: system
trigger: after a run is approved and its journal posted
story: Net pay is paid by payments as one payout per payslip in a Bacs run on the pay date, and each payslip is paid when the bank says so.
given: 'run M08 approved with 46 payslips, net 98,412.60, pay date 2026-11-30; 4410 net 2,354.24 to person partner bp 9410'
when: payroll requests the payouts, twice, and later payments confirms them
then: >-
46 payouts keyed "prl:UK01-M-2026-27-M08:<person_id>:net" with batch_ref UK01-M-2026-27-M08, due_on 2026-11-30,
clearing_purpose net_pay_payable; the repeat is payments'' replay; payments approves the batch (its approver not a
payee in it), exports the Bacs file, reads each bank account from hr itself, and calls payroll''s POST
/pay-runs/UK01-M-2026-27-M08/payouts/{payout_ref}/paid; the run is paid when every payout is paid or returned; a
returned payout leaves the payslip unpaid with the bank''s reason for a new payout (…:net:2); a payout is never
requested for a net of 0.00
owner: payments
reference: not built
- id: PRL-13
kind: integration
actor: system
trigger: after a run is approved, on or before its pay date
story: The full payment submission goes to HMRC through a capability on or before payday, so that HMRC knows what each person was paid and deducted.
given: 'run M08 approved on 2026-11-25; PAYE reference 123/AB45678, accounts office reference 123PA00045678; identity of each person from hr (HR-61)'
when: payroll calls the capability hmrc.rti_submit with kind fps, and again after a timeout
then: >-
the FPS (tax year 2026-27, tax month 8, payment date 2026-11-30, per employee the payroll ID, NI number or address,
name, date of birth, gender as HMRC holds it, tax code and basis, starter or leaver details, pay and deductions in
the period and to date) is written as an object raw/payroll/UK01/rti/fps/UK01-M-2026-27-M08-1.xml and sent with
idempotency_key "fps:UK01-M-2026-27-M08:1"; the capability''s RESULT accepted (HMRC''s correlation id) moves the run
to submitted, rejected keeps it approved with HMRC''s errors for the admin, and a corrected FPS is n+1 (v2: sent
by a holder of prl:fps:correct); with no provider it is a manual task "submit this FPS by 2026-11-30" with the
file, which a holder of prl:filing:complete_manual records as done with HMRC''s reference; no live HMRC call is
built here
owner: "the capability provider named in compose.yaml (an HMRC RTI provider brick; none = a manual task)"
reference: not built
- id: PRL-14
kind: integration
actor: system
trigger: monthly, after the last run of a tax month, before the 19th of the next month
story: The employer payment summary goes to HMRC through the same capability, so that statutory pay recovered and the employment allowance reduce what we pay.
given: 'tax month 8 of 2026-27: SMP paid 2,423.57, recovered 92% = 2,229.68; no other recoveries'
when: 'the EPS pass runs on 2026-12-06 (after the tax month ends on 2026-12-05), and again'
then: >-
one EPS keyed "eps:123/AB45678:2026-27:M08:1" with the year-to-date recoverable amounts (SMP recovered 2,229.68,
no NIC compensation), sent through hmrc.rti_submit kind eps; a month with no payments sends the no-payment EPS;
the journal debits paye_ni_payable and credits statutory_pay_recovery 2,229.68 (event eps, derived from the EPS
key); the second pass sends nothing
owner: "the capability provider named in compose.yaml (an HMRC RTI provider brick; none = a manual task)"
reference: not built
- id: PRL-15
kind: integration
actor: system
trigger: after a run is approved
story: Pension contributions and enrolment changes go to the scheme through a capability, so that the scheme and the payslips agree.
given: 'run M08: 44 members, employee 5,312.40, employer 3,187.44; 4450 newly enrolled; 4460 opted out with a refund'
when: payroll calls the capability pension.submit_contributions, and again after a timeout
then: >-
a PAPDIS file (one row per member: employee and employer contributions, pensionable earnings, enrolment status
and dates) as an object raw/payroll/UK01/pension/UK01-QS/UK01-M-2026-27-M08.csv, sent with idempotency_key
"pension:UK01-QS:UK01-M-2026-27-M08"; the scheme collects 8,499.84 by direct debit, which payments matches by its
reconciliation rule to purpose pension_payable; with no provider it is a manual task to upload the file by the
22nd of the next month (the legal due date)
owner: "the capability provider named in compose.yaml (a pension provider brick; none = a manual task)"
reference: not built
- id: PRL-20
kind: integration
actor: system
trigger: when a run is opened and before it is calculated
story: Pay, starters, leavers and changes come from hr's payroll extract, so that payroll never keeps a second copy of salaries.
given: 'company UK01, monthly period 2026-11'
when: 'payroll calls hr POST /payroll-extracts {legal_entity_ref UK01, period 2026-11}, then GET /payroll-extracts/UK01|2026-11, and after approval POST /payroll-extracts/UK01|2026-11/sent {sent_ref UK01-M-2026-27-M08}'
then: >-
starters become payroll employees waiting for their tax details (PRL-01), leavers get their final pay and P45
(PRL-08), pay slices and components become payslip lines with their proration dates, retro lines for closed
periods are paid in this period; the employment and the person partner come from GET /persons/{person_id}/employment;
the signed-in employee from GET /persons?pool_user_sub=; payroll never reads hr_ tables
owner: hr
reference: not built
- id: PRL-21
kind: integration
actor: system
trigger: when a run is calculated, and to detect changes before approval
story: Unpaid leave, sickness and family leave come from time-off's approved absences.
given: 'time-off''s November absences of UK01 (TOF-20)'
when: 'payroll calls GET /absences?company_code=UK01&from=2026-11-01&to=2026-11-30, and later with changed_since'
then: 'pay treatments map to elements (unpaid -> UNPAID_DEDUCTION, sick -> SICK_DEDUCTION and SSP, statutory_maternity -> SMP); an absence changed after calculation marks the payslip stale (PRL-25); a change to a period already approved is settled in the next run; payroll never writes tof_ tables'
owner: time-off
reference: not built
- id: PRL-22
kind: integration
actor: system
trigger: when a weekly or monthly run with hourly-paid or overtime-paid people is calculated
story: Hourly pay and overtime come from approved timesheets.
given: 'timesheets'' approved hours of UK01 for 2026-11-09 to 2026-11-15 (TS-20)'
when: 'payroll calls GET /approved-hours?company_code=UK01&from=2026-11-09&to=2026-11-15, and later with changed_since'
then: 'normal hours x the hourly rate (hr), overtime hours x the rate x the overtime element''s multiplier; a superseded revision is settled as the difference in the next run; payroll never reads ts_ tables'
owner: timesheets
reference: not built
- id: PRL-18
kind: integration
actor: system
trigger: when a run is approved and when a period's journal is posted
story: Cost centres, open periods and the company come from gl.
given: 'cost centre SALES-N open; period 2026-11 open; company UK01 in GBP'
when: 'payroll calls GET /cost-centres/SALES-N, GET /periods/open?date=2026-11-30&company_code=UK01 and GET /companies/UK01'
then: 'a payslip whose cost centre is closed is refused at approval with the person named; a closed period leaves the journal pending; payroll never writes gl_ tables'
owner: gl
reference: not built
- id: PRL-28
kind: integration
actor: system
trigger: when payments confirms or returns a payout it paid for payroll
story: Payments tells payroll that a net pay payout was paid or came back, so that each payslip knows whether the person has their money.
given: 'payout prl:UK01-M-2026-27-M08:4410:net of 2,354.24'
when: 'payments calls POST /pay-runs/UK01-M-2026-27-M08/payouts/prl:UK01-M-2026-27-M08:4410:net/paid {status paid, payment_id, paid_on 2026-11-30, amount 2,354.24}, twice'
then: 'the payslip is paid on 2026-11-30, recorded once (the second call answers the same); a different amount is refused 409; status returned leaves the payslip unpaid with the reason and lists it for the admin'
owner: payroll
reference: not built
processes:
- key: pay_run
pcf_id: null # APQC 7.5.2 Process payroll (Reward and retain employees): id to confirm against PCF v8.0
pcf_name: Process payroll
reference_names: ["Hire to Retire (pay)", "Gross to net", "Payroll run"]
brick: payroll
- key: payroll_employee
pcf_id: null # APQC 7.5.x: id to confirm
pcf_name: Administer payroll records (starters, tax codes, leavers)
reference_names: ["Starter and leaver processing", "P45"]
brick: payroll
workflows:
- entity: pay_run
states: [open, calculated, approved, submitted, paid, closed, cancelled]
terminal: [closed, cancelled]
transitions:
- { key: open_pay_run, name: Open the period, from: none, to: open, by: [system, payroll_admin], gate: "the schedule's previous regular run is approved; the period claim (company, schedule, tax year, period, regular) free", effect: "pay_run_id <company>-<schedule>-<tax year>-<period>; the hr extract produced (HR-60)" }
- { key: open_off_cycle_run, name: Open an off-cycle run, from: none, to: open, by: payroll_manager, requires: reason, gate: "the tax period's regular run approved", effect: "pay_run_id <regular>-X<n>, kind off_cycle" }
- { key: calculate_pay_run, name: Calculate, from: open, to: calculated, by: payroll_admin, gate: "every employee in it has a tax code and an NI category", effect: "payslips derived from hr's extract, time-off, timesheets and the rows in force on the pay date" }
- { key: recalculate_pay_run, name: Recalculate, from: calculated, to: calculated, by: payroll_admin, effect: "the draft payslips replaced; stale flags cleared" }
- { key: approve_pay_run, name: Approve the run, from: calculated, to: approved, by: payroll_manager, approval: true, gate: "not_stale; nmw_met; net_not_negative; bacs_lead; the approver is not the preparer (email AND sub); own_payslip_by_peer (the approver's own payslip, if in the run, already approved by a peer)", effect: "payslips frozen; after commit, from pending rows: extract sent (HR-60), journal (PRL-16), payouts (PRL-12); the FPS and the pension file are built by build_pay_run_documents (v2), never inside the approval" }
- { key: build_pay_run_documents, name: Build the FPS and the pension file, from: approved, to: approved, by: system, gate: "the approval committed; the run's pending document row claimed once by the worker tick", effect: "v2 (seemo's decided answer 7): the FPS (fps:<run>:1) and the pension contribution file built from the frozen payslips and handed to the capability (PRL-13, PRL-15) or to a manual task; the EPS is built by the tick's monthly EPS pass; a retry builds nothing twice" }
- { key: approve_approver_payslip, name: "Approve the approver's own payslip", from: calculated, to: calculated, by: payroll_manager, approval: true, gate: "a payroll manager who is neither the payslip's person nor the run's preparer (email AND sub)", effect: "that payslip approved; the run may then be approved" }
- { key: record_fps_accepted, name: HMRC accepted the FPS, from: approved, to: submitted, by: system, gate: "the capability's RESULT accepted, or the admin's manual task done with HMRC's reference", effect: "the correlation id kept" }
- { key: confirm_pay_run_paid, name: Every payout settled, from: submitted, to: paid, by: system, gate: "every payout paid or returned (payments, PRL-28)" }
- { key: close_pay_run, name: Close the period, from: paid, to: closed, by: [system, payroll_admin], gate: "the journal posted; the pension file sent or its manual task done", effect: "year to date frozen; payslips published on the pay date" }
- { key: cancel_pay_run, name: Cancel the run, from: [open, calculated], to: cancelled, by: payroll_manager, requires: reason, effect: "the period claim released; nothing was sent" }
readiness:
- { transition: calculate_pay_run, gate: tax_details, reason: "{person} has no tax code or NI category", fix: "Employees: complete the record (P45 or starter declaration)" }
- { transition: approve_pay_run, gate: not_stale, reason: "payslips changed under the run: named with their cause", fix: "recalculate" }
- { transition: approve_pay_run, gate: nmw_met, reason: "{person} is paid {rate} an hour, below the {band} minimum of {minimum}", fix: "correct the rate in hr" }
- { transition: approve_pay_run, gate: net_not_negative, reason: "{person}'s net pay is below 0.00", fix: "carry the deduction to the next period" }
- { transition: approve_pay_run, gate: bacs_lead, reason: "the pay date is fewer than {n} working days away", fix: "approve now and ask finance for a faster payment, or move the pay date" }
- { transition: approve_pay_run, gate: second_person, reason: "you calculated this run", fix: "another payroll manager approves it" }
- { transition: approve_pay_run, gate: own_payslip_by_peer, reason: "your own payslip is in this run and no peer has approved it", fix: "another payroll manager approves your payslip first" }
- { transition: record_fps_accepted, gate: fps_on_time, reason: "the FPS is due on or before {pay_date}", fix: "send it now; a late FPS needs a late reporting reason" }
note: "approved never returns to calculated: a mistake found after approval is corrected in the next period or by an off-cycle run, so no state is ever re-entered"
- entity: payroll_employee
states: [pending_details, active, left]
terminal: [left]
transitions:
- { key: take_starter, name: Take a starter from hr, from: none, to: pending_details, by: system, gate: "a starter on hr's extract with an employee worker type", effect: "payroll_id <person_id>-<n>; enrolment assessed in the first run" }
- { key: complete_payroll_details, name: Complete the tax details, from: pending_details, to: active, by: payroll_admin, requires: tax_code, gate: "a P45 or a starter declaration (A, B or C), else the emergency code with a warning; the admin is not the person (email AND sub)" }
- { key: issue_p45, name: Issue the P45, from: active, to: left, by: system, gate: "the leaver's final payslip approved", effect: "P45 document; the leaving date on the FPS" }
- entity: pension_membership
states: [enrolled, opted_out, ceased]
terminal: [opted_out, ceased]
transitions:
- { key: enrol_pension, name: Enrol, from: none, to: enrolled, by: [system, payroll_admin], gate: "assessed eligible jobholder, or an entitled or non-eligible worker who asked to join", effect: "the enrolment date; the opt-out window (one month) starts when the scheme confirms" }
- { key: opt_out_pension, name: Opt out, from: enrolled, to: opted_out, by: [employee, payroll_admin], gate: "within the opt-out window", effect: "contributions refunded in the next run; the scheme told" }
- { key: cease_pension, name: Cease membership, from: enrolled, to: ceased, by: [employee, payroll_admin, system], gate: "after the window (no refund), or the person left", effect: "contributions stop from the next period" }
- entity: prl_ytd_run
states: [loaded, reconciled, signed, discarded]
terminal: [signed, discarded]
transitions:
- { key: load_prl_ytd, name: "Load the previous provider's year to date", from: none, to: loaded, by: system, gate: "hr's migration signed; the provider's last FPS file attached", effect: "v2 (pattern §11, seemo's decided answer 4): staging rows only (prl_migration_row, keyed by the source id); nothing written to the ledger, balances or journals" }
- { key: reconcile_prl_migration, name: Reconcile against the provider's FPS, from: loaded, to: reconciled, by: system }
- { key: sign_prl_migration, name: Sign the reconciliation, from: reconciled, to: signed, by: owner, approval: true, gate: "every total equal to the penny", effect: "v2 (decided answer 4): the staged rows written through this module's normal use cases, idempotent on the source key; the first write to the ledger; a signed run is later reversed by reversing entries, never deleted" }
- { key: discard_prl_migration, name: Discard the load, from: [loaded, reconciled], to: discarded, by: owner, requires: reason, effect: "v2 (decided answer 4): the run's staging rows deleted, nothing else (no ledger row was written)" }
entities:
- id: paye_scheme
meaning: one employer PAYE scheme registered with HMRC
natural_key: [company_code, paye_ref]
key_attributes: [accounts_office_ref, small_employer_relief (dated), employment_allowance_claim (dated), rti_capability]
master_data: module-owned
reference_table: prl_paye_scheme
- id: pay_schedule
meaning: how often a group is paid and on which day
natural_key: [company_code, schedule_code]
key_attributes: [frequency (monthly | weekly | fortnightly | four_weekly), period_rule (calendar_month | iso_week_in_arrears), pay_day_rule (last_working_day | friday_after | a day of the month), paye_ref]
master_data: module-owned
reference_table: prl_pay_schedule
- id: payroll_employee
meaning: one employment of one person on this payroll, as HMRC knows it
natural_key: 'payroll_id = "<person_id>-<n>" (the employment n from hr), fixed once on an FPS'
key_attributes: [person_id, company_code, schedule_code, status, start_date, leaving_date, starter_declaration (A | B | C), p45_pay_to_date, p45_tax_to_date, hmrc_gender (as HMRC holds it, an RTI field), director_since]
master_data: module-owned
reference_table: prl_employee
- id: tax_code_row
meaning: the tax code and basis in force for an employment from a date
natural_key: [payroll_id, valid_from]
key_attributes: [tax_code (1257L, BR, D0, NT, 0T, K475, S1257L, C1257L), basis (cumulative | week1_month1), source (p45 | starter_checklist | p6 | p9 | emergency | manual), notice_ref, set_by, set_sub]
master_data: "module-owned (append-only)"
reference_table: prl_tax_code
- id: ni_category_row
meaning: the National Insurance category letter of an employment from a date
natural_key: [payroll_id, valid_from]
key_attributes: [category (A, B, C, H, J, M, Z, F, I, L, S, V, N, E, D, K), reason]
master_data: "module-owned (append-only)"
reference_table: prl_ni_category
- id: student_loan_row
meaning: a student or postgraduate loan HMRC asked us to deduct, from a date
natural_key: [payroll_id, plan, valid_from]
key_attributes: [plan (plan_1 | plan_2 | plan_4 | plan_5 | postgraduate), valid_to, source (starter_checklist | sl1 | sl2)]
master_data: "module-owned (append-only)"
reference_table: prl_student_loan
- id: pay_element
meaning: one kind of payment or deduction and how tax, NI, pension and the books treat it
natural_key: [company_code, element_code, valid_from]
key_attributes: [name, kind (earning | deduction | statutory | employer), taxable, niable, pensionable, rti_field, gl_purpose, multiplier (NUMERIC 19,4, overtime), maps_from (an hr pay component, a time-off pay treatment, a timesheets hours kind)]
master_data: "SHARED, sole writer (gl maps the purpose; hr's pay components and time-off's pay treatments name what they map to)"
reference_table: prl_element
- id: statutory_rate
meaning: a rate, band or threshold set by law, from a date
natural_key: [rate_key, region, frequency, valid_from]
key_attributes: [value (NUMERIC 19,4), unit (amount | percent | pounds), source (the statute or HMRC page named)]
master_data: "vendor-published (v2, seemo's decided answer 1): dated rows shipped with the module's statutory pack and its releases, checked against the statute or HMRC page named; never edited by the customer (a wrong row is corrected by a new vendor row from a date); the customer's own figures are scheme rows (company sick pay, enhanced maternity), never these"
reference_table: prl_statutory_rate
- id: pension_scheme
meaning: a workplace pension scheme the employer pays into
natural_key: [company_code, scheme_code]
key_attributes: [provider_capability, basis (qualifying_earnings | pensionable_pay | basic_pay), "employee_pct (percent)", "employer_pct (percent)", tax_relief (net_pay | relief_at_source), postponement_days, valid_from]
master_data: module-owned
reference_table: prl_pension_scheme
- id: pension_membership
meaning: one employment's membership of a scheme
natural_key: [payroll_id, scheme_code, enrolled_on]
key_attributes: [status, assessed_as (eligible | non_eligible | entitled), opt_out_window_ends, opted_out_on, ceased_on]
master_data: module-owned
reference_table: prl_pension_membership
- id: pay_run
meaning: one payment of one schedule for one period
natural_key: 'pay_run_id = "<company_code>-<schedule>-<tax year>-<period>" (UK01-M-2026-27-M08, UK01-W-2026-27-W33); off-cycle "<regular>-X<n>"'
key_attributes: [company_code, schedule_code, kind (regular | off_cycle), tax_year, tax_period, period_from, period_to, pay_date, status, prepared_by, prepared_sub, approved_by, approved_sub, approved_at, journal_id, fps_ref, hr_batch_id]
master_data: module-owned
reference_table: prl_pay_run
- id: period_claim
meaning: the row a regular run of a period races for, so a period is paid once
natural_key: [company_code, schedule_code, tax_year, period_code, kind]
key_attributes: [pay_run_id, claimed_at]
master_data: "module-owned (a plain INSERT; released only by cancel_pay_run)"
reference_table: prl_period_claim
- id: payslip
meaning: one person's pay in one run
natural_key: [pay_run_id, payroll_id]
key_attributes: ["gross_amount, taxable_amount, tax_amount, ee_ni_amount, er_ni_amount, ee_pension_amount, er_pension_amount, student_loan_amount, net_amount (NUMERIC 18,2)", tax_code_used, ni_category_used, "ytd_json (taxable pay, tax, NI per letter, statutory pay to date)", stale, stale_reason, approved_by, approved_sub, payout_ref, paid_on, payment_id, document_key, published_on]
master_data: module-owned
reference_table: prl_payslip
- id: payslip_line
meaning: one element on one payslip and where it came from
natural_key: [pay_run_id, payroll_id, line_no]
key_attributes: [element_code, "quantity (NUMERIC 19,4)", "rate_amount (NUMERIC 19,4)", "amount (NUMERIC 18,2)", source_kind (hr_extract | absence | hours | statutory | manual), source_ref, rate_rows_used]
master_data: module-owned
reference_table: prl_payslip_line
- id: ytd_opening
meaning: the year-to-date figures carried from the previous provider
natural_key: 'ytd_ref = "ytd:<payroll_id>:<tax year>"'
key_attributes: [taxable_pay_amount, tax_amount, "ni_by_letter_json", er_ni_amount, statutory_json, pension_amount, source_file_key]
master_data: module-owned
reference_table: prl_ytd_opening
- id: rti_submission
meaning: one FPS or EPS sent (or to be sent) to HMRC
natural_key: 'submission_id = "fps:<pay_run_id>:<n>" | "eps:<paye_ref>:<tax year>:M<nn>:<n>"'
key_attributes: [kind (fps | eps), object_key, status (pending | accepted | rejected | manual), correlation_id, errors_json, sent_at, due_on]
master_data: module-owned
reference_table: prl_rti_submission
- id: document
meaning: a payslip, P45 or P60 kept for the person and for HMRC's record-keeping period
natural_key: object key raw/payroll/<company_code>/<tax year>/<kind>/<person_id>/<ref>.pdf
key_attributes: [kind (payslip | p45 | p60), payroll_id, ref, published_on, retain_until]
master_data: module-owned
reference_table: prl_document
- id: outbox
meaning: a side effect waiting after the local commit (extract sent, journal, payouts, FPS, EPS, pension file, publication)
natural_key: [target, ref]
key_attributes: [payload, status, attempts, last_error]
master_data: module-owned
reference_table: prl_outbox
- id: settings
meaning: the controls and defaults, dated
natural_key: [key, valid_from]
key_attributes: [value, set_by]
master_data: module-owned
reference_table: prl_settings
master_data:
owns: [prl_element]
references:
- entity: person
owner: hr
how: "person_id and the employment n; pay, starters and leavers from hr's extract; identity from HR-61; the person partner (payee) from HR-56"
if_absent: "no payroll: this module keeps no copy of salaries or identities"
- entity: absence
owner: time-off
how: "absence_ref and revision on payslip lines"
if_absent: "unpaid leave, sickness and maternity are keyed by hand as manual lines"
- entity: approved_hours
owner: timesheets
how: "sheet_id and revision on payslip lines"
if_absent: "hourly pay is keyed by hand as manual lines"
- entity: cost_centre
owner: gl
how: "the employee's cost centre from hr's employment answer, checked in gl"
if_absent: "one cost centre for the whole payroll"
- entity: business_partner
owner: business-partner
how: "the person partner (bp_key person:<person_id>) as the payout payee"
if_absent: "finance pays by hand"
warning: >-
A payroll that keeps its own staff list and salaries is how a leaver is paid in December and a pay rise is paid in
March for January. Pay and dates stay in hr; this module keeps only what HMRC and the pension scheme need on top.
jobs:
- story: PRL-16
trigger: when a run is approved
runs: the journal
where_options: [in the approval request after commit, the composition's worker]
idempotent: 'journal_id = derived_id("prl:<pay_run_id>", "approve"); the EPS journal derived_id("<eps submission_id>", "eps")'
- story: PRL-17
trigger: 2027-04-06 and every 6 April
runs: final FPS check, P60s, the new tax year
where_options: [always-on worker (--reserved 1 --max-replicas 1), external scheduler]
idempotent: 'P60 keyed "p60:<payroll_id>:<tax year>"; the year keyed by tax year'
- story: PRL-25
trigger: on read and before approval
runs: the stale check against hr, time-off and timesheets (changed_since)
where_options: [derived on read, in the approval request]
idempotent: "stale is a flag; recalculation is a pure function"
- story: PRL-14
trigger: on the 6th of each month (after the tax month ends on the 5th)
runs: the EPS for the tax month just ended
where_options: [always-on worker (--reserved 1 --max-replicas 1), external scheduler]
idempotent: 'submission_id "eps:<paye_ref>:<tax year>:M<nn>:<n>"'
seams:
- story: PRL-20
with: hr
owner: hr
direction: outbound
route: "POST /payroll-extracts"
permission: hr:payroll_extract:read
request: '{legal_entity_ref (the company_code), period (YYYY-MM, or YYYY-Www for a weekly schedule)}'
rule: "produced when the run opens and again before calculation while the run is open"
status: "satisfied by hr v3 (HR-60)"
- story: PRL-20
with: hr
owner: hr
direction: outbound
route: "GET /payroll-extracts/{batch_id}"
permission: hr:payroll_extract:read
request: 'path batch_id "<company_code>|<period>"'
rule: "the source of pay, starters and leavers; never copied beyond the payslip lines it produced"
status: "satisfied by hr v3 (HR-60)"
- story: PRL-20
with: hr
owner: hr
direction: outbound
route: "POST /payroll-extracts/{batch_id}/sent"
permission: hr:payroll_extract:mark_sent
request: '{sent_ref: the pay_run_id}'
rule: "after approve_pay_run commits, from the outbox; a later change lands in hr's next batch as a retro line"
status: "satisfied by hr v3 (HR-60)"
- story: PRL-20
with: hr
owner: hr
direction: outbound
route: "GET /persons/{person_id}/employment?on="
permission: hr:employment:read
request: "path person_id; query on (the pay date)"
rule: "the company, cost centre and the person partner (bp_id) for the payout"
status: "exists in hr v2 (HR-56); hr v3 lists payroll as a caller"
- story: PRL-11
with: hr
owner: hr
direction: outbound
route: "GET /persons?pool_user_sub="
permission: hr:employment:read
request: "query pool_user_sub"
rule: "the signed-in employee's person_id for My payslips"
status: "exists in hr v2 (HR-58); hr v3 lists payroll as a caller"
- story: PRL-13
with: hr
owner: hr
direction: outbound
route: "GET /persons/{person_id}/payroll-identity?on="
permission: hr:payroll_identity:read
request: "path person_id; query on (the pay date)"
rule: "read when an FPS, P45 or P60 is built, written only into that file object; every read audited by hr"
status: "satisfied by hr v3 (HR-61)"
- story: PRL-04
with: hr
owner: hr
direction: outbound
route: "GET /persons/{person_id}/work-pattern?from=&to="
permission: hr:work_pattern:read
request: "path person_id; query from, to (the pay period)"
rule: "working days for proration and qualifying days for SSP"
status: "satisfied by hr v3 (HR-62)"
- story: PRL-21
with: time-off
owner: time-off
direction: outbound
route: "GET /absences?company_code=&from=&to=&person_id=&changed_since="
permission: tof:absence:read_for_payroll
request: "query company_code, from, to (the pay period), changed_since (the calculation time)"
rule: "pay treatments map to elements; changes since the calculation mark payslips stale"
status: "satisfied by time-off v1 (TOF-20)"
- story: PRL-08
with: time-off
owner: time-off
direction: outbound
route: "GET /persons/{person_id}/balance?leave_type_code=&on="
permission: tof:balance:read_for_payroll
request: "path person_id; query leave_type_code ANNUAL, on (the last day)"
rule: "the days owed (or overtaken) at the last day, paid in lieu at the day rate"
status: "satisfied by time-off v1 (TOF-22)"
- story: PRL-22
with: timesheets
owner: timesheets
direction: outbound
route: "GET /approved-hours?company_code=&from=&to=&person_id=&changed_since="
permission: ts:hours:read_approved
request: "query company_code, from, to (the hours period), changed_since"
rule: "normal and overtime hours of approved revisions; superseded revisions settled as a difference"
status: "satisfied by timesheets v1 (TS-20)"
- story: PRL-12
with: payments
owner: payments
direction: outbound
route: "POST /payouts"
permission: pay:payout:intake
request: '{payout_ref: "prl:<pay_run_id>:<person_id>:net[:<n>]", batch_ref: pay_run_id, payee_bp_id (the person partner), person_id, company_code, currency, amount (string, NUMERIC 18,2), due_on (the pay date), reference ("SALARY <period>", at most 18 characters for Bacs), source_module: payroll, source_document: pay_run_id, clearing_purpose: net_pay_payable}'
request_schema:
- { field: payout_ref, type: string, required: true }
- { field: batch_ref, type: string, required: true }
- { field: payee_bp_id, type: bp_id, required: true }
- { field: person_id, type: person_id, required: true }
- { field: company_code, type: company_code, required: true }
- { field: currency, type: currency, required: true }
- { field: amount, type: decimal, required: true }
- { field: due_on, type: date, required: true }
- { field: reference, type: string, required: true }
- { field: source_module, type: string, required: true, values: [payroll] }
- { field: source_document, type: string, required: true }
- { field: clearing_purpose, type: string, required: true, values: [net_pay_payable] }
response: '201 {payout_ref, payout_id, status: requested}; 200 the same for the same payout_ref; 409 for a known payout_ref with a different amount; 422 {gate: payee_is_person | paying_account | bounds, reason, fix}'
rule: "payments approves the batch, pays it on the due date, posts net_pay_payable / bank and tells payroll (PRL-28); payroll never sees a bank account"
status: "satisfied by payments v7 (PAY-52: source payroll, batch approval)"
- story: PRL-12
with: payments
owner: payments
direction: outbound
route: "POST /payouts/{payout_ref}/cancel"
permission: pay:payout:intake
request: 'path payout_ref; body {reason}'
rule: "an off-cycle correction before export may cancel a payout of the same person and run; after export the payment stands"
status: "exists in payments v6 (PAY-51); v7 accepts payroll's payouts"
- story: PRL-28
with: payments
owner: payroll
direction: inbound
route: "POST /pay-runs/{pay_run_id}/payouts/{payout_ref}/paid"
permission: prl:payout:mark_paid
request: '{status (paid | returned), payment_id, paid_on (YYYY-MM-DD), amount (string, NUMERIC 18,2), reason (for returned)}'
request_schema:
- { field: pay_run_id, type: string, required: true, in: path }
- { field: payout_ref, type: string, required: true, in: path }
- { field: status, type: string, required: true, values: [paid, returned] }
- { field: payment_id, type: payment_id, required: false }
- { field: paid_on, type: date, required: true }
- { field: amount, type: decimal, required: true }
- { field: reason, type: string, required: false }
response: "200 the payslip with paid_on or the return; 200 the same for a repeat; 409 {gate: amount_matches, reason, fix}"
rule: "called by payments after confirm_payout_paid or record_payout_return commits, from its outbox, until 2xx; payroll never writes pay_ tables"
status: "offered by payroll v1; payments v7 calls it (PAY-52)"
- story: PRL-16
with: gl
owner: gl
direction: outbound
route: "POST /journal-entry/journals"
permission: gl:journal:post
request: '{journal_id: derived_id("prl:<pay_run_id>", "approve"), source_module: payroll, source_document: "prl:<pay_run_id>", event: approve | eps | reverse, company_code, journal_date (the pay date), currency, memo, lines: [{purpose: gross_pay | employer_ni | employer_pension | statutory_pay_recovery | paye_ni_payable | pension_payable | net_pay_payable | other_deductions_payable, cost_centre, debit | credit}]}'
rule: "one journal per approved run, summed per purpose and cost centre (never per person in the ledger); the EPS recovery journal per tax month; payroll never writes gl_ tables"
status: "satisfied by gl v8 (GL-30 row for source_module payroll)"
- story: PRL-18
with: gl
owner: gl
direction: outbound
route: "GET /cost-centres/{cost_centre}"
permission: gl:cost_centre:read
request: "path cost_centre"
rule: "each payslip's cost centre (from hr) open on the pay date"
status: "exists in gl v7 (GL-61)"
- story: PRL-18
with: gl
owner: gl
direction: outbound
route: "GET /periods/open"
permission: gl:period:read
request: "query date (the pay date), company_code"
rule: "a closed period leaves the journal pending and holds the payouts"
status: "exists in gl v7 (GL-32)"
- story: PRL-18
with: gl
owner: gl
direction: outbound
route: "GET /companies/{company_code}"
permission: gl:company:read
request: "path company_code"
rule: "the functional currency (GBP for a UK PAYE scheme; another currency is refused)"
status: "exists in gl v7 (GL-61)"
- story: PRL-13
owner: "the capability provider named in compose.yaml (an HMRC RTI provider brick; none = a manual task)"
direction: outbound
route: "POST /capabilities/hmrc.rti_submit (CapabilitySeam)"
request: '{idempotency_key: "<submission_id>", dry_run, input: {kind: fps | eps, paye_ref, accounts_office_ref, tax_year, tax_period, payment_date, file_object_key, sha256}}'
response: "the capability RESULT; output {status: accepted | rejected, correlation_id, errors: [{code, field, message}]}"
rule: "the FPS on or before the pay date (PAYE reg. 67B), the EPS by the 19th after the tax month (reg. 67D); the file is built here and its object key handed over; no HMRC credential and no live HMRC call in this module; the capability is payroll's own contract (capabilities.local.yaml) until erp-platform's capabilities.yaml has it"
status: "named seam (v1 draft); no provider brick exists"
- story: PRL-15
owner: "the capability provider named in compose.yaml (a pension provider brick; none = a manual task)"
direction: outbound
route: "POST /capabilities/pension.submit_contributions (CapabilitySeam)"
request: '{idempotency_key: "pension:<scheme_code>:<pay_run_id>", dry_run, input: {scheme_code, pay_run_id, pay_date, format: papdis, file_object_key, sha256, totals {employee, employer}}}'
response: "the capability RESULT; output {status: accepted | rejected, errors}"
rule: "after approval; the scheme's direct debit is matched in payments by a reconciliation rule to purpose pension_payable"
status: "named seam (v1 draft); no provider brick exists"
# ── v2 (2026-10-26, seemo's decided answer 2): hr's organisational assignment and roster by person id, in place of typed-in doors ──
- story: PRL-24
with: hr
owner: hr
route: "GET /roster?company_code=&on=&org_unit_code=&manager_person_id=&limit=&offset="
permission: hr:roster:read
rule: "the YTD load matches the previous provider's employees by work email, then confirms the NI number through HR-61 (was the person taken from the payroll id)"
status: "satisfied by hr v4 (HR-64)"
- story: PRL-20
with: hr
owner: hr
route: "GET /persons/{person_id}/assignment?on="
permission: hr:assignment:read
rule: "the company and work email by person_id for payslip notices; nothing typed in"
status: "satisfied by hr v4 (HR-63)"
tool_names: # shared.yaml conventions.tool_names: the migration family names this module's noun
- { tool: load_prl_ytd, route: "the prl_ytd_run workflow's transition (route chosen at build)" }
- { tool: reconcile_prl_migration, route: "the prl_ytd_run workflow's transition (route chosen at build)" }
- { tool: sign_prl_migration, route: "the prl_ytd_run workflow's transition (route chosen at build)" }
- { tool: discard_prl_migration, route: "the prl_ytd_run workflow's transition (route chosen at build)" }
- { tool: approve_pay_run, route: "POST /pay-runs/{pay_run_id}/approve" }
screens:
- { id: employees, name: Employees, stories: [PRL-01, PRL-02, PRL-03, PRL-10], actors: [payroll_admin, payroll_manager], note: "the payroll record per employment: tax codes with their source, NI category, starter declaration, P45 figures, student loans, pension membership; starters waiting for details" }
- { id: pay_runs, name: Pay runs, stories: [PRL-04, PRL-05, PRL-06, PRL-07, PRL-08, PRL-09, PRL-26], actors: [payroll_admin, payroll_manager], note: "runs per schedule and period; payslips with every line and its source, stale flags with causes, the gates before approval, the FPS and payout status; variance against last period per person" }
- { id: hmrc, name: HMRC, stories: [PRL-27], actors: [payroll_manager], note: "FPS and EPS per period with HMRC's answers, the liability per tax month and its due date, late-filing risks" }
- { id: my_payslips, name: My payslips, stories: [PRL-11], actors: [employee], note: "my payslips, P60s and P45, my tax code and pension membership; the opt-out button inside the window" }
- { id: settings, name: Settings, stories: [PRL-19, PRL-23], actors: [payroll_manager, owner], note: "PAYE schemes, schedules, elements, pension schemes, statutory rates; the controls to the owner only" }
- { id: migration, name: Migration, stories: [PRL-24], actors: [owner, payroll_manager] }
permissions:
namespace: prl
catalog:
- prl:employee:read
- prl:employee:write
- prl:tax_code:write
- prl:pay_run:read
- prl:pay_run:open
- prl:pay_run:calculate
- prl:pay_run:approve
- prl:pay_run:off_cycle
- prl:pay_run:cancel
- prl:element:write
- prl:rate:write
- prl:scheme:write # v2: the PAYE scheme, the pay schedules and the pension schemes (put_paye_scheme, put_pay_schedule, put_pension_scheme); the payroll manager by default
- prl:pension:write
- prl:pension:opt_out_own
- prl:document:read
- prl:rti:read
- prl:filing:complete_manual # v2: record a manual FPS / EPS / pension-file task as done, with the reference the authority gave (no provider brick)
- prl:fps:correct # v2: send a corrected FPS (fps:<run>:<n+1>) after HMRC rejected one or a fact changed; never the calculating person's own run alone
- prl:liability:read
- prl:payslip:read_own
- prl:settings:controls # second approval of payouts, small employer relief, employment allowance, proration; never held by whoever they control
- prl:migration:read
- prl:migration:sign
- prl:pay_run:open_auto # machine
- prl:payslip:derive # machine
- prl:journal:post_auto # machine
- prl:payout:request # machine
- prl:payout:mark_paid # machine: payments' service client only
- prl:rti:submit # machine
- prl:pension:submit # machine
- prl:document:publish # machine
- prl:year_end:run # machine
- prl:migration:load # machine
- prl:migration:reconcile # machine
machine:
- prl:pay_run:open_auto
- prl:payslip:derive
- prl:journal:post_auto
- prl:payout:request
- prl:payout:mark_paid
- prl:rti:submit
- prl:pension:submit
- prl:document:publish
- prl:year_end:run
- prl:migration:load
- prl:migration:reconcile
row_visibility:
rule: "an employee sees only their own payslips and documents; payroll admins and managers see every payroll record of their companies; a payroll user's own payslip is readable by them as an employee, never editable or approvable by them; managers in hr never see payslips here"
enforced: "in every use case: the caller's person_id from hr (by sub), the company list; every read of another person's payslip is logged"
not_found_not_forbidden: true
config_rows:
- table: prl_settings
changes: "how pay is calculated, approved and published; every row dated (valid_from after today; the first row of a key may start today)"
settings:
- { key: proration, type: str, default: "working_days", changes: "working_days | calendar_days | 260ths for part periods; prl:settings:controls" }
- { key: day_rate_divisor, type: int, default: 260, changes: "annual salary / this = a day's pay for deductions and holiday in lieu" }
- { key: bacs_lead_working_days, type: int, default: 3, changes: "working days before the pay date by which a run must be approved" }
- { key: payouts_auto_approve_source, type: bool, default: false, changes: "payments approves payroll's payout batch without a second person; prl:settings:controls (and payments' own control)" }
- { key: small_employer_relief, type: bool, default: false, changes: "recover 103% of statutory pay instead of 92%; prl:settings:controls" }
- { key: employment_allowance_claim, type: bool, default: true, changes: "claim the employment allowance on the EPS; prl:settings:controls" }
- { key: deduct_overtaken_holiday, type: bool, default: false, changes: "a leaver's overtaken holiday is deducted (only if the contract allows)" }
- { key: publish_on, type: str, default: "pay_date", changes: "pay_date | approval: when employees see their payslip" }
- { key: off_cycle_allowed, type: bool, default: true, changes: "payroll managers may open off-cycle runs" }
- { table: prl_statutory_rate, changes: "VENDOR rows, not the customer's (v2): tax bands per region, NI thresholds per frequency, SSP (rate, 80% cap, waiting days, LEL test, PIW 4 days), SMP, the lower earnings limit, auto-enrolment thresholds, student loan thresholds, minimum wage bands, each dated; shipped and corrected by the vendor's statutory pack" }
- { table: prl_element, changes: "pay elements, their tax, NI and pension treatment, gl purpose and overtime multipliers" }
- { table: prl_pension_scheme, changes: "schemes, basis, contribution rates, tax relief method" }
- { table: prl_pay_schedule, changes: "pay frequencies and pay days" }
- { table: prl_paye_scheme, changes: "the employer's PAYE references" }
in_code_not_rows:
- "that nobody calculates and approves the same run, approves their own payslip or changes their own tax code, under any login (email AND sub)"
- "that a period is paid by one regular run (prl_period_claim)"
- "HMRC's calculation method and rounding (cumulative and week1/month1 tax; taxable pay to date rounded down to the pound; NI by the exact percentage method; CWG2); the rates are rows, the method is not"
- "that an approved payslip is never recalculated; a correction is the next period's or an off-cycle run's"
- "that the national minimum wage is a gate, never a warning"
- "that bank details never enter this module (payments reads them from hr)"
- "that neighbours and capabilities are called only from pending rows after the local commit"
- "that 'today' and the deadlines (pay date, the 19th, the 22nd, 31 May) are in business_timezone"
ai_steps:
- key: explain_variance
benefit: "before approval, each payslip that moved more than 10% from last period gets a one-line reason (a pay change from hr, three sick days, a tax code change)"
mode: suggest
modes_allowed: ["off", suggest]
safe_for_auto: false # it explains, never changes a figure
inputs: [this payslip, last period's payslip, the source lines]
output: "a short explanation per flagged payslip; the approver decides"
fallback: "the lines side by side"
audit: "prl_outbox (explanations shown)"
eval: "seeded variances with known causes: share explained correctly"
note: "a task in the signed process (Task_pay_run_explain_variance); it reads only payroll's own figures"
extension_points:
- { name: company_sick_pay, what: "occupational sick pay schemes (N days full, M days half) offset by SSP" }
- { name: enhanced_family_pay, what: "enhanced maternity, paternity and adoption pay on top of statutory pay" }
- { name: salary_sacrifice, what: "pension, cycle-to-work and EV schemes by salary sacrifice (NI-saving), with the minimum wage check" }
- { name: benefits_in_kind, what: "payrolled benefits (P11D replacement) and their Class 1A NICs" }
- { name: attachments, what: "attachment of earnings orders and child maintenance deductions" }
- { name: other_countries, what: "a country pack (Germany: Lohnsteuer, Sozialversicherung, ELStAM, DEÜV) on the same run, payslip and element model" }
- { name: hmrc_dps, what: "coding notices fetched through an hmrc.dps capability instead of keyed" }
tests:
acceptance: "one per story id above, named for it"
access:
- "an employee cannot read another person's payslip (404) or anything in Pay runs"
- "a payroll admin cannot approve a run; a payroll manager cannot approve a run they calculated or their own payslip"
- "a payroll user cannot change their own tax code, NI category or pension membership except opting out as an employee"
- "prl:settings:write cannot change a control; anyone but the owner holding prl:settings:controls is refused at configuration"
- "prl:payout:mark_paid is refused to every person and to any client but payments'"
- "the machine permissions are refused to every person role and to a wildcard grant"
invariants:
- "claim keys: a period (company, schedule, tax year, period, kind), a payslip (pay_run_id, payroll_id), a journal (derived_id(prl:<run>, approve)), a payout (prl:<run>:<person>:net[:n]), an FPS (fps:<run>:<n>), an EPS (eps:<paye_ref>:<year>:M<nn>:<n>), a pension file (pension:<scheme>:<run>), a P45 (p45:<payroll_id>) and a P60 (p60:<payroll_id>:<year>) are each claimed once; two approvals, two retries or two passes leave one of each"
- "side effects (extract sent, journal, payouts, FPS, EPS, pension file, publication) are sent only from pending outbox rows written in the same transaction as the approval; a rolled-back approval sends nothing; a crash after commit sends them on the next run"
- "no backward states: approved never returns to calculated, submitted never to approved, a closed run is terminal, a left employee is terminal (a rehire is a new employment n+1); each tested by attempting the reverse"
- "self-approval: no run is approved by its preparer, and no payslip by its own person, matched on email AND sub, including a second login"
- "the business date is in business_timezone: the tax period is decided by the pay date in Europe/London; an approval at 23:30 on 2026-11-26 counts the Bacs lead from 2026-11-26; the UTC date is never used"
- "money bounded: an amount above NUMERIC(18,2), a rate above NUMERIC(19,4), a negative gross, or a value with more decimals than allowed answers 422 {gate: bounds}, never a 500; a negative net blocks approval (net_not_negative)"
- "results marked stale: a change in hr's extract, an absence, approved hours, a tax code or a rate row in force marks the affected calculated payslips stale; approval is refused until recalculated; approved payslips never change"
- "flat statements: My payslips, a run's payslip list, the HMRC liability and the FPS contain only their own person's, run's or PAYE scheme's rows; seeding another company's runs and another person's payslips changes none of them"
- "the journal of a run balances to the penny and equals the sum of its payslips per purpose; the payouts of a run sum to its net pay; the FPS year to date equals the payslips' year to date"
- "cumulative tax is order-independent: the same year's payslips recalculated from the opening figures give the same tax to date"
scenarios:
- id: SC-PRL-01
name: a November monthly run end to end
stories: [PRL-04, PRL-06, PRL-16, PRL-12, PRL-13, PRL-15, PRL-28]
given: 4410 on 36,000.00, 1257L, category A, 5% / 3%
when: calculated, approved by another manager, posted, paid and submitted
then: net 2,354.24; journal 3,461.85 each side; one payout; one FPS accepted; one pension row
- id: SC-PRL-02
name: the approver's own payslip
stories: [PRL-06]
given: payroll manager M's payslip in the run M approves
when: M approves, and tries to approve their own payslip under a second email with the same sub
then: the run waits; refused; peer P approves it
- id: SC-PRL-03
name: sickness extended after calculation
stories: [PRL-07, PRL-25, PRL-21]
given: run calculated with 3 sick days for 4410
when: time-off extends the sickness to 5 days before approval
then: the payslip is stale; approval refused; recalculated with 5 days
- id: SC-PRL-04
name: a weekly worker paid from timesheets
stories: [PRL-05, PRL-22]
given: 4470 approved 32.00 hours
when: the weekly run is calculated and approved
then: net 307.04; hours on the payslip; a later correction to 30.00 settles -26.00 gross next week
scale:
reads: "the tables reader serves 16 concurrent reads; a calculation reads hr's extract, time-off's and timesheets' periods once each (one call per company and period), then computes in memory; a run's payslip list is one query"
hot_paths: ["calculate (one pass per run)", "approve (journal, payouts, FPS rows)", "My payslips on pay day"]
workers: "the EPS, year-end and publication passes in the composition's worker; nothing pinned warm is required"
volumes: "500 employees monthly plus 200 weekly is about 13,000 payslips and 150,000 lines a year; a run of 500 calculates in one request"
platform_constraints:
- constraint: "No read-your-writes inside an open transaction."
today: "approval commits the run and its outbox rows; the journal, payouts and FPS go in a second step"
- constraint: "A plain INSERT of a committed key is rejected (23505)."
today: "period claims, tax code rows and RTI submissions are plain INSERTs; the loser re-reads"
- constraint: "No HMRC or pension provider capability exists on the platform."
today: "FPS, EPS and PAPDIS files are produced and handed to a person as manual tasks with their deadlines; tell the customer before the first run who submits them"
customer_varies:
- question: How often do you pay, and on which day?
changes: prl_pay_schedule
typical:
office_staff: "monthly, the last working day"
hourly_staff: "weekly, Friday, a week in arrears"
- question: What do you pay when someone is off sick or on maternity leave?
changes: the company_sick_pay and enhanced_family_pay extensions; SSP and SMP rows
standard: "Social Security Contributions and Benefits Act 1992 Parts XI and XII; Employment Rights Act 2025 (SSP from day one, from 2026-04-06)"
- question: Which pension scheme do you use, on which earnings, and how is tax relief given?
changes: prl_pension_scheme
standard: "Pensions Act 2008 ss. 13 and 20 (qualifying earnings, minimum 8% with at least 3% from the employer)"
typical:
small_employer: "the state-backed scheme, qualifying earnings, relief at source"
mid_market: "an insurer's scheme, qualifying or basic pay, net pay arrangement"
warning: "the net pay arrangement leaves members earning below the personal allowance without relief"
- question: Who approves the payroll, and should finance approve the bank payment again?
changes: approval roles; payouts_auto_approve_source
- question: Were your Class 1 NICs last tax year 45,000 or less, and are you eligible for the employment allowance?
changes: small_employer_relief, employment_allowance_claim
standard: "HMRC: Statutory Payments (small employers' relief); NICs Act 2014 s. 2 (employment allowance)"
- question: Who submits to HMRC and the pension scheme today, and with which software?
changes: the RTI and pension capability providers, or the manual tasks
- question: Are you moving payroll mid-year, and who has the year-to-date figures?
changes: PRL-24 (the year-to-date load)
- question: How long do you keep payroll records?
changes: retain_until on documents
standard: "Income Tax (PAYE) Regulations 2003 reg. 97 (3 years after the tax year); National Minimum Wage Regulations 2015 reg. 59 (6 years)"
variation_points:
- node: Task_pay_run_approve_pay_run
op: narrow
default: "a payroll manager who did not calculate the run; their own payslip by a peer"
depth: 1
requirement: "refuse - a route in which the preparer approves the run or anyone approves their own payslip"
- node: Gateway_pay_run_stale
op: row_value
default: "stale payslips block approval"
depth: 1
requirement: "refuse - approving a payslip computed from inputs that changed"
- node: Task_pay_run_explain_variance
op: on_off
default: "suggest"
depth: 1
guideline: "warn - off is safe; it never changes a figure"
migration:
previous_provider:
runs_after: [hr]
reads:
- source: "the previous provider's last FPS of the tax year (XML) and its year-to-date report per employee"
fields: [payroll_id, ni_number, tax_code, taxable pay to date, tax to date, NI per category letter to date (earnings at LEL, LEL to PT, PT to UEL, above UEL, employee and employer NICs), SSP, SMP, SPP, student loan to date, pension to date]
why: "the cumulative tax and the P60 need the whole year"
maps:
- { from: "the FPS employee record", to: ytd_opening, rule: 'ytd_ref "ytd:<payroll_id>:<tax year>"; the person matched through hr''s roster (HR-64) by the provider report''s NI number and name, confirmed by HR-61''s identity; an unmatched employee is listed for HR at the door, never guessed, never a typed map (v2, 2026-10-26); payroll_id kept as HMRC knows it' }
- { from: "the FPS tax code", to: tax_code_row, rule: "valid_from the cut-over date, source manual with the provider named" }
door:
- "the tax month the first run here pays (recommended: the start of a tax month, never mid-period)"
- "whether payroll IDs are kept (recommended) or changed on the first FPS with the old ID"
- "the pension scheme's last contribution period sent by the previous provider"
reconcile:
- { check: "year to date per employee", previous: "the last FPS's to-date figures", ours: "ytd_opening rows", must: equal }
- { check: "scheme totals", previous: "the provider's year-to-date report totals", ours: "SUM of ytd_opening", must: equal }
not_carried:
- "payslips of earlier periods (kept by the previous provider for the record-keeping period)"
- "earlier tax years"
idempotent_on: 'ytd_ref "ytd:<payroll_id>:<tax year>"; prl_migration_map on [source, payroll_id]'
needs: [] # every route this spec calls exists in its owner (hr v3 HR-56..62, time-off v1, timesheets v1, payments v7 PAY-52, gl v8 GL-30); the two capabilities are named seams without a provider
reference:
# `name` is the brick's DISTRIBUTION name (pyproject [project].name): erp-compose init pins
# `<name> @ git+<https>@<tag>`, and pip refuses a pin whose name differs from the package's.
repos:
- name: erp-brick-payroll
role: brick
https: https://git.dodil.io/dodil/erp-brick-payroll.git
ssh: git@git.dodil.io:dodil/erp-brick-payroll.git
tag: v0.2.0
read_first: [README.md, AGENTS.md]
- name: erp-platform
role: platform
https: https://git.dodil.io/dodil/erp-platform.git
ssh: git@git.dodil.io:dodil/erp-platform.git
tag: v0.9.13
read_first: [README.md, AGENTS.md]
packages: []
contracts: []
build_guide: null
known_gaps:
- "nothing is built; this is version 1 of the spec, unsigned"
- "no HMRC or pension provider capability exists: FPS, EPS and PAPDIS files are manual tasks until one does"
- "UK only: Scotland's bands are rows; Northern Ireland and Wales use the UK rows with their own codes; other countries are packs"
- "statutory paternity, adoption, shared parental and parental bereavement pay follow SMP's pattern and are rows plus elements still to be written down"
- "directors' annual earnings period for NI, benefits in kind, salary sacrifice and attachments of earnings are extensions"
- "every 2026/27 figure in the stories is a dated row to be checked against HMRC's published rates before the first run"
- "APQC ids are null until verified against PCF v8.0"
sap_parity:
- { sap: "Payroll GB schema (gross to net, cumulative tax, NI)", ours: covered, where: "PRL-04, PRL-05" }
- { sap: "Wage types and their processing classes", ours: covered, where: "pay_element (taxable, niable, pensionable, gl purpose)" }
- { sap: "RTI FPS / EPS", ours: covered, where: "PRL-13, PRL-14", why: "through a capability; no live HMRC link here" }
- { sap: "Absence valuation (SSP, SMP)", ours: covered, where: "PRL-07, PRL-09 from time-off" }
- { sap: "Pension auto-enrolment (PAE)", ours: covered, where: "PRL-10, PRL-15" }
- { sap: "Posting to accounting (RPCIPE00)", ours: covered, where: "PRL-16 (one journal per run, per purpose and cost centre)" }
- { sap: "Bank transfer (preliminary DME)", ours: different, where: "PRL-12", why: "payments pays and confirms from the statement" }
- { sap: "Off-cycle payroll", ours: covered, where: "PRL-26" }
- { sap: "Retroactive accounting (retro runs)", ours: different, where: "PRL-25, hr retro lines", why: "a change after approval lands in the next period, never a rerun of a closed one" }
- { sap: "Year-end P60 / P11D", ours: covered, where: "PRL-17 (P60); P11D is the benefits_in_kind extension" }