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 = """
DateJan 23, 2025
ActionSold
Quantity100.0
TickerMETA
Price$612.34
""" _BUY = """
2024-11-15
Bought
5.5
AAPL
$225.00
""" _MALFORMED = "no transaction here" _MISSING_CELLS = """
Jan 23, 2025
Sold
""" 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 = """

Release Confirmation

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

""" 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 = "Release Confirmation — please contact support" 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)