dodil/erp-brick-payroll
DODIL ERP brick: payroll
src/brick_payroll/domain/values.py
"""The rows a customer (or their payroll manager) keeps, and their starting values. The CODE holds HMRC's method; every
rate, band and threshold is a dated row (prl_statutory_rate), so a new tax year is entered and checked, never programmed.
The 2026/27 rows below are the spec's (module.yaml header), seeded with to_confirm = true: a payroll manager checks each
against HMRC's published rates before the first run (spec known_gaps)."""
from datetime import date
# key -> (default, what it changes). config_rows of module.yaml; business_timezone is in_code_not_rows' "today".
SETTING_DEFS = {
"proration": ("working_days", "working_days | calendar_days | 260ths for part periods; prl:settings:controls (slice A: working_days)"),
"day_rate_divisor": ("260", "annual salary / this = a day's pay for deductions and holiday in lieu"),
"bacs_lead_working_days": ("3", "working days before the pay date by which a run must be approved"),
"payouts_auto_approve_source": ("false", "payments approves payroll's payout batch without a second person; prl:settings:controls"),
"small_employer_relief": ("false", "recover 109% of statutory pay instead of 92% (2026/27); prl:settings:controls"),
"employment_allowance_claim": ("true", "claim the employment allowance on the EPS; prl:settings:controls"),
"deduct_overtaken_holiday": ("false", "a leaver's overtaken holiday is deducted (only if the contract allows)"),
"publish_on": ("pay_date", "pay_date | approval: when employees see their payslip"),
"off_cycle_allowed": ("true", "payroll managers may open off-cycle runs"),
"emergency_tax_code": ("1257L", "the code a starter gets on statements A and B and with no statement (HMRC's emergency code; UNVERIFIED figure, a dated row)"),
"business_timezone": ("Europe/London", "the zone 'today', the tax period and every deadline are counted in"),
}
TAX_YEAR_START = (4, 6) # 6 April (ITEPA 2003; PAYE regs) - the method, not a rate
# The pay elements every company starts with (company_code "*"; a company's own row of the same code wins).
# (code, name, kind, taxable, niable, pensionable, gl_purpose, maps_from)
# kind: earning (part of gross; may be negative: a deduction from pay) | statutory | deduction | employer
DEFAULT_ELEMENTS = (
("SALARY", "Salary", "earning", True, True, True, "gross_pay", "hr:base"),
("UNPAID_DEDUCTION", "Unpaid leave", "earning", True, True, True, "gross_pay", "tof:unpaid"),
("HOLIDAY_IN_LIEU", "Holiday pay in lieu", "earning", True, True, True, "gross_pay", "tof:balance"),
("TAX", "PAYE income tax", "deduction", False, False, False, "paye_ni_payable", ""),
("EE_NI", "Employee National Insurance", "deduction", False, False, False, "paye_ni_payable", ""),
("EE_PENSION", "Employee pension (net pay arrangement)", "deduction", False, False, False, "pension_payable", ""),
("ER_NI", "Employer National Insurance", "employer", False, False, False, "employer_ni", ""),
("ER_PENSION", "Employer pension", "employer", False, False, False, "employer_pension", ""),
)
# Added by migration 00003 (slice B item 1): statutory pay. A sick day's salary comes off at the day rate (SICK_DEDUCTION) and SSP goes
# on (SSP); a maternity day's salary comes off pro rata (MATERNITY_DEDUCTION) and SMP goes on. Statutory pay is taxable, NIable and
# pensionable (qualifying earnings include it). gl books all four as gross pay.
STATUTORY_ELEMENTS = (
("SICK_DEDUCTION", "Sickness (salary not paid)", "earning", True, True, True, "gross_pay", "tof:sick"),
("SSP", "Statutory sick pay", "statutory", True, True, True, "gross_pay", "tof:ssp"),
("MATERNITY_DEDUCTION", "Maternity leave (salary not paid)", "earning", True, True, True, "gross_pay", "tof:statutory_maternity"),
("SMP", "Statutory maternity pay", "statutory", True, True, True, "gross_pay", "tof:smp"),
)
SEED_FROM = date(2026, 4, 6)
HMRC_RATES = "HMRC Rates and thresholds for employers 2026 to 2027 (to confirm)"
TPR = "The Pensions Regulator: automatic enrolment earnings thresholds 2026 to 2027 (to confirm)"
NMW = "National Minimum Wage and National Living Wage rates from April 2026 (to confirm)"
# (rate_key, region, frequency, value, unit, source). region "ruk" = England and Northern Ireland bands (Wales: C codes,
# Scotland: S codes have their own rows, not seeded). frequency: annual | monthly | weekly | any.
DEFAULT_RATES = (
("tax_basic_rate", "ruk", "any", "20.0000", "percent", HMRC_RATES),
("tax_basic_limit", "ruk", "annual", "37700.0000", "pounds", HMRC_RATES),
("tax_higher_rate", "ruk", "any", "40.0000", "percent", HMRC_RATES),
("tax_higher_limit", "ruk", "annual", "125140.0000", "pounds", HMRC_RATES),
("tax_additional_rate", "ruk", "any", "45.0000", "percent", HMRC_RATES),
("tax_regulatory_limit", "uk", "any", "50.0000", "percent", "PAYE regulations 2003 reg. 23 (the overriding limit)"),
("ni_primary_threshold", "uk", "monthly", "1048.0000", "amount", HMRC_RATES),
("ni_primary_threshold", "uk", "weekly", "242.0000", "amount", HMRC_RATES),
("ni_upper_earnings_limit", "uk", "monthly", "4189.0000", "amount", HMRC_RATES),
("ni_upper_earnings_limit", "uk", "weekly", "967.0000", "amount", HMRC_RATES),
("ni_secondary_threshold", "uk", "monthly", "417.0000", "amount", HMRC_RATES),
("ni_secondary_threshold", "uk", "weekly", "96.0000", "amount", HMRC_RATES),
("ni_a_employee_main", "uk", "any", "8.0000", "percent", HMRC_RATES),
("ni_a_employee_additional", "uk", "any", "2.0000", "percent", HMRC_RATES),
("ni_a_employer", "uk", "any", "15.0000", "percent", HMRC_RATES),
("ae_qualifying_lower", "uk", "monthly", "520.0000", "amount", TPR),
("ae_qualifying_lower", "uk", "weekly", "120.0000", "amount", TPR),
("ae_qualifying_upper", "uk", "monthly", "4189.0000", "amount", TPR),
("ae_qualifying_upper", "uk", "weekly", "967.0000", "amount", TPR),
("ae_trigger", "uk", "annual", "10000.0000", "amount", TPR),
("nmw_21_and_over", "uk", "hourly", "12.7100", "amount", NMW),
("nmw_18_to_20", "uk", "hourly", "10.8500", "amount", NMW),
("nmw_under_18", "uk", "hourly", "8.0000", "amount", NMW),
)
# Slice B item 1 (migration 00003): statutory pay rows, each dated and flagged to_confirm. SPEC = module.yaml's own figure (PRL-07, PRL-09);
# UNVERIFIED = supplied by the builder 2026-10-26 from memory, to be checked against HMRC (AGENTS.md lists each).
SPEC_STAT = "payroll module.yaml PRL-07 / PRL-09 (to confirm against HMRC)"
UNVERIFIED = "UNVERIFIED: supplied by the builder 2026-10-26, check against HMRC (to confirm)"
GOV_UK = "gov.uk Rates and thresholds for employers 2026 to 2027, checked 2026-10-26"
# (rate_key, region, frequency, valid_from, value, unit, source)
STATUTORY_RATES = (
("ssp_weekly_rate", "uk", "weekly", SEED_FROM, "123.2500", "amount", SPEC_STAT),
("ssp_waiting_days", "uk", "any", date(2000, 1, 1), "3.0000", "count", UNVERIFIED + ": 3 waiting days before 6 April 2026"),
("ssp_waiting_days", "uk", "any", SEED_FROM, "0.0000", "count", SPEC_STAT + ": payable from the first qualifying day"),
("ssp_lel_test", "uk", "any", date(2000, 1, 1), "1.0000", "flag", UNVERIFIED + ": SSP needs average earnings at or above the LEL before 6 April 2026"),
("ssp_lel_test", "uk", "any", SEED_FROM, "0.0000", "flag", UNVERIFIED + ": no LEL test from 6 April 2026 (the 80% cap instead)"),
("ssp_earnings_cap_pct", "uk", "any", SEED_FROM, "80.0000", "percent", SPEC_STAT + ": the lower of the rate and 80% of average weekly earnings"),
("ni_lower_earnings_limit", "uk", "weekly", SEED_FROM, "129.0000", "amount", UNVERIFIED),
("smp_weekly_flat_rate", "uk", "weekly", SEED_FROM, "194.3200", "amount", SPEC_STAT),
("smp_higher_pct", "uk", "any", SEED_FROM, "90.0000", "percent", SPEC_STAT),
("smp_higher_weeks", "uk", "any", SEED_FROM, "6.0000", "count", SPEC_STAT),
("smp_total_weeks", "uk", "any", SEED_FROM, "39.0000", "count", UNVERIFIED + ": 39 weeks of SMP"),
("smp_recovery_pct", "uk", "any", SEED_FROM, "92.0000", "percent", SPEC_STAT),
("ssp_requires_piw", "uk", "any", date(2000, 1, 1), "1.0000", "flag", UNVERIFIED + ": SSP is paid only in a period of incapacity for work (4 or more days); PRL-07's example pays 3 days: set 0 to follow it"),
("ssp_max_weeks", "uk", "any", SEED_FROM, "28.0000", "count", UNVERIFIED + ": SSP is limited to 28 weeks in a linked period"),
("ssp_self_certification_days", "uk", "any", SEED_FROM, "7.0000", "count", UNVERIFIED + ": a fit note is needed from the 8th calendar day"),
("smp_small_employer_recovery_pct", "uk", "any", SEED_FROM, "109.0000", "percent", GOV_UK),
)
# Slice B item 2 (migration 00004): Class 1 and auto-enrolment bands for the fortnightly and four-weekly tables. The monthly and weekly rows
# are HMRC's published figures; these are the annual figure over the number of periods, to the nearest pound - UNVERIFIED (HMRC prints its own
# rounded tables, which can differ by a pound).
PERIOD_UNVERIFIED = "UNVERIFIED: the annual threshold over the periods, nearest pound, supplied by the builder 2026-10-26 (HMRC's printed table may differ by 1)"
# Migration 00008: Class 1 multi-week bands are the weekly figure times the weeks (HMRC's period tables); auto-enrolment uses the DWP pay
# reference period figures. Checked 2026-10-26.
PERIOD_CHECKED = "weekly band x weeks (HMRC period tables) / DWP auto-enrolment pay reference period figures, checked 2026-10-26"
PERIOD_RATES = tuple((k, "uk", f, SEED_FROM, v, "amount", PERIOD_CHECKED) for k, f, v in (
("ni_primary_threshold", "fortnightly", "484.0000"), ("ni_primary_threshold", "four_weekly", "968.0000"),
("ni_upper_earnings_limit", "fortnightly", "1934.0000"), ("ni_upper_earnings_limit", "four_weekly", "3868.0000"),
("ni_secondary_threshold", "fortnightly", "192.0000"), ("ni_secondary_threshold", "four_weekly", "384.0000"),
("ae_qualifying_lower", "fortnightly", "240.0000"), ("ae_qualifying_lower", "four_weekly", "480.0000"),
("ae_qualifying_upper", "fortnightly", "1934.0000"), ("ae_qualifying_upper", "four_weekly", "3867.0000")))