from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal from enum import StrEnum class AccountType(StrEnum): ISA = "ISA" SIPP = "SIPP" GIA = "GIA" LISA = "LISA" JISA = "JISA" WORKPLACE_PENSION = "WORKPLACE_PENSION" class ActivityType(StrEnum): BUY = "BUY" SELL = "SELL" DIVIDEND = "DIVIDEND" INTEREST = "INTEREST" DEPOSIT = "DEPOSIT" WITHDRAWAL = "WITHDRAWAL" TRANSFER_IN = "TRANSFER_IN" TRANSFER_OUT = "TRANSFER_OUT" CONVERSION_IN = "CONVERSION_IN" CONVERSION_OUT = "CONVERSION_OUT" FEE = "FEE" TAX = "TAX" class FxRateSource(StrEnum): ECB_LIVE = "ECB_LIVE" HMRC_MONTHLY = "HMRC_MONTHLY" _QTY_PRICE_TYPES = {ActivityType.BUY, ActivityType.SELL} _AMOUNT_TYPES = { ActivityType.DIVIDEND, ActivityType.INTEREST, ActivityType.DEPOSIT, ActivityType.WITHDRAWAL, ActivityType.FEE, ActivityType.TAX, ActivityType.TRANSFER_IN, ActivityType.TRANSFER_OUT, ActivityType.CONVERSION_IN, ActivityType.CONVERSION_OUT, } @dataclass class Account: id: str name: str account_type: AccountType currency: str provider: str @dataclass class Activity: external_id: str account_id: str account_type: AccountType date: datetime activity_type: ActivityType currency: str symbol: str | None = None quantity: Decimal | None = None unit_price: Decimal | None = None amount: Decimal | None = None fee: Decimal = field(default_factory=lambda: Decimal("0")) amount_gbp: Decimal | None = None fx_rate_gbp: Decimal | None = None fx_rate_source: FxRateSource | None = None notes: str | None = None def __post_init__(self) -> None: if self.activity_type in _QTY_PRICE_TYPES and (self.quantity is None or self.unit_price is None): raise ValueError(f"{self.activity_type} requires quantity and unit_price") if self.activity_type in _AMOUNT_TYPES and self.amount is None: raise ValueError(f"{self.activity_type} requires amount") def to_wealthfolio_csv_row(self) -> dict[str, str]: return { "date": self.date.isoformat(), "activity_type": str(self.activity_type), "symbol": self.symbol or "", "quantity": _fmt(self.quantity), "unit_price": _fmt(self.unit_price), "currency": self.currency, "fee": _fmt(self.fee), "amount": _fmt(self.amount), "notes": self.notes or "", } def _fmt(v: Decimal | None) -> str: if v is None: return "" return format(v, "f") @dataclass class VestEvent: """Schwab RSU vest event — written to payslip_ingest.rsu_vest_events. Carries both the gross vest (shares x FMV) and the sell-to-cover portion (shares withheld for tax x FMV). Sibling Activity records (one BUY for the full vest, one SELL for the sold-to-cover slice) are produced separately for Wealthfolio. USD-only at parse time; FX conversion happens at the postgres sink via the ECB daily rate so the DB row carries both the raw USD figures and the GBP-translated values for dashboard joins. """ external_id: str # schwab:{date}:{ticker}:VEST:{shares_vested} vest_date: datetime ticker: str shares_vested: Decimal shares_sold_to_cover: Decimal | None fmv_at_vest_usd: Decimal tax_withheld_usd: Decimal | None source: str = "schwab_email" raw: dict[str, str] = field(default_factory=dict)