Meta variant-B payslips gross up Taxable Pay for RSU and compute PAYE on the grossed-up figure, so `income_tax` on the slip is the total PAYE (cash + RSU-attributed). Dashboards that stacked the raw figure made vest-month tax look ~2x higher than "cash tax paid". Introduce `cash_income_tax = income_tax * (gross_pay - pension_sacrifice) / taxable_pay` as a derived column alongside the raw figure. Dashboards can now stack cash vs RSU-attributed tax as separate segments. Also capture YTD column values of `RSU Tax Offset` and `RSU Excs Refund` from the Payments grid — needed for reconciliation against HMRC annual figures. P60 ingest: new parser under `parsers/p60.py` anchoring on statutory HMRC line labels (`Tax year to 5 April YYYY`, `Employer PAYE reference`, `In this employment` pay/tax row, NI letter bands). Processor routes documents carrying the `p60` Paperless tag to `_handle_p60` which writes to the new `payslip_ingest.p60_reference` table (one row per tax_year+employer). App lifespan resolves the tag id at startup; missing tag disables dispatch without breaking payslip ingest. Paperless tag creation + webhook config are manual follow-ups. Migrations: - 0004 — cash_income_tax + ytd_rsu_tax_offset + ytd_rsu_excs_refund on payslip, all nullable. - 0005 — p60_reference table with (tax_year, employer) unique + paperless_doc_id unique for idempotent re-uploads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.8 KiB
Python
74 lines
2.8 KiB
Python
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from payslip_ingest.parsers.p60 import P60ParserError, parse_p60
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
def _load(name: str) -> str:
|
|
return (FIXTURES / name).read_text(encoding="utf-8")
|
|
|
|
|
|
def test_parses_meta_uk_p60_2024_25() -> None:
|
|
result = parse_p60(_load("meta_uk_p60_2024_25.txt"))
|
|
|
|
assert result.tax_year == "2024/25"
|
|
assert result.employer == "Facebook UK Limited"
|
|
assert result.employer_paye_ref == "120/FA12345"
|
|
assert result.gross_pay == Decimal("232630.34")
|
|
assert result.income_tax == Decimal("95820.11")
|
|
assert result.national_insurance == Decimal("5172.40")
|
|
assert result.student_loan == Decimal("0.00")
|
|
assert result.tax_code == "1257L"
|
|
|
|
|
|
def test_parse_p60_raises_on_non_p60_text() -> None:
|
|
with pytest.raises(P60ParserError, match="does not look like a P60"):
|
|
parse_p60("Payslip for March 2025\nGross: £1000\n")
|
|
|
|
|
|
def test_parse_p60_raises_on_empty_text() -> None:
|
|
with pytest.raises(P60ParserError):
|
|
parse_p60("")
|
|
|
|
|
|
def test_parse_p60_raises_without_tax_year_anchor() -> None:
|
|
with pytest.raises(P60ParserError, match="Tax year"):
|
|
parse_p60("P60\nSome other content without the required anchor\n")
|
|
|
|
|
|
def test_parse_p60_handles_old_facebook_uk_ltd_spelling() -> None:
|
|
"""Pre-2022 P60s list the employer as `Facebook UK Ltd` (no `Limited`)."""
|
|
text = _load("meta_uk_p60_2024_25.txt").replace("Facebook UK Limited", "Facebook UK Ltd")
|
|
result = parse_p60(text)
|
|
assert result.employer == "Facebook UK Ltd"
|
|
|
|
|
|
def test_parse_p60_student_loan_missing_is_none() -> None:
|
|
"""P60s for years without student-loan deductions omit that line entirely."""
|
|
text = _load("meta_uk_p60_2024_25.txt")
|
|
# Strip the Student Loan line (simulating a year pre-loan).
|
|
stripped = "\n".join(line for line in text.splitlines() if "Student Loan" not in line)
|
|
result = parse_p60(stripped)
|
|
assert result.student_loan is None
|
|
|
|
|
|
def test_parse_p60_tax_code_missing_is_none() -> None:
|
|
"""Some historical P60s may not print a `Final tax code` line."""
|
|
text = _load("meta_uk_p60_2024_25.txt").replace("Final tax code", "XXX")
|
|
result = parse_p60(text)
|
|
assert result.tax_code is None
|
|
|
|
|
|
def test_parse_p60_sums_ni_across_letter_bands() -> None:
|
|
"""Employees who cross NI letter bands mid-year get one row per letter."""
|
|
text = _load("meta_uk_p60_2024_25.txt")
|
|
# Append a second NI letter row — same shape as the A row in the fixture.
|
|
extra = "C £6,396.00 £47,268.00 £1,000.00\n"
|
|
augmented = text + "\n" + extra
|
|
result = parse_p60(augmented)
|
|
# 5172.40 (letter A, in fixture) + 1000.00 (letter C, appended)
|
|
assert result.national_insurance == Decimal("6172.40")
|