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

@ -120,17 +120,30 @@ class WealthfolioSink:
assert isinstance(raw, list)
return raw
async def ensure_account(self, account: Account) -> None:
async def ensure_account(self, account: Account) -> str:
"""Idempotently create the account and return Wealthfolio's UUID for it.
Wealthfolio generates its own UUIDs on POST /accounts, ignoring any
`id` we supply. We identify accounts by (provider, providerAccountId)
which Wealthfolio DOES preserve verbatim. Our own Account.id is
used as the providerAccountId.
"""
existing = await self.list_accounts()
if any(a.get("id") == account.id for a in existing):
return
# Wealthfolio 3.2's NewAccount is camelCase with required booleans.
for a in existing:
if (
a.get("provider") == account.provider
and a.get("providerAccountId") == account.id
):
wf_id = a.get("id")
assert isinstance(wf_id, str)
return wf_id
# NewAccount is camelCase with required booleans.
# See apps/server/src/models.rs#NewAccount.
resp = await self._request(
"POST",
_ACCOUNTS_PATH,
json={
"id": account.id,
"name": account.name,
"accountType": str(account.account_type),
"currency": account.currency,
@ -143,6 +156,13 @@ class WealthfolioSink:
},
)
resp.raise_for_status()
created = resp.json()
wf_id = created.get("id")
if not isinstance(wf_id, str):
raise WealthfolioError(
f"POST /accounts returned no id: {created}"
)
return wf_id
# -- activity import --