- annotate monkeypatch fixture as pytest.MonkeyPatch
- import invest_engine parser module directly instead of via imap_mod.ie_parser
(mypy's strict "no implicit re-export" rule trips on the indirection)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The IMAP IE parser and the bearer-token IE API path generate different
external_ids for the same fill, so running both produces duplicate BUYs
in Wealthfolio. With IE now served by the API path (broker-sync invest-engine),
we keep the IMAP path live for Schwab and gate IE off via env var.
Setting BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS=invest-engine on the imap CronJob
stops new dupes; Schwab routing is unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Real Schwab trade-execution emails come from donotreply@mail.schwab.com,
not the root @schwab.com domain. The existing matcher's endswith("@schwab.com")
guard rejected these, silently skipping the May 2026 RSU vest's
same-day-sell confirmation. Extend the matcher to also accept any
*.schwab.com subdomain.
Added test_schwab_subdomain_sender_matches; full suite green.
test_imap.py:49 — one-line comment ran past the 100-char line limit
introduced in commit c830856. Split the "£20,000 cap" note onto its
own line above the call.
test_fidelity_planviewer.py:108 — mypy flagged `offset.amount > 0`
where amount is typed Decimal | None. Added an explicit `is not None`
guard; runtime behaviour unchanged (we already check offset is not
None two lines earlier).
$ poetry run ruff check . → All checks passed!
$ poetry run mypy broker_sync tests → Success: no issues found in 43 source files
$ poetry run pytest -q → 133 passed, 1 skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Context
Viktor's InvestEngine account has both an ISA and a GIA wrapper. Trade
confirmation emails (info@investengine.com) are identical between them —
subject "Here's how your portfolio looks now", body shows "Client name:
Viktor Barzin" with no portfolio/account type. That left the IMAP parser
hardcoded to route every IE BUY to the ISA (invest-engine-primary),
which produced a 2339-share over-count when 2023-24 GIA buys landed in
the ISA during the 2026-04-18 reconciliation.
Viktor's rule: from 6 April each tax year, BUYs fill ISA up to the
£20,000 cap, then overflow to GIA. This commit codifies that rule in a
standalone batch splitter and applies it at the ImapProvider boundary.
Also picks up a silent-drop bug surfaced during the same reconciliation:
WF's /import (unlike /import/check) rejects naive datetimes with
"Invalid date". The sink now coerces tzinfo=UTC defensively so every
provider gets the same guarantee.
## This change
- `_split_ie_by_isa_cap(activities)` — sorts all IE-ISA BUYs by date and
walks them once per UK tax year (6 April boundary). A BUY whose running
tax-year total BEFORE it is strictly below £20k stays on the ISA;
otherwise it flips to a new `invest-engine-gia` account_id. No
fractional splits — boundary activities go whole to whichever bucket
their pre-running-total dictates. Non-IE and non-BUY activities pass
through unchanged.
- `ImapProvider.accounts()` gains an `invest-engine-gia` Account so the
pipeline's `_ensure_accounts` can resolve both.
- `ImapProvider.fetch()` calls the splitter on the full batch before
applying the `since`/`before` date filter — batch-level sort
guarantees consistent routing regardless of the order IMAP returns
messages.
- `WealthfolioSink._activity_to_import_row` coerces naive datetimes to
UTC so the row passes WF /import validation.
## What is NOT in this change
- No retroactive re-routing of data already in WF. Historical
finance-mysql rows (all lumped to `invest-engine-primary` or
`invest-engine-gia` by the existing heuristic) keep their current
account assignment. If a past tax-year was routed "wrong" under the
new rule, that's corrected manually via the WF API, not here.
- No change to the Schwab or trading212 paths.
## Verification
### Automated
\`\`\`
$ poetry run pytest tests/providers/test_imap.py -v
tests/providers/test_imap.py::test_uk_tax_year_start_before_april_6_rolls_back PASSED
tests/providers/test_imap.py::test_single_tax_year_under_cap_stays_isa PASSED
tests/providers/test_imap.py::test_overflow_past_cap_flips_to_gia PASSED
tests/providers/test_imap.py::test_tax_year_boundary_resets_cap PASSED
tests/providers/test_imap.py::test_out_of_order_activities_sorted_before_cap_applied PASSED
tests/providers/test_imap.py::test_non_ie_activities_passed_through_unchanged PASSED
6 passed in 0.36s
$ poetry run pytest -q --ignore=tests/test_cli.py
116 passed, 1 skipped in 2.76s
$ poetry run ruff check broker_sync/providers/imap.py broker_sync/sinks/wealthfolio.py
All checks passed!
$ poetry run mypy broker_sync/providers/imap.py broker_sync/sinks/wealthfolio.py
Success: no issues found in 2 source files
\`\`\`
### Manual verification
The tzinfo fix was validated against the live WF instance during the
2026-04-18 reconciliation — before the fix, /import returned
\`"errors": {"symbol": ["Invalid date '2022-05-24T00:00:00'."]}\` for
every IMAP activity; after, the same payload imported cleanly.
The splitter was not exercised against live IMAP data because Viktor's
mailbox only has Apr 2022 → Feb 2024 emails, all inside finance.position's
existing coverage. Running IMAP ingest with \`since=2024-04-06\` yields
fetched=0. The unit tests cover the boundary arithmetic; a live run
will happen when newer emails are parsed (or when finance coverage is
re-scoped).
## Reproduce locally
1. \`poetry install\`
2. \`poetry run pytest tests/providers/test_imap.py\`
3. Expected: 6 passed, 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>