2026-04-18 22:10:23 +00:00
|
|
|
from datetime import date
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
|
|
|
|
TOTALS_TOLERANCE = Decimal("0.02")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExtractedPayslip(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
pay_date: date
|
|
|
|
|
pay_period_start: date | None = None
|
|
|
|
|
pay_period_end: date | None = None
|
|
|
|
|
employer: str | None = None
|
|
|
|
|
currency: str = "GBP"
|
|
|
|
|
gross_pay: Decimal
|
|
|
|
|
income_tax: Decimal = Field(default=Decimal("0"))
|
|
|
|
|
national_insurance: Decimal = Field(default=Decimal("0"))
|
|
|
|
|
pension_employee: Decimal = Field(default=Decimal("0"))
|
|
|
|
|
pension_employer: Decimal = Field(default=Decimal("0"))
|
|
|
|
|
student_loan: Decimal = Field(default=Decimal("0"))
|
2026-04-18 23:37:25 +00:00
|
|
|
# RSU vest reported on the UK payslip is notional — the share grant is
|
|
|
|
|
# handled by Schwab which withholds US-side tax by selling shares. The
|
|
|
|
|
# UK payslip only lists it for HMRC reporting; no cash flows through
|
|
|
|
|
# UK payroll. Track it separately so dashboards can derive cash-only
|
|
|
|
|
# gross = gross_pay - rsu_vest.
|
|
|
|
|
rsu_vest: Decimal = Field(default=Decimal("0"))
|
|
|
|
|
# Corresponding offset deduction that nets the RSU out of cash pay on the
|
|
|
|
|
# UK slip (labels vary: "Shares Retained", "Stock Tax Withholding",
|
|
|
|
|
# "RSU Offset", "Notional Pay Offset"). Same as rsu_vest in magnitude.
|
|
|
|
|
rsu_offset: Decimal = Field(default=Decimal("0"))
|
2026-04-18 22:10:23 +00:00
|
|
|
other_deductions: dict[str, Decimal] = Field(default_factory=dict)
|
|
|
|
|
net_pay: Decimal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WebhookPayload(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
document_id: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_totals(p: ExtractedPayslip) -> bool:
|
|
|
|
|
"""Check that gross - deductions ≈ net within a 2p tolerance.
|
|
|
|
|
|
2026-04-18 23:37:25 +00:00
|
|
|
- Employer pension is excluded — it never leaves the employer's books.
|
|
|
|
|
- `rsu_offset` is included as a deduction: it's the line that nets
|
|
|
|
|
the RSU notional back out of cash pay on UK payslips with stock comp.
|
|
|
|
|
The gross + rsu_vest inflation is offset by rsu_offset of equal size.
|
2026-04-18 22:10:23 +00:00
|
|
|
"""
|
|
|
|
|
deductions = (p.income_tax + p.national_insurance + p.pension_employee + p.student_loan +
|
2026-04-18 23:37:25 +00:00
|
|
|
p.rsu_offset +
|
2026-04-18 22:10:23 +00:00
|
|
|
sum(p.other_deductions.values(), start=Decimal("0")))
|
|
|
|
|
diff = abs(p.gross_pay - deductions - p.net_pay)
|
|
|
|
|
return diff < TOTALS_TOLERANCE
|