dodil/erp-brick-payroll
DODIL ERP brick: payroll
tests/test_statutory.py
"""SSP and SMP from time-off's absence facts (PRL-07, PRL-09). The rules are pure (domain/statutory.py, tested against the rate rows),
and two stories run through the whole run: PRL-07's sickness and PRL-09's maternity, to the penny."""
from datetime import date
from decimal import Decimal
from fractions import Fraction
import pytest
from conftest import MANAGER, api, must, onboard, open_run, pay_month, refused, setup_company, slip
from erp_platform import work
from brick_payroll.domain import statutory as st
from brick_payroll.domain.errors import RuleBroken
from brick_payroll.domain.paye import Rates
def rates(**over):
base = {("ssp_weekly_rate", "weekly"): "123.25", ("ssp_waiting_days", "any"): "0", ("ssp_lel_test", "any"): "0", ("ssp_requires_piw", "any"): "1",
("ssp_earnings_cap_pct", "any"): "80", ("ssp_max_weeks", "any"): "28", ("ssp_self_certification_days", "any"): "7",
("ni_lower_earnings_limit", "weekly"): "129", ("smp_weekly_flat_rate", "weekly"): "194.32", ("smp_higher_pct", "any"): "90",
("smp_higher_weeks", "any"): "6", ("smp_total_weeks", "any"): "39"}
base.update({(k.split("__")[0], k.split("__")[1]): v for k, v in over.items()})
return Rates([{"rate_key": k, "frequency": f, "value": Decimal(v), "valid_from": date(2026, 4, 6)} for (k, f), v in base.items()])
def weekdays(first, n):
out, d = [], date.fromisoformat(first)
while len(out) < n:
if d.weekday() < 5:
out.append(d)
d = date.fromordinal(d.toordinal() + 1)
return out
def facts(first="2026-11-02", n=10, **kw):
q = weekdays(first, n)
return st.SspFacts("S1", 1, q[0], q[-1], True, kw.pop("linked", False), "S1", kw.pop("evidence_ok", True), q, 5, kw.pop("paid_before", 0))
def test_ssp_pays_the_flat_rate_a_qualifying_day_from_the_first_day_when_the_cap_is_above_it():
got = st.ssp(facts(n=5), date(2026, 11, 1), date(2026, 11, 30), Fraction(692.31).limit_denominator(100), rates())
assert len(got.paid_days) == 5 and got.weekly == Fraction("123.25") and got.waiting_days == []
assert got.daily == Fraction("24.65") # 123.25 / 5 qualifying days
capped = st.ssp(facts(n=5), date(2026, 11, 1), date(2026, 11, 30), Fraction(100), rates())
assert capped.weekly == Fraction(80) # the lower of the rate and 80% of 100.00
def test_waiting_days_are_a_dated_row_not_paid_and_waived_in_a_linked_period():
r = rates(ssp_waiting_days__any="3")
first = st.ssp(facts(n=5), date(2026, 11, 1), date(2026, 11, 30), Fraction(700), r)
assert [d.day for d in first.waiting_days] == [2, 3, 4] and [d.day for d in first.paid_days] == [5, 6]
linked = st.ssp(facts(n=5, linked=True), date(2026, 11, 1), date(2026, 11, 30), Fraction(700), r)
assert linked.waiting_days == [] and len(linked.paid_days) == 5 # served in the earlier period
assert [d.day for d in first.deduct_days] == [2, 3, 4, 5, 6] # waiting days are unpaid, not worked
def test_the_lower_earnings_limit_test_is_a_dated_row_and_keeps_ssp_from_low_earners():
on = rates(ssp_lel_test__any="1")
assert st.ssp(facts(n=5), date(2026, 11, 1), date(2026, 11, 30), Fraction(120), on).paid_days == []
assert "lower earnings limit" in st.ssp(facts(n=5), date(2026, 11, 1), date(2026, 11, 30), Fraction(120), on).reason
assert len(st.ssp(facts(n=5), date(2026, 11, 1), date(2026, 11, 30), Fraction(130), on).paid_days) == 5
assert len(st.ssp(facts(n=5), date(2026, 11, 1), date(2026, 11, 30), Fraction(120), rates()).paid_days) == 5 # no test from 6 April 2026
def test_ssp_stops_at_28_weeks_across_linked_periods_and_a_missing_fit_note_stops_it_after_the_self_certification_days():
got = st.ssp(facts(n=5, paid_before=28 * 5 - 2), date(2026, 11, 1), date(2026, 11, 30), Fraction(700), rates())
assert len(got.paid_days) == 2 and got.notes["stopped_at_limit"]
assert st.ssp(facts(n=5, paid_before=140), date(2026, 11, 1), date(2026, 11, 30), Fraction(700), rates()).paid_days == []
late = st.ssp(facts(n=10, evidence_ok=False), date(2026, 11, 1), date(2026, 11, 30), Fraction(700), rates())
assert [d.day for d in late.paid_days] == [2, 3, 4, 5, 6] # calendar days 1 to 7 of the spell (2 to 8 November); the fit note is due from the 9th
def test_smp_is_90_percent_for_six_weeks_then_the_lower_of_the_flat_rate_and_90_percent():
f = st.SmpFacts("M1", 1, date(2026, 11, 15), date(2026, 6, 1), date(2026, 11, 2), date(2027, 10, 31), True)
first = st.smp(f, date(2026, 11, 1), date(2026, 11, 30), Fraction("650.0008"), rates())
assert first.days == 29 and f"{st.money(first.amount)}" == "2423.57"
week7 = st.smp(f, date(2026, 12, 14), date(2026, 12, 20), Fraction(650), rates()) # weeks 7 and 8 fall here: 14 Dec is day 42
assert week7.notes["weekly_later"] == "194.32" and f"{st.money(week7.amount)}" == "194.32"
low = st.smp(f, date(2026, 12, 14), date(2026, 12, 20), Fraction(200), rates()) # 90% of 200.00 = 180.00 is below the flat rate
assert low.notes["weekly_later"] == "180.00"
def test_smp_needs_the_evidence_26_weeks_service_and_earnings_at_the_lower_earnings_limit():
base = dict(ewc=date(2026, 11, 15), start=date(2026, 11, 2), last_day=date(2026, 11, 30))
assert "MATB1" in st.smp(st.SmpFacts("M", 1, evidence_received_on=None, employed_26_weeks=True, **base), date(2026, 11, 1), date(2026, 11, 30), Fraction(650), rates()).reason
assert "26 weeks" in st.smp(st.SmpFacts("M", 1, evidence_received_on=date(2026, 6, 1), employed_26_weeks=False, **base), date(2026, 11, 1),
date(2026, 11, 30), Fraction(650), rates()).reason
assert "lower earnings limit" in st.smp(st.SmpFacts("M", 1, evidence_received_on=date(2026, 6, 1), employed_26_weeks=True, **base), date(2026, 11, 1),
date(2026, 11, 30), Fraction(100), rates()).reason
with pytest.raises(RuleBroken, match="no earnings"):
st.smp(st.SmpFacts("M", 1, evidence_received_on=date(2026, 6, 1), employed_26_weeks=True, **base), date(2026, 11, 1), date(2026, 11, 30), None, rates())
def test_the_qualifying_week_and_the_relevant_period_of_the_story():
assert st.qualifying_week(date(2026, 11, 15)) == (date(2026, 8, 2), date(2026, 8, 8))
assert st.relevant_period(date(2026, 11, 15)) == (date(2026, 6, 14), date(2026, 8, 8))
assert str(st.recovery(Fraction("2423.57"), False, rates(smp_recovery_pct__any="92"))) == "2229.68"
assert str(st.recovery(Fraction("2423.57"), True, rates(smp_small_employer_recovery_pct__any="109"))) == "2641.69"
def _set_rate(engine, key, value, frm="2026-11-01", freq="any"):
from brick_payroll.infrastructure.tables import _plain, prl_statutory_rate
with engine.begin() as conn:
_plain(conn, prl_statutory_rate, {"rate_key": key, "region": "uk", "frequency": freq, "valid_from": date.fromisoformat(frm), "value": Decimal(value),
"unit": "flag", "source": "test", "to_confirm": False, "set_by": "test"}, work.now())
def test_prl_07_sickness_is_a_deduction_at_the_day_rate_and_ssp_on_the_qualifying_days(nb, engine):
"""Given 4410 (36,000.00) sick 10-12 November and the row that follows the story (a 3 day spell pays), then 3 x 138.4615 = 415.38 comes off and
SSP 3 x 24.65 = 73.95 goes on: gross 3,000.00 - 415.38 + 73.95 = 2,658.57 (80% of 692.31 = 553.85 is above 123.25, so the flat rate)."""
setup_company()
_set_rate(engine, "ssp_requires_piw", "0")
nb.sick(4410, ["2026-11-10", "2026-11-11", "2026-11-12"])
run = open_run()["pay_run_id"]
s = slip(onboard(run), "4410-1")
lines = {ln["element_code"]: ln for ln in s["lines"]}
assert (lines["SICK_DEDUCTION"]["quantity"], lines["SICK_DEDUCTION"]["rate_amount"], lines["SICK_DEDUCTION"]["amount"]) == ("3.0000", "138.4615", "-415.38")
assert (lines["SSP"]["quantity"], lines["SSP"]["rate_amount"], lines["SSP"]["amount"]) == ("3.0000", "24.6500", "73.95")
assert "UK01-TOF-2026-00100 r1" in lines["SSP"]["source_ref"] and "ssp_weekly_rate/weekly@2026-04-06" in lines["SSP"]["rate_rows_used"]
assert s["gross"] == "2658.57"
def test_a_three_day_sickness_is_not_a_period_of_incapacity_so_the_default_pays_no_ssp(nb):
setup_company()
nb.sick(4410, ["2026-11-10", "2026-11-11", "2026-11-12"])
run = open_run()["pay_run_id"]
s = slip(onboard(run), "4410-1")
assert "SSP" not in {ln["element_code"] for ln in s["lines"]} and s["gross"] == "2584.62" # 3,000.00 - 415.38: sick, not paid
got = must(api("GET", f"/pay-runs/{run}/payslips/4410-1", MANAGER))
assert got["statutory"]["ssp"][0]["reason"].startswith("not a period of incapacity")
def test_a_five_day_period_of_incapacity_pays_ssp_from_the_first_day_and_the_week_count_is_its_own(nb):
setup_company()
nb.sick(4410, ["2026-11-09", "2026-11-10", "2026-11-11", "2026-11-12", "2026-11-13"])
run = open_run()["pay_run_id"]
s = slip(onboard(run), "4410-1")
ssp = next(ln for ln in s["lines"] if ln["element_code"] == "SSP")
assert (ssp["quantity"], ssp["amount"]) == ("5.0000", "123.25")
def test_prl_09_maternity_pay_from_the_facts_time_off_recorded(nb):
"""Given 4431 (33,800.00, employed since 2023) on maternity from 2 November (EWC 15 November, MATB1 1 June) and her two paydays in the
relevant period (2,816.67 each), then AWE 650.00, 90% = 585.00: 4 whole weeks 2,340.00 + 1 day 83.57 = 2,423.57 and salary 0.00."""
setup_company()
nb.salaried(4431, "33800.00", start="2023-03-01")
for month, at in (("2026-06", "2026-06-24T10:00:00+01:00"), ("2026-07", "2026-07-27T10:00:00+01:00")):
pay_month(month, at)
nb.maternity(4431, "2026-11-02", "2026-11-15", "2026-06-01", "2027-10-29")
run = open_run("2026-11")["pay_run_id"]
s = slip(onboard(run), "4431-1")
lines = {ln["element_code"]: ln for ln in s["lines"]}
assert (lines["SALARY"]["amount"], lines["MATERNITY_DEDUCTION"]["amount"], lines["SMP"]["amount"]) == ("2816.67", "-2816.67", "2423.57")
assert s["gross"] == "2423.57"
smp = must(api("GET", f"/pay-runs/{run}/payslips/4431-1", MANAGER))["statutory"]["smp"][0]
assert (smp["awe"], smp["awe_basis"], smp["weekly_higher"], smp["weekly_later"], smp["days"]) == ("650.00", "payslips", "585.00", "194.32", 29)
assert smp["schedule"] == [{"weeks": "1-6", "weekly": "585.00"}, {"weeks": "7-39", "weekly": "194.32"}]
assert smp["relevant_period"] == "2026-06-14..2026-08-08" and smp["qualifying_week"] == "2026-08-02..2026-08-08"
def test_an_earlier_birth_changes_the_absence_and_marks_the_payslip_stale(nb):
setup_company()
nb.salaried(4431, "33800.00", start="2023-03-01")
nb.maternity(4431, "2026-11-02", "2026-11-15", "2026-06-01", "2027-10-29")
run = open_run("2026-11")["pay_run_id"]
onboard(run)
nb.absences[0]["statutory"]["ewc"] = "2026-11-08"
nb.absences[0]["revision"] = 2
refused(api("POST", f"/pay-runs/{run}/approve", MANAGER), 409, "4431-1")