fire-planner/fire_planner/ingest/payslip.py

78 lines
2.6 KiB
Python
Raw Normal View History

2026-05-07 17:06:19 +00:00
"""Read the deployed payslip-ingest schema for income + RSU vest cadence.
Read-only: we never write to `payslip_ingest.*`. The DB role
`pg-fire-planner` only needs SELECT on payslip_ingest.payslip and
payslip_ingest.rsu_vest_events.
Outputs feed scenario calibration:
- savings_per_year_gbp: median monthly net_pay × 12 less the £100k
baseline spend (the planner allocates the surplus to portfolio).
- annual_rsu_gross_gbp: median annual RSU vest value, used to validate
the savings rate against expected gross compensation.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass(frozen=True)
class IncomeSummary:
median_monthly_net_pay_gbp: Decimal
median_annual_rsu_gbp: Decimal
earliest_date: date | None
latest_date: date | None
payslip_count: int
rsu_count: int
async def read_income_summary(session: AsyncSession, months: int = 24) -> IncomeSummary:
"""Aggregate the most-recent `months` of payslips + RSU vests."""
payslip_rows = (await session.execute(
text(
"""
SELECT pay_date, net_pay
FROM payslip_ingest.payslip
WHERE pay_date >= CURRENT_DATE - (:months || ' months')::interval
ORDER BY pay_date DESC
""", ),
{"months": months},
)).all()
rsu_rows = (await session.execute(
text(
"""
SELECT vest_date, gross_value_gbp
FROM payslip_ingest.rsu_vest_events
WHERE vest_date >= CURRENT_DATE - (:months || ' months')::interval
ORDER BY vest_date DESC
""", ),
{"months": months},
)).all()
monthly_nets = sorted(Decimal(str(r[1] or 0)) for r in payslip_rows)
median_monthly_net = (monthly_nets[len(monthly_nets) // 2] if monthly_nets else Decimal("0"))
rsu_total_gbp = sum((Decimal(str(r[1] or 0)) for r in rsu_rows), start=Decimal("0"))
months_span = max(1, months)
annual_rsu = rsu_total_gbp * 12 / months_span
pay_dates = [r[0] for r in payslip_rows]
rsu_dates = [r[0] for r in rsu_rows]
all_dates = pay_dates + rsu_dates
earliest = min(all_dates) if all_dates else None
latest = max(all_dates) if all_dates else None
return IncomeSummary(
median_monthly_net_pay_gbp=median_monthly_net,
median_annual_rsu_gbp=annual_rsu,
earliest_date=earliest,
latest_date=latest,
payslip_count=len(payslip_rows),
rsu_count=len(rsu_rows),
)