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
140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
from broker_sync.models import AccountType, ActivityType
|
|
from broker_sync.providers.parsers.schwab import parse_schwab_email
|
|
|
|
_SELL = """
|
|
<html><body>
|
|
<table>
|
|
<tr><td>Date</td><td class="dark-background-body" align="right">Jan 23, 2025</td></tr>
|
|
<tr><td>Action</td><td class="dark-background-body" align="right">Sold</td></tr>
|
|
<tr><td>Quantity</td><td class="dark-background-body" align="right">100.0</td></tr>
|
|
<tr><td>Ticker</td><td class="dark-background-body" align="right">META</td></tr>
|
|
<tr><td>Price</td><td class="dark-background-body" align="right">$612.34</td></tr>
|
|
</table>
|
|
</body></html>
|
|
"""
|
|
|
|
_BUY = """
|
|
<html><body><table>
|
|
<tr><td class="dark-background-body" align="right">2024-11-15</td></tr>
|
|
<tr><td class="dark-background-body" align="right">Bought</td></tr>
|
|
<tr><td class="dark-background-body" align="right">5.5</td></tr>
|
|
<tr><td class="dark-background-body" align="right">AAPL</td></tr>
|
|
<tr><td class="dark-background-body" align="right">$225.00</td></tr>
|
|
</table></body></html>
|
|
"""
|
|
|
|
_MALFORMED = "<html><body>no transaction here</body></html>"
|
|
|
|
_MISSING_CELLS = """
|
|
<html><body><table>
|
|
<tr><td class="dark-background-body" align="right">Jan 23, 2025</td></tr>
|
|
<tr><td class="dark-background-body" align="right">Sold</td></tr>
|
|
</table></body></html>
|
|
"""
|
|
|
|
|
|
def test_sell_email_parses_to_one_sell_activity() -> None:
|
|
acts = parse_schwab_email(_SELL)
|
|
assert len(acts) == 1
|
|
a = acts[0]
|
|
assert a.activity_type is ActivityType.SELL
|
|
assert a.symbol == "META"
|
|
assert a.quantity == Decimal("100.0")
|
|
assert a.unit_price == Decimal("612.34")
|
|
assert a.currency == "USD"
|
|
assert a.account_id == "schwab-workplace"
|
|
assert a.account_type is AccountType.GIA
|
|
assert a.date.date().isoformat() == "2025-01-23"
|
|
|
|
|
|
def test_buy_email_becomes_buy_activity() -> None:
|
|
acts = parse_schwab_email(_BUY)
|
|
assert len(acts) == 1
|
|
a = acts[0]
|
|
assert a.activity_type is ActivityType.BUY
|
|
assert a.symbol == "AAPL"
|
|
assert a.quantity == Decimal("5.5")
|
|
assert a.unit_price == Decimal("225.00")
|
|
|
|
|
|
def test_malformed_email_returns_empty_list() -> None:
|
|
# No matching td cells at all.
|
|
assert parse_schwab_email(_MALFORMED) == []
|
|
|
|
|
|
def test_missing_cells_returns_empty_list() -> None:
|
|
# Only 2 of the 5 required cells — parser must bail cleanly.
|
|
assert parse_schwab_email(_MISSING_CELLS) == []
|
|
|
|
|
|
def test_external_id_is_stable_across_reruns() -> None:
|
|
# Same email → same external_id (deterministic, not timestamp-based).
|
|
a1 = parse_schwab_email(_SELL)[0]
|
|
a2 = parse_schwab_email(_SELL)[0]
|
|
assert a1.external_id == a2.external_id
|
|
|
|
|
|
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)
|