dodil/erp-brick-payroll
DODIL ERP brick: payroll
tests/test_weekly.py
"""Weekly (and, in the tables, fortnightly and four-weekly) pay: the tax period of a pay date, HMRC's cumulative PAYE and Class 1 by period
table, and a weekly run end to end (PRL-05's worked example to the penny, from the rate rows)."""
from datetime import date
from decimal import Decimal
import pytest
from conftest import ADMIN, ADMIN2, MANAGER, api, approve, must, on_day, peer_approves_own, refused, setup_company, slip
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.money import money
from brick_payroll.domain.values import DEFAULT_RATES, PERIOD_RATES
def seeded_rates():
return paye.Rates([{"rate_key": k, "region": rg, "frequency": f, "valid_from": date(2026, 4, 6), "value": Decimal(v)}
for k, rg, f, v, _, _ in DEFAULT_RATES] + [{"rate_key": k, "region": rg, "frequency": f, "valid_from": vf, "value": Decimal(v)}
for k, rg, f, vf, v, _, _ in PERIOD_RATES])
def test_the_tax_period_of_a_pay_date_by_frequency():
assert paye.tax_period_of("monthly", date(2026, 11, 30)) == 8
assert paye.tax_period_of("weekly", date(2026, 11, 20)) == 33 # PRL-05: UK01-W-2026-27-W33
assert paye.tax_period_of("weekly", date(2026, 4, 6)) == 1 and paye.tax_period_of("weekly", date(2026, 4, 12)) == 1
assert paye.tax_period_of("weekly", date(2026, 4, 13)) == 2
assert paye.tax_period_of("weekly", date(2027, 4, 5)) == 53 # the 53rd week: one or two days at the end of the year
assert paye.tax_period_of("fortnightly", date(2026, 11, 20)) == 17 and paye.tax_period_of("four_weekly", date(2026, 11, 20)) == 9
def test_a_weekly_run_is_the_iso_week_worked_paid_the_friday_after():
assert pr.period_dates("weekly", "2026-W46") == (date(2026, 11, 9), date(2026, 11, 15), date(2026, 11, 20))
assert pr.period_dates("monthly", "2026-11")[2] == date(2026, 11, 30)
assert pr.pay_run_id("UK01", "W", date(2026, 11, 20), 1, "weekly") == "UK01-W-2026-27-W33"
assert pr.pay_run_id("UK01", "W", date(2026, 11, 20), 2, "weekly") == "UK01-W-2026-27-W33-R2"
with pytest.raises(RuleBroken, match="ISO week"):
pr.week_bounds("2026-W54")
with pytest.raises(RuleBroken, match="monthly and weekly only"):
pr.period_dates("fortnightly", "2026-W46")
def test_class_1_uses_the_period_table_of_the_schedule_and_never_prorates():
r = seeded_rates()
ee, er = paye.class1_ni("A", "weekly", Decimal("416.00"), r).employee, paye.class1_ni("A", "weekly", Decimal("416.00"), r).employer
assert (ee, er) == (Decimal("13.92"), Decimal("48.00")) # (416.00 - 242.00) x 8%, (416.00 - 96.00) x 15%
assert paye.class1_ni("A", "monthly", Decimal("416.00"), r).employee == Decimal("0.00") # under the monthly threshold
assert paye.class1_ni("A", "fortnightly", Decimal("1000.00"), r).employee == money(Decimal("1000.00") - Decimal("484")) * Decimal("0.08")
assert paye.class1_ni("A", "four_weekly", Decimal("2000.00"), r).employer == money((Decimal("2000.00") - Decimal("384")) * Decimal("0.15"))
def test_cumulative_paye_scales_the_free_pay_and_the_bands_by_the_number_of_periods():
r = seeded_rates()
code = paye.parse_tax_code("1257L")
week = paye.paye(code, "cumulative", "weekly", 33, Decimal("20000.00"), Decimal("0.00"), Decimal("0.00"), r)
# free pay to date 12,579 x 33 / 52 = 7,982.83 (up to the penny); taxable 12,017.17 -> 12,017 (down); basic band 37,700 x 33 / 52 = 23,925 (up)
assert week.detail["free_pay_to_date"] == "7982.83" and week.detail["taxable_to_date"] == "12017" and week.detail["basic_band_to_date"] == "23925"
assert week.tax == money(Decimal("12017") * Decimal("0.2"))
month = paye.paye(code, "cumulative", "monthly", 8, Decimal("23008.00"), Decimal("0.00"), Decimal("0.00"), r)
assert month.detail["free_pay_to_date"] == "8386.00" # the PRL-04 figure is unchanged
week53 = paye.paye(code, "cumulative", "weekly", 53, Decimal("20000.00"), Decimal("0.00"), Decimal("0.00"), r)
assert week53.detail["free_pay_to_date"] == "12820.91" and week53.detail["basic_band_to_date"] == "38425" # 53 / 52 of the year: the tables run on
def test_prl_05_the_weekly_worked_example_to_the_penny():
"""32.00 hours x 13.0000 = 416.00; qualifying earnings 416.00 - 120.00 = 296.00: pension 14.80 / 8.88; BR on 401.20 = 80.24; NI 13.92 / 48.00; net 307.04."""
run = pr.PayRun("UK01-W-2026-27-W33", "UK01", "W", "weekly", "regular", "2026-27", 33, "2026-W46", date(2026, 11, 9), date(2026, 11, 15),
date(2026, 11, 20), "123/AB45678")
inp = pr.PersonInput("4470-1", 4470, [pr.Earning("SALARY", Decimal("32.0000"), Decimal("13.0000"), Decimal("416.00"), "hours", "2026-W46")],
paye.parse_tax_code("BR"), "cumulative", "A", paye.Scheme("UK01-QS", "qualifying_earnings", Decimal("5"), Decimal("3"), "net_pay"), {})
s = pr.gross_to_net(run, inp, seeded_rates())
assert (s.gross, s.ee_pension, s.er_pension, s.taxable, s.tax, s.ee_ni, s.er_ni, s.net) == tuple(
Decimal(x) for x in ("416.00", "14.80", "8.88", "401.20", "80.24", "13.92", "48.00", "307.04"))
assert any("week 33" in ln.source_ref for ln in s.lines)
def _weekly_schedule():
must(api("POST", "/pay-schedules", MANAGER, {"company_code": "UK01", "schedule_code": "W", "paye_ref": "123/AB45678", "frequency": "weekly"}))
def _weekly_company():
setup_company()
_weekly_schedule()
def test_a_weekly_schedule_is_paid_friday_after_and_a_week_is_not_a_month(nb):
setup_company()
refused(api("POST", "/pay-schedules", MANAGER, {"company_code": "UK01", "schedule_code": "W", "paye_ref": "123/AB45678", "frequency": "weekly",
"pay_day_rule": "last_working_day"}), 422, "friday_after")
_weekly_schedule()
run = must(api("POST", "/pay-runs", ADMIN, {"company_code": "UK01", "schedule_code": "W", "period": "2026-W46"}), 201)
assert (run["pay_run_id"], run["period_from"], run["period_to"], run["pay_date"], run["tax_period"]) == (
"UK01-W-2026-27-W33", "2026-11-09", "2026-11-15", "2026-11-20", 33)
refused(api("POST", "/pay-runs", ADMIN, {"company_code": "UK01", "schedule_code": "W", "period": "2026-W46"}), 409, "period") # one regular run per period
refused(api("POST", "/pay-runs", ADMIN, {"company_code": "UK01", "schedule_code": "W", "period": "2026-11"}), 422, "ISO week")
refused(api("POST", "/pay-runs", ADMIN, {"company_code": "UK01", "schedule_code": "M", "period": "2026-W46"}), 422, "calendar month")
def _onboard_br(run_id):
got = api("POST", f"/pay-runs/{run_id}/calculate", ADMIN)
assert got[0] == 409
for e in must(api("GET", "/payroll-employees", ADMIN, {"status": "pending_details"}))["employees"]:
must(api("POST", f"/payroll-employees/{e['payroll_id']}/details", ADMIN2 if e["payroll_id"] == "2001-1" else ADMIN,
{"tax_code": "BR", "basis": "cumulative", "ni_category": "A", "source": "manual", "valid_from": "2026-04-06"}))
if e["payroll_id"] == "4410-1":
must(api("POST", f"/payroll-employees/{e['payroll_id']}/pension", ADMIN, {"scheme_code": "UK01-QS", "enrolled_on": "2026-04-06"}), 201)
return must(api("POST", f"/pay-runs/{run_id}/calculate", ADMIN))
def test_a_salaried_weekly_run_calculates_with_the_weekly_tables_and_approves(nb):
"""4410: 36,000.00 / 52 = 692.31 a week, BR: pension 28.62 / 17.17 on 572.31, taxable 663.69, tax 132.73, NI 36.02 / 89.45, net 494.94."""
_weekly_company()
with on_day("2026-11-16T10:00:00+00:00"):
run = must(api("POST", "/pay-runs", ADMIN, {"company_code": "UK01", "schedule_code": "W", "period": "2026-W46"}), 201)["pay_run_id"]
s = slip(_onboard_br(run), "4410-1")
assert (s["gross"], s["employee_pension"], s["employer_pension"], s["taxable"], s["tax"], s["employee_ni"], s["employer_ni"], s["net"]) == (
"692.31", "28.62", "17.17", "663.69", "132.73", "36.02", "89.45", "494.94")
tax = next(ln for ln in s["lines"] if ln["element_code"] == "TAX")
assert tax["source_ref"] == "BR cumulative week 33" and "ni_primary_threshold/weekly@2026-04-06" in next(
ln for ln in s["lines"] if ln["element_code"] == "EE_NI")["rate_rows_used"]
peer_approves_own(run)
done = must(approve(run))
assert done["status"] == "approved" and done["journal_status"] == "posted"
assert nb.journals and next(iter(nb.journals.values()))["journal_date"] == "2026-11-20"
def test_the_next_week_is_opened_only_after_the_previous_is_approved(nb):
_weekly_company()
with on_day("2026-11-16T10:00:00+00:00"):
must(api("POST", "/pay-runs", ADMIN, {"company_code": "UK01", "schedule_code": "W", "period": "2026-W46"}), 201)
refused(api("POST", "/pay-runs", ADMIN, {"company_code": "UK01", "schedule_code": "W", "period": "2026-W47"}), 409, "approve it first")