Schwab's workplace-RSU confirmation emails have 5 data td elements
with class='dark-background-body' align='right': date, direction, qty,
ticker, price-with-currency-sign. One email → one Activity.
- parse_schwab_email(raw_html) -> list[Activity] (1-item or empty)
- Empty on any parse failure (IMAP batch shouldn't crash on one bad mail)
- Deterministic external_id ('schwab📅ticker:type:qty') — stable
across re-pulls so dedup works
- Hardcoded to account 'schwab-workplace' / AccountType.GIA / USD
- 6 unit tests: SELL + BUY happy path, malformed, missing cells,
external-id stability, commas in price
Dropped from the original finance port:
- msg_timestamp-based external id (non-deterministic — would re-import
on every IMAP walk). Replaced with a hash-stable key.
- Currency.from_sign() currency hack. Schwab US is USD-only; we'll add
FX when that changes.
poetry run pytest -q → 109 passed, 1 skipped
poetry run mypy → clean (added types-python-dateutil)
poetry run ruff check → clean
84 lines
2.8 KiB
Python
84 lines
2.8 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")
|