schwab: detect vest-confirmation emails + emit VestEvent
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions

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:
Viktor Barzin 2026-04-19 18:27:58 +00:00
parent 6f3bcea23e
commit 1d1e20b72b
3 changed files with 261 additions and 16 deletions

View file

@ -82,3 +82,59 @@ def test_price_with_commas_parses() -> None:
html = _SELL.replace("$612.34", "$1,612.34")
a = parse_schwab_email(html)[0]
assert a.unit_price == Decimal("1612.34")
# --- Vest-release parsing -------------------------------------------------
_VEST_RELEASE = """<html><body>
<h2>Release Confirmation</h2>
<p>
Release Date: 15 Mar 2026
Ticker: META
Total Shares Released: 100.0
Market Price: $612.34
Shares Withheld for Taxes: 45
Tax Withholding Amount: $27,555.30
</p>
</body></html>"""
def test_vest_release_returns_two_activities_and_vest_event() -> None:
"""Release Confirmation yields a BUY (full vest) + SELL (sell-to-cover) + VestEvent."""
from broker_sync.providers.parsers.schwab import parse_schwab_email_full
result = parse_schwab_email_full(_VEST_RELEASE)
assert result.vest_event is not None
assert result.vest_event.ticker == "META"
assert result.vest_event.shares_vested == Decimal("100.0")
assert result.vest_event.shares_sold_to_cover == Decimal("45")
assert result.vest_event.fmv_at_vest_usd == Decimal("612.34")
assert result.vest_event.tax_withheld_usd == Decimal("27555.30")
assert result.vest_event.vest_date.date().isoformat() == "2026-03-15"
assert result.vest_event.external_id.startswith("schwab:2026-03-15:META:VEST:")
assert len(result.activities) == 2
buy = result.activities[0]
assert buy.activity_type is ActivityType.BUY
assert buy.quantity == Decimal("100.0")
sell = result.activities[1]
assert sell.activity_type is ActivityType.SELL
assert sell.quantity == Decimal("45")
assert sell.unit_price == Decimal("612.34")
def test_vest_email_with_unparseable_body_returns_empty() -> None:
"""Subject says Release Confirmation but fields missing → empty result, no crash."""
from broker_sync.providers.parsers.schwab import parse_schwab_email_full
html = "<html><body>Release Confirmation — please contact support</body></html>"
result = parse_schwab_email_full(html)
assert result.vest_event is None
assert result.activities == []
def test_back_compat_parse_schwab_email_drops_vest_event() -> None:
"""The legacy list[Activity] shape remains stable for existing callers."""
acts = parse_schwab_email(_VEST_RELEASE)
assert len(acts) == 2
assert all(isinstance(a.activity_type, ActivityType) for a in acts)