from __future__ import annotations import json from datetime import UTC, date, datetime from decimal import Decimal from pathlib import Path import pytest from broker_sync.models import Account, AccountType, ActivityType from broker_sync.providers.fidelity_planviewer import ( ACCOUNT_ID, FidelityCreds, FidelityPlanViewerProvider, FidelityProviderConfigError, fidelity_holdings_to_snapshot, gains_offset_delta_activity, ) from broker_sync.providers.parsers.fidelity import ( FidelityHolding, parse_transactions_html, parse_valuation_json, ) _FIXTURES = Path(__file__).parent.parent / "fixtures" / "fidelity" def test_accounts_exposes_single_workplace_pension_account() -> None: prov = FidelityPlanViewerProvider(FidelityCreds( storage_state_path="/tmp/x", plan_id="META", )) assert prov.accounts() == [ Account( id=ACCOUNT_ID, name="Fidelity UK Pension", account_type=AccountType.WORKPLACE_PENSION, currency="GBP", provider="fidelity-planviewer", ), ] async def test_fetch_raises_without_storage_state() -> None: prov = FidelityPlanViewerProvider(FidelityCreds( storage_state_path="/tmp/does-not-exist-xyzzy.json", plan_id="META", )) with pytest.raises(FidelityProviderConfigError, match="storage_state"): async for _ in prov.fetch(): pytest.fail("should have raised before yielding") # -- parser tests against real (captured) fixture -- def test_parse_transactions_real_fixture() -> None: html = (_FIXTURES / "transactions-full.html").read_text() txs = parse_transactions_html(html) # Scheme has ~48 months + a couple of single premiums + 1 rebate; # Bulk Switches must be filtered out (zero-amount rows). assert 40 <= len(txs) <= 100 # All dates are within the scheme's lifetime (2022-03 to today-ish). assert all(tx.date >= datetime(2022, 1, 1, tzinfo=UTC) for tx in txs) # Sum should match the header total on the page (£102,004.15 at # fixture time). Allow a £5 tolerance in case the page summary row # changes in future captures — the unit test primarily guards parsing # correctness, not drift in the fixture. total = sum((tx.amount for tx in txs), Decimal(0)) assert abs(total - Decimal("102004.15")) < Decimal("5") def test_parse_transactions_skips_bulk_switch() -> None: html = (_FIXTURES / "transactions-full.html").read_text() txs = parse_transactions_html(html) assert not any("bulk switch" in tx.tx_type.lower() for tx in txs) def test_parse_transactions_external_id_deterministic() -> None: html = (_FIXTURES / "transactions-full.html").read_text() a = parse_transactions_html(html) b = parse_transactions_html(html) assert [tx.external_id for tx in a] == [tx.external_id for tx in b] assert all(tx.external_id.startswith("fidelity:tx:") for tx in a) def test_parse_valuation_fixture() -> None: payload = json.loads((_FIXTURES / "valuation.json").read_text()) holdings = parse_valuation_json(payload) assert len(holdings) >= 1 h = holdings[0] assert h.fund_code == "KDOA" assert "Passive Global Equity" in h.fund_name assert h.currency == "GBP" assert h.units > 0 assert h.unit_price > 0 # Value ≈ units * price assert abs(h.total_value - h.units * h.unit_price) < Decimal("1") # Contribution-type breakdown must parse assert set(h.units_by_source.keys()) >= {"SASC", "ERXS"} def test_holdings_to_snapshot_real_fixture() -> None: html = (_FIXTURES / "transactions-full.html").read_text() valuation = json.loads((_FIXTURES / "valuation.json").read_text()) holdings = parse_valuation_json(valuation) total_contrib = sum((tx.amount for tx in parse_transactions_html(html)), Decimal(0)) snapshot = fidelity_holdings_to_snapshot( holdings=holdings, total_real_contribution=total_contrib, as_of=date(2026, 4, 18), ) assert snapshot is not None assert snapshot.date == date(2026, 4, 18) assert snapshot.currency == "GBP" # Cost basis sums to the cash contributions (allocated by fund value share) sum_cost = sum((p.total_cost_basis for p in snapshot.positions), Decimal(0)) assert abs(sum_cost - total_contrib) < Decimal("1") # Meta scheme had KDOA + LAFC + one other at fixture time; the # dominant fund must be KDOA. symbols = [p.symbol for p in snapshot.positions] assert "KDOA" in symbols kdoa = next(p for p in snapshot.positions if p.symbol == "KDOA") assert kdoa.quantity > 0 # Proportional cost-basis allocation: KDOA holds nearly the whole pot # so it should get the lion's share of cost kdoa_share = kdoa.total_cost_basis / sum_cost assert kdoa_share > Decimal("0.9") # cashBalances zero — pension contributions flow straight into funds assert snapshot.cash_balances == {"GBP": Decimal(0)} def test_holdings_to_snapshot_none_when_no_holdings() -> None: assert fidelity_holdings_to_snapshot( holdings=[], total_real_contribution=Decimal("100"), as_of=date(2026, 4, 18), ) is None def test_provider_caches_holdings_for_cli_snapshot_push() -> None: """The CLI reads `last_holdings` after fetch() drains to push the manual snapshot. This guards the contract that fetch() populates the attribute even when no Activity is yielded (e.g., backfill window cut-off).""" prov = FidelityPlanViewerProvider(FidelityCreds( storage_state_path="/tmp/x", plan_id="META", )) # Pre-fetch state: empty assert prov.last_holdings == [] assert prov.last_total_contribution == Decimal(0) # -- delta-shaped gains offset (the monthly accumulation mechanism) -- def _holdings_summing_to(total: Decimal) -> list[FidelityHolding]: return [FidelityHolding( fund_code="KDOA", fund_name="Test", units=Decimal("100"), unit_price=total / Decimal("100"), currency="GBP", total_value=total, units_by_source={}, )] def test_gains_delta_emits_deposit_when_gain_exceeds_prior_offset() -> None: # pot £145k, real contrib £102k → current gain £43k; prior offset £35k # → delta = +£8k activity = gains_offset_delta_activity( holdings=_holdings_summing_to(Decimal("145000")), total_real_contribution=Decimal("102000"), prior_offset_cumulative=Decimal("35000"), as_of=datetime(2026, 5, 17, tzinfo=UTC), ) assert activity is not None assert activity.activity_type == ActivityType.DEPOSIT assert activity.amount == Decimal("8000") assert activity.external_id == "fidelity:gains-delta:2026-05-17" assert "unrealised-gains-offset" in (activity.notes or "") def test_gains_delta_emits_withdrawal_on_market_drop() -> None: # pot dropped: current gain £30k, prior offset £35k → delta = -£5k activity = gains_offset_delta_activity( holdings=_holdings_summing_to(Decimal("132000")), total_real_contribution=Decimal("102000"), prior_offset_cumulative=Decimal("35000"), as_of=datetime(2026, 5, 17, tzinfo=UTC), ) assert activity is not None assert activity.activity_type == ActivityType.WITHDRAWAL assert activity.amount == Decimal("5000") def test_gains_delta_suppressed_below_minimum() -> None: # delta ~£0.20, below the £0.50 min — skip emission to avoid noise. activity = gains_offset_delta_activity( holdings=_holdings_summing_to(Decimal("137000.20")), total_real_contribution=Decimal("102000"), prior_offset_cumulative=Decimal("35000"), as_of=datetime(2026, 5, 17, tzinfo=UTC), ) assert activity is None def test_gains_delta_none_when_no_holdings() -> None: assert gains_offset_delta_activity( holdings=[], total_real_contribution=Decimal("0"), prior_offset_cumulative=Decimal("0"), as_of=datetime(2026, 5, 17, tzinfo=UTC), ) is None