schwab: detect vest-confirmation emails + emit VestEvent
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
This commit is contained in:
parent
6f3bcea23e
commit
1d1e20b72b
3 changed files with 261 additions and 16 deletions
|
|
@ -102,3 +102,27 @@ 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue