dodil/erp-brick-payroll
DODIL ERP brick: payroll
tests/test_starters_leavers.py
"""Starters and leavers: the starter checklist (statements A, B and C and the emergency code, PRL-02), the P45 a starter brings (PRL-01), the
P45 a leaver is given when the final payslip is approved, and a payment after it (PRL-08)."""
import base64
from conftest import (
ADMIN,
ADMIN2,
EMP4410,
MANAGER,
NOBODY,
api,
calculate,
must,
on_day,
onboard,
open_run,
pay_month,
peer_approves_own,
refused,
setup_company,
slip,
)
from conftest import approve as approve_run
def pending(who=ADMIN):
return [e["payroll_id"] for e in must(api("GET", "/payroll-employees", who, {"status": "pending_details"}))["employees"]]
def finish_the_rest(run, skip=()):
"""Complete everyone still waiting except `skip` the plain way, so a calculation can go ahead."""
for pid in pending():
if pid not in skip:
must(api("POST", f"/payroll-employees/{pid}/details", ADMIN2 if pid == "2001-1" else ADMIN,
{"tax_code": "1257L", "basis": "cumulative", "ni_category": "A", "source": "manual", "valid_from": "2026-04-06"}))
def test_prl_02_each_statement_sets_the_tax_code_dated_from_the_start(nb):
"""Given starters 4451 (A), 4452 (B) and 4453 (C) with no P45, then 1257L cumulative, 1257L month 1 and BR, each dated from the start date, source
starter_checklist, and the statement kept for the first FPS."""
setup_company()
for pid, start in ((4451, "2026-11-02"), (4452, "2026-11-02"), (4453, "2026-11-09")):
nb.salaried(pid, "30000.00", start=start)
run = open_run()["pay_run_id"]
calculate(run)
got = {}
for pid, statement in (("4451-1", "A"), ("4452-1", "B"), ("4453-1", "C")):
must(api("POST", f"/payroll-employees/{pid}/starter-declaration", ADMIN, {"statement": statement}))
got[pid] = must(api("GET", f"/payroll-employees/{pid}", ADMIN))
assert [(g["tax_codes"][0]["tax_code"], g["tax_codes"][0]["basis"], g["tax_codes"][0]["source"], g["tax_codes"][0]["valid_from"], g["starter_declaration"])
for g in got.values()] == [("1257L", "cumulative", "starter_checklist", "2026-11-02", "A"), ("1257L", "week1_month1", "starter_checklist", "2026-11-02", "B"),
("BR", "cumulative", "starter_checklist", "2026-11-09", "C")]
finish_the_rest(run)
view = onboard(run)
assert [(slip(view, p)["tax_code"], slip(view, p)["basis"]) for p in ("4451-1", "4452-1", "4453-1")] == [("1257L", "cumulative"), ("1257L", "week1_month1"), ("BR", "cumulative")]
def test_no_statement_by_the_first_run_is_the_emergency_code_with_a_warning_a_person_chose(nb):
setup_company()
nb.salaried(4454, "30000.00", start="2026-11-02")
run = open_run()["pay_run_id"]
refused(calculate(run), 409, "4454-1 has no tax code") # the run never invents a code
must(api("POST", "/payroll-employees/4454-1/emergency-code", ADMIN, {}))
finish_the_rest(run)
s = slip(onboard(run), "4454-1")
assert (s["tax_code"], s["basis"]) == ("1257L", "week1_month1")
detail = must(api("GET", f"/pay-runs/{run}/payslips/4454-1", MANAGER))
assert detail["warnings"] and "emergency code" in detail["warnings"][0]
assert must(api("GET", "/payroll-employees/4454-1", ADMIN))["tax_codes"][0]["source"] == "emergency"
refused(api("POST", "/payroll-employees/4454-1/starter-declaration", ADMIN, {"statement": "A"}), 409, "pending_details") # active now: not twice
def test_prl_01_a_p45_is_data_and_its_figures_count_in_the_cumulative_tax_only(nb):
"""Starter 4450 on 30,000.00 from 2026-11-16 brings a P45: 1257L cumulative, pay 12,000.00 and tax 1,000.00 to date. Month 8: pay 1,309.52, to date
13,309.52 - free pay 8,386.00 = 4,923 taxable at 20% = 984.60, less the 1,000.00 already deducted: a refund of 15.40."""
setup_company()
nb.salaried(4450, "30000.00", start="2026-11-16")
run = open_run()["pay_run_id"]
calculate(run)
got = must(api("POST", "/payroll-employees/4450-1/p45", ADMIN, {"tax_code": "1257l", "previous_pay_to_date": "12000.00", "previous_tax_to_date": "1000.00",
"previous_paye_ref": "999/ZZ12345", "previous_leaving_date": "2026-11-13"}))
assert got["status"] == "active" and got["p45_pay_to_date"] == "12000.00" and got["tax_codes"][0]["source"] == "p45"
finish_the_rest(run)
s = slip(onboard(run), "4450-1")
assert (s["gross"], s["tax"], s["to_date"]["tax"], s["to_date"]["taxable"]) == ("1309.52", "-15.40", "-15.40", "1309.52") # this employer's year to date excludes the P45
kept = must(api("GET", "/payroll-employees/4450-1", ADMIN))["p45"]
assert kept == [{"direction": "in", "paye_ref": "999/ZZ12345", "leaving_date": "2026-11-13", "tax_code": "1257L", "basis": "cumulative",
"pay_to_date": "12000.00", "tax_to_date": "1000.00", "student_loan": False, "document_key": None}]
refused(api("POST", "/payroll-employees/4450-1/p45", ADMIN, {"tax_code": "1257L", "previous_pay_to_date": "1.00", "previous_tax_to_date": "0.00",
"previous_paye_ref": "999/ZZ12345", "previous_leaving_date": "2026-11-13"}), 409, "pending_details")
def test_a_p45_with_the_week1_month1_flag_ignores_its_figures_in_the_tax_calculation(nb):
setup_company()
nb.salaried(4450, "30000.00", start="2026-11-16")
run = open_run()["pay_run_id"]
calculate(run)
must(api("POST", "/payroll-employees/4450-1/p45", ADMIN, {"tax_code": "1257L", "week1_month1": True, "previous_pay_to_date": "12000.00",
"previous_tax_to_date": "1000.00", "previous_paye_ref": "999/ZZ12345", "previous_leaving_date": "2026-11-13"}))
finish_the_rest(run)
s = slip(onboard(run), "4450-1")
assert (s["basis"], s["tax"]) == ("week1_month1", "52.20") # (1,309.52 - 1,048.25 free pay) rounded down to 261 x 20%: the P45's figures play no part
def test_prl_08_the_leavers_final_payslip_issues_the_p45_and_moves_them_to_left(nb):
"""Leaver 4490 (last day 2026-10-16, 36,000.00, 1257L cumulative): approving October gives the P45 (pay and tax to date as data and a document under the
spec's key), the FPS later carries the leaving date, and the payroll employee is left."""
setup_company()
nb.salaried(4490, "36000.00", last_day="2026-10-16")
nb.balances[4490] = "4.79"
with on_day("2026-10-20T10:00:00+01:00"):
run = open_run("2026-10")["pay_run_id"]
view = onboard(run)
peer_approves_own(run)
must(approve_run(run))
s = slip(view, "4490-1")
emp = must(api("GET", "/payroll-employees/4490-1", MANAGER))
assert emp["status"] == "left" and emp["leaving_date"] == "2026-10-16"
(p45,) = [p for p in emp["p45"] if p["direction"] == "out"]
assert (p45["leaving_date"], p45["tax_code"], p45["basis"], p45["document_key"]) == ("2026-10-16", "1257L", "cumulative", "raw/payroll/UK01/2026-27/p45/4490/UK01-M-2026-27-M07.pdf")
assert p45["pay_to_date"] == s["to_date"]["taxable"] and p45["tax_to_date"] == s["to_date"]["tax"]
doc = must(api("GET", "/payroll-employees/4490-1/p45/document", MANAGER))
pdf = base64.b64decode(doc["content_base64"]).decode("ascii")
assert "P45 Part 1A" in pdf and "Example4490" in pdf and "QQ004490C" in pdf and "Leaving date 2026-10-16" in pdf
refused(api("GET", "/payroll-employees/4490-1/p45/document", NOBODY), 404, "no P45")
refused(api("GET", "/payroll-employees/4490-1/p45/document", EMP4410), 404, "no P45") # someone else's
def test_a_payment_after_the_p45_is_taxed_on_0t_month_1_and_reports_the_leaving_date_and_gets_no_second_p45(nb):
setup_company()
nb.salaried(4490, "36000.00", last_day="2026-10-16")
pay_month("2026-10", "2026-10-20T10:00:00+01:00")
nb.late_payment(4490, "2026-11", "500.00") # a late bonus hr still extracts
with on_day("2026-11-24T10:00:00+00:00"):
run = open_run("2026-11")["pay_run_id"]
view = onboard(run)
s = slip(view, "4490-1")
assert (s["tax_code"], s["basis"], s["gross"], s["tax"], s["leaving_date"]) == ("0T", "week1_month1", "500.00", "100.00", "2026-10-16") # 500.00 x 20%
warn = must(api("GET", f"/pay-runs/{run}/payslips/4490-1", MANAGER))["warnings"]
assert "paid after the P45" in warn[0]
peer_approves_own(run)
assert must(approve_run(run))["status"] == "approved" # no second P45: the 'out' row is written once
assert [p["direction"] for p in must(api("GET", "/payroll-employees/4490-1", MANAGER))["p45"]] == ["out"]