dodil/erp-brick-payroll

DODIL ERP brick: payroll

tests/test_rules.py

"""The method, as pure functions (domain/paye.py, domain/pay_run.py): HMRC's tax table routines, NI by the exact percentage
method, pension on qualifying earnings, proration, and the one table of arrows checked against the signed process."""
import pathlib
from datetime import date
from decimal import Decimal as D

import pytest
import yaml

from brick_payroll.domain import pay_run as pr
from brick_payroll.domain import paye
from brick_payroll.domain.errors import RuleBroken
from brick_payroll.domain.values import DEFAULT_RATES

PROCESS = pathlib.Path(__file__).resolve().parents[1] / "src" / "brick_payroll" / "application" / "process.yaml"


def rates():
    return paye.Rates([{"rate_key": k, "frequency": f, "value": D(v), "valid_from": date(2026, 4, 6)} for k, _, f, v, _, _ in DEFAULT_RATES])


def tax(code, pay, n=8, prev_pay="0", prev_tax="0", basis="cumulative"):
    return paye.paye(paye.parse_tax_code(code), basis, "monthly", n, D(pay), D(prev_pay), D(prev_tax), rates()).tax


def test_the_tax_year_and_its_months_follow_the_pay_date():
    assert [paye.tax_month(date(2026, m, d)) for m, d in ((4, 6), (5, 5), (5, 6), (11, 30), (12, 5), (12, 6), (4, 5))] == [1, 1, 2, 8, 8, 9, 12]
    assert paye.tax_year_label(paye.tax_year_of(date(2027, 4, 5))) == "2026-27" and paye.tax_year_of(date(2027, 4, 6)) == 2027
    assert pr.pay_run_id("UK01", "M", date(2026, 11, 30)) == "UK01-M-2026-27-M08"
    assert pr.last_working_day(*pr.month_bounds("2026-10")) == date(2026, 10, 30)                # the 31st is a Saturday


def test_cumulative_tax_of_the_worked_example_and_month_one():
    assert tax("1257L", "2876.00", 8, "20132.00", "2558.80") == D("365.60")
    assert tax("1257L", "2876.00", 1) == D("365.40")                     # 2,876.00 - 1,048.25 = 1,827 x 20%
    assert tax("1257L", "2876.00", 1, basis="week1_month1") == D("365.40")
    assert tax("1257L", "2876.00", 8, basis="week1_month1") == D("365.40")  # week1/month1 ignores the year to date


def test_cumulative_tax_refunds_when_the_code_goes_up_and_bands_to_date_are_rounded_up_to_the_pound():
    refund = tax("1257L", "0.00", 2, "2876.00", "500.00")                 # 2,876 - 2,096.50 = 779 taxable: 155.80 due, 500.00 paid
    assert refund == D("-344.20")
    high = tax("1257L", "10000.00", 1)                                    # 10,000 - 1,048.25 = 8,951: 3,142 at 20%, 5,809 at 40%
    assert high == D("2952.00")


def test_flat_codes_k_codes_and_the_overriding_limit():
    assert tax("BR", "401.20") == D("80.24")                              # PRL-05's figure
    assert tax("D0", "1000.00") == D("400.00") and tax("D1", "1000.00") == D("450.00") and tax("NT", "5000.00") == D("0.00")
    assert tax("0T", "1000.00", 1) == D("200.00")
    assert tax("K475", "1000.00", 1) == D("279.20")                      # 1,000 + 396.59 = 1,396 x 20%, under the 50% limit
    assert tax("K475", "100.00", 1) == D("50.00")                        # 100 + 396.59 -> 99.20 due, capped at 50.00
    with pytest.raises(paye.UnsupportedHere):
        paye.parse_tax_code("S1257L")
    with pytest.raises(RuleBroken):
        paye.parse_tax_code("12X57")


def test_ni_category_a_by_the_exact_percentage_method():
    ni = paye.class1_ni("A", "monthly", D("3000.00"), rates())
    assert (ni.employee, ni.employer) == (D("156.16"), D("387.45"))
    above = paye.class1_ni("A", "monthly", D("5000.00"), rates())        # 3,141 x 8% + 811 x 2%; 4,583 x 15%
    assert (above.employee, above.employer) == (D("267.50"), D("687.45"))
    low = paye.class1_ni("A", "monthly", D("400.00"), rates())
    assert (low.employee, low.employer) == (D("0.00"), D("0.00"))
    half = paye.class1_ni("A", "monthly", D("1048.0625"), rates())       # 0.0625 x 8% = 0.005: an exact half penny rounds DOWN
    assert half.employee == D("0.00")
    with pytest.raises(paye.UnsupportedHere):
        paye.class1_ni("C", "monthly", D("3000.00"), rates())


def test_pension_on_qualifying_earnings_and_the_band():
    s = paye.Scheme("UK01-QS", "qualifying_earnings", D("5"), D("3"), "net_pay")
    p = paye.pension(s, "monthly", D("3000.00"), rates())
    assert (p.employee, p.employer, p.pensionable) == (D("124.00"), D("74.40"), D("2480.00"))
    top = paye.pension(s, "monthly", D("9000.00"), rates())               # capped at the upper limit: 4,189 - 520
    assert top.pensionable == D("3669.00") and top.employee == D("183.45")
    assert paye.pension(s, "monthly", D("400.00"), rates()).employee == D("0.00")
    assert paye.pension(None, "monthly", D("3000.00"), rates()).employee == D("0.00")


def test_proration_by_working_days_is_rounded_once():
    assert pr.salary_line("year", D("30000"), "monthly", 11, 21) == (D("11"), D("2500.00"), D("1309.52"))
    assert pr.salary_line("year", D("36000"), "monthly", 12, 22)[2] == D("1636.36")
    assert pr.salary_line("year", D("25000"), "monthly", None, None)[2] == D("2083.33")
    with pytest.raises(paye.UnsupportedHere):
        pr.salary_line("hour", D("13"), "monthly", None, None)


def test_the_bacs_lead_counts_working_days_from_the_business_date():
    assert pr.bacs_lead_met(date(2026, 11, 25), date(2026, 11, 30), 3)     # Thu, Fri, Mon
    assert not pr.bacs_lead_met(date(2026, 11, 26), date(2026, 11, 30), 3)


def test_the_arrows_are_the_signed_workflows():
    spec = {w["entity"]: w for w in yaml.safe_load(PROCESS.read_text())["workflows"]}
    for entity, moves in (("pay_run", pr.MOVES), ("payroll_employee", pr.EMPLOYEE_MOVES)):
        signed = {t["key"]: ((tuple(t["from"]) if isinstance(t["from"], list) else (t["from"],)), t["to"]) for t in spec[entity]["transitions"]}
        assert {k: v for k, v in signed.items() if k in moves} == moves, entity
    assert set(pr.MOVES) == {t["key"] for t in spec["pay_run"]["transitions"]}
    with pytest.raises(RuleBroken):
        pr.move("approved", "recalculate_pay_run")
    with pytest.raises(RuleBroken):
        pr.move("approved", "cancel_pay_run")


def test_the_journal_balances_and_a_refund_run_goes_on_the_other_side():
    a = pr.Payslip("R", "1-1", 1, 1, gross=D("3000.00"), tax=D("365.60"), ee_ni=D("156.16"), er_ni=D("387.45"), ee_pension=D("124.00"),
                   er_pension=D("74.40"), net=D("2354.24"), cost_centre="SALES-N")
    b = pr.Payslip("R", "2-1", 2, 1, gross=D("0.00"), tax=D("-500.00"), net=D("500.00"), cost_centre="SALES-N")
    lines = pr.journal_lines([a, b])
    assert {"purpose": "paye_ni_payable", "credit": "409.21"} in lines and {"purpose": "net_pay_payable", "credit": "2854.24"} in lines
    only_refund = pr.journal_lines([b])
    assert only_refund == [{"purpose": "paye_ni_payable", "debit": "500.00"}, {"purpose": "net_pay_payable", "credit": "500.00"}]