Context: IE has not (yet) sent CSV-attached statements in production,
but the upstream parser had _extract_positions_csv as a third fallback
for exactly this case. Keeping the fallback preserves behaviour-parity
with the legacy parser and makes future statement support one fixture
away — the shape is documented by column set, not scraped live.
Unlike the upstream which split the body on whitespace and broke on any
embedded commas in names, this port walks real MIME attachments using
Python's csv.DictReader. A part qualifies as CSV if:
- its Content-Type is text/csv / application/csv / application/vnd.ms-excel, OR
- its filename ends in .csv (defence against IE mis-labelling the part)
Rows missing required columns or containing unparseable numbers/dates
are skipped silently — consistent with the "partial match" contract:
a half-corrupt CSV yields whatever rows were intact. Required columns:
ticker, unit_price, quantity, date (YYYY-MM-DD), currency. Non-GBP
rows are filtered because the IE ISA is strictly sterling — flagging
this assumption in the review notes.
This change:
- Adds `_parse_csv_attachment(raw_email)` as the third strategy after
text/plain and text/html; it re-parses the raw email bytes so we can
inspect Content-Type/filename on each part.
- Flags symbols/currencies, filters non-GBP, and runs each row through
the shared `_build_activity` so external_id formation matches every
other strategy (dedup stays consistent across strategies).
- Fixture `csv_attachment.eml` has three rows (VUAG, SWDA, VUSA) in a
`text/csv` part with a `.csv` filename — covers both detection paths.
Test plan:
poetry run pytest tests/providers/parsers/ -q → 6 passed in 0.15s
poetry run mypy broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py → clean
poetry run ruff check broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py → All checks passed!
poetry run yapf --diff → clean (no diff)
Manual verification: load csv_attachment.eml, call parse_invest_engine_email,
assert 3 activities each with symbol in {VUAG,SWDA,VUSA}, currency=GBP,
notes containing "csv".
91 lines
3.1 KiB
Python
91 lines
3.1 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
from broker_sync.models import AccountType, ActivityType
|
|
from broker_sync.providers.parsers.invest_engine import parse_invest_engine_email
|
|
|
|
_FIXTURES = Path(__file__).parent.parent.parent / "fixtures" / "invest_engine"
|
|
|
|
|
|
def _load(name: str) -> bytes:
|
|
return (_FIXTURES / name).read_bytes()
|
|
|
|
|
|
# -- RFC 2822 body (v2-style, single BUY) --
|
|
|
|
|
|
def test_rfc2822_single_buy_parses_to_one_activity() -> None:
|
|
activities = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))
|
|
assert len(activities) == 1
|
|
a = activities[0]
|
|
assert a.activity_type is ActivityType.BUY
|
|
assert a.symbol == "VUAG"
|
|
assert a.quantity == Decimal("59.539562")
|
|
assert a.unit_price == Decimal("60.46")
|
|
assert a.currency == "GBP"
|
|
assert a.date == datetime(2023, 1, 17)
|
|
assert a.account_id == "invest-engine-primary"
|
|
assert a.account_type is AccountType.ISA
|
|
|
|
|
|
def test_rfc2822_external_id_is_deterministic() -> None:
|
|
a1 = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
|
|
a2 = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
|
|
assert a1.external_id == a2.external_id
|
|
assert a1.external_id.startswith("invest-engine:")
|
|
|
|
|
|
def test_rfc2822_notes_record_parse_strategy() -> None:
|
|
a = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
|
|
assert a.notes is not None
|
|
assert "rfc2822" in a.notes
|
|
|
|
|
|
# -- HTML table body (multipart/alternative, two orders) --
|
|
|
|
|
|
def test_html_body_parses_both_orders() -> None:
|
|
activities = parse_invest_engine_email(_load("html_two_orders.eml"))
|
|
assert len(activities) == 2
|
|
a, b = activities
|
|
assert a.symbol == "VUAG"
|
|
assert a.quantity == Decimal("10.5")
|
|
assert a.unit_price == Decimal("62.10")
|
|
assert a.date == datetime(2026, 4, 1)
|
|
assert a.account_id == "invest-engine-primary"
|
|
assert a.account_type is AccountType.ISA
|
|
assert a.activity_type is ActivityType.BUY
|
|
assert b.symbol == "SWDA"
|
|
assert b.quantity == Decimal("2.25")
|
|
assert b.unit_price == Decimal("85.40")
|
|
assert b.date == datetime(2026, 4, 1)
|
|
|
|
|
|
def test_html_notes_record_html_strategy() -> None:
|
|
a = parse_invest_engine_email(_load("html_two_orders.eml"))[0]
|
|
assert a.notes is not None
|
|
assert "html" in a.notes
|
|
|
|
|
|
# -- CSV attachment body --
|
|
|
|
|
|
def test_csv_attachment_parses_all_rows() -> None:
|
|
activities = parse_invest_engine_email(_load("csv_attachment.eml"))
|
|
assert len(activities) == 3
|
|
by_symbol = {a.symbol: a for a in activities}
|
|
assert by_symbol["VUAG"].quantity == Decimal("12.5")
|
|
assert by_symbol["VUAG"].unit_price == Decimal("63.21")
|
|
assert by_symbol["VUAG"].date == datetime(2025, 4, 2)
|
|
assert by_symbol["SWDA"].quantity == Decimal("4.75")
|
|
assert by_symbol["VUSA"].date == datetime(2025, 4, 4)
|
|
for a in activities:
|
|
assert a.activity_type is ActivityType.BUY
|
|
assert a.currency == "GBP"
|
|
assert a.account_id == "invest-engine-primary"
|
|
assert a.account_type is AccountType.ISA
|
|
assert a.notes is not None
|
|
assert "csv" in a.notes
|