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 = """
| Date | Jan 23, 2025 |
| Action | Sold |
| Quantity | 100.0 |
| Ticker | META |
| Price | $612.34 |
"""
_BUY = """
| 2024-11-15 |
| Bought |
| 5.5 |
| AAPL |
| $225.00 |
"""
_MALFORMED = "no transaction here"
_MISSING_CELLS = """
"""
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)