Extends parse_schwab_email to handle Schwab's RSU Release Confirmation emails alongside the existing trade confirmations. Adds: - `VestEvent` dataclass in models.py — carries vest_date, ticker, shares_vested, shares_sold_to_cover, fmv_at_vest_usd, tax_withheld_usd. Written to payslip_ingest.rsu_vest_events by a postgres sink (pending a real email fixture + cross-service DB grant). - `parse_schwab_email_full()` — new entry point returning both `list[Activity]` and `VestEvent | None`. The legacy `parse_schwab_email()` shape is preserved for existing callers. - Vest-release dispatch heuristic: HTML body mentions "Release Confirmation" / "Award Vesting" / "RSU Release". On match, extract vest fields via label regexes; the full vest becomes a BUY Activity and the sell-to-cover slice becomes a SELL Activity at the same FMV (net zero cash on the day). Gross vest + sell-to-cover returned so Wealthfolio gets the full portfolio picture. - Tests: 3 new (vest roundtrip, unparseable-vest safety, legacy shape preserved); existing 6 unchanged. The regex heuristics will need tightening once a real email sample exists — the HTML structure observed in public Schwab emails may differ in material ways. For now, unmatched vest bodies return empty-result (no Activity, no VestEvent) rather than crashing the IMAP batch. Part of: code-860
128 lines
3.6 KiB
Python
128 lines
3.6 KiB
Python
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)
|