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"]