diff --git a/alembic/versions/0002_add_rsu_columns.py b/alembic/versions/0002_add_rsu_columns.py new file mode 100644 index 0000000..43fb7bb --- /dev/null +++ b/alembic/versions/0002_add_rsu_columns.py @@ -0,0 +1,33 @@ +"""Add rsu_vest and rsu_offset columns. + +UK payslips for Meta report RSU grants as notional pay (gross inflation) +and offset them via a same-magnitude deduction. The cash gross Viktor +cares about for dashboarding is gross_pay - rsu_vest. Track both for +reporting + exactness; cash and tax-rate charts compute from them. +""" +import sqlalchemy as sa + +from alembic import op + +revision = "0002_add_rsu_columns" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "payslip", + sa.Column("rsu_vest", sa.Numeric(12, 2), nullable=False, server_default=sa.text("0")), + schema="payslip_ingest", + ) + op.add_column( + "payslip", + sa.Column("rsu_offset", sa.Numeric(12, 2), nullable=False, server_default=sa.text("0")), + schema="payslip_ingest", + ) + + +def downgrade() -> None: + op.drop_column("payslip", "rsu_offset", schema="payslip_ingest") + op.drop_column("payslip", "rsu_vest", schema="payslip_ingest") diff --git a/payslip_ingest/db.py b/payslip_ingest/db.py index d8543d6..3c86e61 100644 --- a/payslip_ingest/db.py +++ b/payslip_ingest/db.py @@ -46,6 +46,12 @@ class Payslip(Base): student_loan: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("0")) + rsu_vest: Mapped[Decimal] = mapped_column(Numeric(12, 2), + nullable=False, + server_default=text("0")) + rsu_offset: Mapped[Decimal] = mapped_column(Numeric(12, 2), + nullable=False, + server_default=text("0")) other_deductions: Mapped[dict[str, Any] | None] = mapped_column(JSON_TYPE, nullable=True) net_pay: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) tax_year: Mapped[str] = mapped_column(String, nullable=False) diff --git a/payslip_ingest/extractor.py b/payslip_ingest/extractor.py index 2275a49..a38ae14 100644 --- a/payslip_ingest/extractor.py +++ b/payslip_ingest/extractor.py @@ -30,6 +30,8 @@ EXTRACTION_PROMPT = ( ' "pension_employee": number,\n' ' "pension_employer": number,\n' ' "student_loan": number,\n' + ' "rsu_vest": number,\n' + ' "rsu_offset": number,\n' ' "other_deductions": {"label": number, ...},\n' ' "net_pay": number\n' "}\n" @@ -37,8 +39,19 @@ EXTRACTION_PROMPT = ( "Rules:\n" "- Report numbers as the payslip shows them; do not compute sums.\n" "- Unknown numeric fields → 0, not null.\n" + "- `rsu_vest`: any notional/reporting entry in the EARNINGS block labelled " + '"RSU Vest", "Restricted Stock Units", "Stock Value", "Notional Pay", ' + '"Share Award", "Equity Vest", "GSU Vest". For Meta UK payslips this is ' + "the grossed-up RSU value reported for HMRC only; Schwab handles actual " + "tax withholding via share sale.\n" + "- `rsu_offset`: the matching DEDUCTION that nets the RSU out of cash pay — " + 'labels vary: "Shares Retained", "Stock Tax Withholding", "RSU Offset", ' + '"Notional Pay Offset", "Shares Withheld". For Meta this is typically equal ' + "in magnitude to rsu_vest so cash net is unaffected.\n" + "- If either rsu_vest or rsu_offset is present, BOTH should be populated; " + "do NOT put them in `other_deductions`.\n" "- `other_deductions` covers cycle-to-work, share-save, benefits-in-kind, court orders, " - "anything not in the main fields.\n" + "anything not in the main fields (and NOT RSU — those have dedicated fields).\n" "- All money in GBP unless the payslip is denominated otherwise.\n" '- If a field\'s value is ambiguous, pick the value from the "this period" column, not YTD.') diff --git a/payslip_ingest/processor.py b/payslip_ingest/processor.py index 9eb8c8d..c99a024 100644 --- a/payslip_ingest/processor.py +++ b/payslip_ingest/processor.py @@ -107,6 +107,8 @@ async def _insert_payslip( pension_employee=extracted.pension_employee, pension_employer=extracted.pension_employer, student_loan=extracted.student_loan, + rsu_vest=extracted.rsu_vest, + rsu_offset=extracted.rsu_offset, other_deductions=_decimals_to_float(extracted.other_deductions), net_pay=extracted.net_pay, tax_year=derive_tax_year(extracted.pay_date), diff --git a/payslip_ingest/schema.py b/payslip_ingest/schema.py index ff557c1..f1ba3fd 100644 --- a/payslip_ingest/schema.py +++ b/payslip_ingest/schema.py @@ -20,6 +20,16 @@ class ExtractedPayslip(BaseModel): pension_employee: Decimal = Field(default=Decimal("0")) pension_employer: Decimal = Field(default=Decimal("0")) student_loan: Decimal = Field(default=Decimal("0")) + # 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")) other_deductions: dict[str, Decimal] = Field(default_factory=dict) net_pay: Decimal @@ -33,10 +43,13 @@ class WebhookPayload(BaseModel): def validate_totals(p: ExtractedPayslip) -> bool: """Check that gross - deductions ≈ net within a 2p tolerance. - Employer pension is excluded — it never leaves the employer's books and - doesn't affect take-home pay arithmetic. + - 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. """ deductions = (p.income_tax + p.national_insurance + p.pension_employee + p.student_loan + + p.rsu_offset + sum(p.other_deductions.values(), start=Decimal("0"))) diff = abs(p.gross_pay - deductions - p.net_pay) return diff < TOTALS_TOLERANCE diff --git a/tests/test_processor.py b/tests/test_processor.py index 6348a0e..93e6b72 100644 --- a/tests/test_processor.py +++ b/tests/test_processor.py @@ -22,6 +22,8 @@ def _sample_extraction() -> ExtractedPayslip: pension_employee=Decimal("250.00"), pension_employer=Decimal("150.00"), student_loan=Decimal("100.00"), + rsu_vest=Decimal("0.00"), + rsu_offset=Decimal("0.00"), other_deductions={"cycle_to_work": Decimal("50.00")}, net_pay=Decimal("3450.00"), )