Match Wealthfolio accounts by providerAccountId, remap accountId on import

Context: Wealthfolio 3.2 generates its own UUIDs on POST /accounts, ignoring any
`id` we supply. Our logical Account.id lives on as `providerAccountId`, which
WF preserves verbatim.

Live run created six duplicate accounts because ensure_account looked up by
our `id`, never found it, and POSTed a new account on every attempt. Deleted
the duplicates manually via DELETE /accounts/{id}.

This change:
- ensure_account now returns Wealthfolio's UUID; matches existing via
  (provider, providerAccountId)
- pipeline remaps activity.account_id to the WF UUID at submission time
  but keeps dedup keyed on our stable id (WF resets must not blow away
  the whole dedup history)
- test updates to the new account-shape + dedup key expectations

poetry run pytest -q    70 passed
poetry run mypy         clean
poetry run ruff check   clean
This commit is contained in:
Viktor Barzin 2026-04-17 20:44:32 +00:00
parent ba672a1633
commit 80ca009373
4 changed files with 91 additions and 29 deletions

View file

@ -127,7 +127,7 @@ async def test_401_triggers_single_reauth_and_retry(tmp_path: Path) -> None:
# -- Account ensure --
async def test_ensure_account_no_op_if_exists(tmp_path: Path) -> None:
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"}}))
@ -135,11 +135,15 @@ async def test_ensure_account_no_op_if_exists(tmp_path: Path) -> None:
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": "t212-isa",
"name": "Trading212 ISA"
"id": "uuid-wf-123",
"name": "Trading212 ISA",
"provider": "trading212",
"providerAccountId": "t212-isa",
}],
)
if req.method == "POST":
@ -154,7 +158,8 @@ async def test_ensure_account_no_op_if_exists(tmp_path: Path) -> None:
currency="GBP",
provider="trading212",
)
await sink.ensure_account(acc)
wf_id = await sink.ensure_account(acc)
assert wf_id == "uuid-wf-123"
assert posts == [] # no create
@ -169,7 +174,14 @@ async def test_ensure_account_creates_if_missing(tmp_path: Path) -> None:
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": "t212-isa"})
return httpx.Response(
200,
json={
"id": "uuid-new-456",
"provider": "trading212",
"providerAccountId": "t212-isa",
},
)
return httpx.Response(500)
sink = _client(httpx.MockTransport(handler), sp)
@ -180,9 +192,14 @@ async def test_ensure_account_creates_if_missing(tmp_path: Path) -> None:
currency="GBP",
provider="trading212",
)
await sink.ensure_account(acc)
wf_id = await sink.ensure_account(acc)
assert wf_id == "uuid-new-456"
assert len(posted) == 1
assert posted[0]["id"] == "t212-isa"
# 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

View file

@ -73,13 +73,18 @@ async def test_pipeline_skips_dedup_then_imports_new(tmp_path: Path) -> None:
async def handler(req: httpx.Request) -> httpx.Response:
if req.method == "GET" and req.url.path == "/api/v1/accounts":
return httpx.Response(200, json=[{"id": "fake-isa"}])
# Return account with Wealthfolio-assigned UUID + our providerAccountId.
return httpx.Response(
200,
json=[{
"id": "wf-uuid-fake-isa",
"provider": "fake",
"providerAccountId": "fake-isa",
}],
)
if req.url.path == "/api/v1/activities/import/check":
return httpx.Response(200, json={"ok": True})
if req.url.path == "/api/v1/activities/import":
# The httpx request body is multipart. We don't parse the multipart
# properly — we just scan for our dedup tags to confirm the
# pipeline pushed the rows it should have.
body = req.content.decode()
posted_batches.append(body)
# Echo back external_ids so dedup.record gets the WF activity id.
@ -135,7 +140,14 @@ async def test_pipeline_records_failure_when_import_rejects(tmp_path: Path) -> N
async def handler(req: httpx.Request) -> httpx.Response:
if req.method == "GET" and req.url.path == "/api/v1/accounts":
return httpx.Response(200, json=[{"id": "fake-isa"}])
return httpx.Response(
200,
json=[{
"id": "wf-uuid-fake-isa",
"provider": "fake",
"providerAccountId": "fake-isa",
}],
)
if req.url.path == "/api/v1/activities/import/check":
return httpx.Response(400, json={"errors": ["bad row"]})
return httpx.Response(500)