PlanViewer's DisplayValuation.action JSON already gives us current fund units + unit price; we were parsing it and throwing it away, emitting only a single 'unrealised-gains-offset' DEPOSIT to make Wealthfolio's totals match the dashboard. That hack double-counted the gain as a cash contribution, hiding £35k of pension growth from every contribution/growth/ROI panel. New flow: - FidelityPlanViewerProvider exposes last_holdings + last_total_contribution after fetch() drains. - fidelity-ingest CLI converts to a ManualSnapshotPayload (cost basis allocated proportionally by current fund value share) and posts to WF /api/v1/snapshots/import. WF auto-creates unknown fund symbols with kind=INVESTMENT, quoteMode=MANUAL, quoteCcy=GBP. - The gains-offset emission is removed entirely. Historical offset rows already in WF are corrected at the dashboard layer by the dav_corrected view shipped in infra@2841347e. WealthfolioSink gains push_manual_snapshots() + ManualSnapshotPayload / SnapshotPosition wire types. 11 sink tests (3 new) + 9 fidelity provider tests (2 changed, 1 new) all green; mypy + ruff clean.
375 lines
12 KiB
Python
375 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import UTC, date, datetime
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from broker_sync.models import Account, AccountType, Activity, ActivityType
|
|
from broker_sync.sinks.wealthfolio import (
|
|
ImportValidationError,
|
|
ManualSnapshotPayload,
|
|
SnapshotPosition,
|
|
WealthfolioError,
|
|
WealthfolioSink,
|
|
WealthfolioUnauthorizedError,
|
|
)
|
|
|
|
|
|
def _buy() -> Activity:
|
|
return Activity(
|
|
external_id="t212:1",
|
|
account_id="t212-isa",
|
|
account_type=AccountType.ISA,
|
|
date=datetime(2026, 4, 1, 10, 30, tzinfo=UTC),
|
|
activity_type=ActivityType.BUY,
|
|
symbol="VUAG",
|
|
quantity=Decimal("1"),
|
|
unit_price=Decimal("100"),
|
|
currency="GBP",
|
|
)
|
|
|
|
|
|
def _client(handler: httpx.MockTransport, session_path: Path) -> WealthfolioSink:
|
|
return WealthfolioSink(
|
|
base_url="https://wf.test",
|
|
username="viktor",
|
|
password="hunter2",
|
|
session_path=session_path,
|
|
transport=handler,
|
|
)
|
|
|
|
|
|
def _login_ok(req: httpx.Request) -> httpx.Response:
|
|
assert req.url.path == "/api/v1/auth/login"
|
|
body = json.loads(req.content)
|
|
# Wealthfolio 3.2 LoginRequest is password-only.
|
|
assert body == {"password": "hunter2"}
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"authenticated": True,
|
|
"expiresIn": 604800
|
|
},
|
|
headers={"set-cookie": "wf_token=abc123; Path=/api; HttpOnly"},
|
|
)
|
|
|
|
|
|
# -- Login --
|
|
|
|
|
|
async def test_login_persists_cookie(tmp_path: Path) -> None:
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
return _login_ok(req)
|
|
|
|
sp = tmp_path / "session.json"
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
await sink.login()
|
|
|
|
data = json.loads(sp.read_text())
|
|
assert "wf_token" in data["cookies"]
|
|
assert data["cookies"]["wf_token"] == "abc123"
|
|
|
|
|
|
async def test_login_raises_on_401(tmp_path: Path) -> None:
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(401, json={"error": "bad creds"})
|
|
|
|
sink = _client(httpx.MockTransport(handler), tmp_path / "s.json")
|
|
with pytest.raises(WealthfolioUnauthorizedError):
|
|
await sink.login()
|
|
|
|
|
|
# -- Session reuse --
|
|
|
|
|
|
async def test_session_reused_from_disk(tmp_path: Path) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "cached"}}))
|
|
|
|
calls: list[str] = []
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
calls.append(req.url.path)
|
|
assert "wf_token=cached" in req.headers.get("cookie", "")
|
|
return httpx.Response(200, json=[])
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
await sink.list_accounts()
|
|
assert calls == ["/api/v1/accounts"]
|
|
|
|
|
|
async def test_401_triggers_single_reauth_and_retry(tmp_path: Path) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "stale"}}))
|
|
|
|
path_calls: list[tuple[str, str]] = []
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
path_calls.append((req.method, req.url.path))
|
|
if req.url.path == "/api/v1/accounts" and req.headers.get("cookie", "").endswith("stale"):
|
|
return httpx.Response(401)
|
|
if req.url.path == "/api/v1/auth/login":
|
|
return _login_ok(req)
|
|
# After login the stored cookie is fresh; second GET should succeed.
|
|
return httpx.Response(200, json=[{"id": "a1"}])
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
out = await sink.list_accounts()
|
|
assert out == [{"id": "a1"}]
|
|
assert path_calls == [
|
|
("GET", "/api/v1/accounts"),
|
|
("POST", "/api/v1/auth/login"),
|
|
("GET", "/api/v1/accounts"),
|
|
]
|
|
|
|
|
|
# -- Account ensure --
|
|
|
|
|
|
async def test_ensure_account_returns_existing_wf_id(tmp_path: Path) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
|
|
|
posts: list[dict[str, Any]] = []
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
if req.method == "GET" and req.url.path == "/api/v1/accounts":
|
|
# Wealthfolio stores its own UUID for id; providerAccountId is
|
|
# what we gave it.
|
|
return httpx.Response(
|
|
200,
|
|
json=[{
|
|
"id": "uuid-wf-123",
|
|
"name": "Trading212 ISA",
|
|
"provider": "trading212",
|
|
"providerAccountId": "t212-isa",
|
|
}],
|
|
)
|
|
if req.method == "POST":
|
|
posts.append(json.loads(req.content))
|
|
return httpx.Response(500)
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
acc = Account(
|
|
id="t212-isa",
|
|
name="Trading212 ISA",
|
|
account_type=AccountType.ISA,
|
|
currency="GBP",
|
|
provider="trading212",
|
|
)
|
|
wf_id = await sink.ensure_account(acc)
|
|
assert wf_id == "uuid-wf-123"
|
|
assert posts == [] # no create
|
|
|
|
|
|
async def test_ensure_account_creates_if_missing(tmp_path: Path) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
|
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
if req.method == "GET" and req.url.path == "/api/v1/accounts":
|
|
return httpx.Response(200, json=[])
|
|
if req.method == "POST" and req.url.path == "/api/v1/accounts":
|
|
posted.append(json.loads(req.content))
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "uuid-new-456",
|
|
"provider": "trading212",
|
|
"providerAccountId": "t212-isa",
|
|
},
|
|
)
|
|
return httpx.Response(500)
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
acc = Account(
|
|
id="t212-isa",
|
|
name="Trading212 ISA",
|
|
account_type=AccountType.ISA,
|
|
currency="GBP",
|
|
provider="trading212",
|
|
)
|
|
wf_id = await sink.ensure_account(acc)
|
|
assert wf_id == "uuid-new-456"
|
|
assert len(posted) == 1
|
|
# We no longer send our logical id — Wealthfolio would ignore it. We
|
|
# DO send our id as providerAccountId so Wealthfolio preserves it for
|
|
# future matching.
|
|
assert "id" not in posted[0]
|
|
assert posted[0]["providerAccountId"] == "t212-isa"
|
|
assert posted[0]["accountType"] == "ISA"
|
|
assert posted[0]["currency"] == "GBP"
|
|
assert posted[0]["isActive"] is True
|
|
assert posted[0]["isDefault"] is False
|
|
|
|
|
|
# -- Activity import --
|
|
|
|
|
|
async def test_import_dry_run_then_real(tmp_path: Path) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
|
|
|
calls: list[str] = []
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
calls.append(req.url.path)
|
|
if req.url.path == "/api/v1/activities/import/check":
|
|
# /import/check hydrates and returns a list of ActivityImport.
|
|
return httpx.Response(200,
|
|
json=[
|
|
{
|
|
"symbol": "VUAG",
|
|
"isValid": True,
|
|
"errors": None,
|
|
"assetId": "enriched-asset-uuid",
|
|
"exchangeMic": "XLON",
|
|
},
|
|
])
|
|
if req.url.path == "/api/v1/activities/import":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"activities": [
|
|
{
|
|
"id": "wf-1",
|
|
"external_id": "t212:1"
|
|
},
|
|
],
|
|
},
|
|
)
|
|
return httpx.Response(500)
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
out = await sink.import_activities([_buy()])
|
|
assert calls == [
|
|
"/api/v1/activities/import/check",
|
|
"/api/v1/activities/import",
|
|
]
|
|
assert out == [{"id": "wf-1", "external_id": "t212:1"}]
|
|
|
|
|
|
async def test_import_halts_on_validation_failure(tmp_path: Path) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
|
|
|
calls: list[str] = []
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
calls.append(req.url.path)
|
|
if req.url.path == "/api/v1/activities/import/check":
|
|
return httpx.Response(
|
|
400,
|
|
json={"errors": ["row 1: unknown symbol"]},
|
|
)
|
|
return httpx.Response(500)
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
with pytest.raises(ImportValidationError, match="unknown symbol"):
|
|
await sink.import_activities([_buy()])
|
|
assert calls == ["/api/v1/activities/import/check"] # real import never hit
|
|
|
|
|
|
# -- Manual snapshot import (Fidelity path) --
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_manual_snapshots_serialises_decimals_and_calls_endpoint(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
|
|
|
seen: dict[str, Any] = {}
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
if req.url.path == "/api/v1/snapshots/import":
|
|
seen["body"] = json.loads(req.content)
|
|
return httpx.Response(
|
|
200,
|
|
json={"snapshotsImported": 1, "snapshotsFailed": 0, "errors": []},
|
|
)
|
|
return httpx.Response(404)
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
snapshot = ManualSnapshotPayload(
|
|
date=date(2026, 5, 16),
|
|
currency="GBP",
|
|
positions=[
|
|
SnapshotPosition(
|
|
symbol="KDOA",
|
|
quantity=Decimal("4200.5"),
|
|
average_cost=Decimal("24.29"),
|
|
total_cost_basis=Decimal("102004.15"),
|
|
currency="GBP",
|
|
),
|
|
],
|
|
cash_balances={"GBP": Decimal(0)},
|
|
)
|
|
result = await sink.push_manual_snapshots(
|
|
account_id="a7d6208d-2bd6-4f85-bf54-b77984c78234",
|
|
snapshots=[snapshot],
|
|
)
|
|
assert result["snapshotsImported"] == 1
|
|
# Wire format: numeric fields are STRINGS (Decimal.__format__('f'))
|
|
body = seen["body"]
|
|
assert body["accountId"] == "a7d6208d-2bd6-4f85-bf54-b77984c78234"
|
|
pos = body["snapshots"][0]["positions"][0]
|
|
assert pos == {
|
|
"symbol": "KDOA",
|
|
"quantity": "4200.5",
|
|
"averageCost": "24.29",
|
|
"totalCostBasis": "102004.15",
|
|
"currency": "GBP",
|
|
}
|
|
assert body["snapshots"][0]["cashBalances"] == {"GBP": "0"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_manual_snapshots_raises_on_partial_failure(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"snapshotsImported": 0,
|
|
"snapshotsFailed": 1,
|
|
"errors": [{"row": 0, "msg": "bad symbol"}],
|
|
},
|
|
)
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
snapshot = ManualSnapshotPayload(
|
|
date=date(2026, 5, 16), currency="GBP",
|
|
positions=[], cash_balances={},
|
|
)
|
|
with pytest.raises(WealthfolioError, match="bad symbol"):
|
|
await sink.push_manual_snapshots(account_id="acct", snapshots=[snapshot])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_manual_snapshots_short_circuits_on_empty(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
sp = tmp_path / "s.json"
|
|
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
|
|
|
async def handler(req: httpx.Request) -> httpx.Response:
|
|
raise AssertionError(f"unexpected request: {req.method} {req.url.path}")
|
|
|
|
sink = _client(httpx.MockTransport(handler), sp)
|
|
result = await sink.push_manual_snapshots(account_id="acct", snapshots=[])
|
|
assert result["snapshotsImported"] == 0
|