[BTAPI-75] Swap the Wealthsimple activity fetch for wealthsimple-api calls, behind the cutover flag #92
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feature/BTAPI-75"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Ticket
BTAPI-75 Swap in-repo Wealthsimple client for wealthsimple-api service calls
Part of the "Wealthsimple API — shared service for Budget Tracker and Home Assistant" epic. This cuts budget-tracker-api's transaction-sync data-fetching over to the standalone
wealthsimple-apiservice. The upsert/dedupe engine inserver/sync/is untouched.Summary
Config.wealthsimpleApiEnabled(WS_API_ENABLED, default false, added by BTAPI-81) selects the implementation.fetchCreditCardActivities(since)is now a two-line dispatcher:false→ the pre-existing GraphQL path, renamedfetchCreditCardActivitiesDirectand otherwise unchanged (the rename was verified textually identical);true→fetchCreditCardActivitiesViaWealthsimpleApi, which reads the service'sGET /activity-feedthrough a new thin client (lib/wealthsimple/activity-feed-client) modelled on the existingtrigger-client.client/,queries.ts, account discovery andfetchCreditCardBalanceall stay — they are thefalsebranch and thews:activitiesCLI's balance read. Deleting them is BTAPI-78's job once the flag retires. Stating it explicitly so they aren't read as dead code.WS_API_URL/WS_API_KEYin thewealthsimple-api clientblock of.env.example;Config.assertWealthsimpleApiClientConfig(), which fails loud at boot only when the flag is on;parseServiceUrlnormalizing the URL; andsyncjoining the externalapi-sharednetwork.isPurchase,toActivity,logSkipped,toSpendAmount,purchaseNodeSchema) are typed toActivityNode— the loose, all-optional typeactivityNodeSchemainfers — which is also, structurally, everything aRawActivityItemfrom the client promises. So both branches share one set of rules about what counts as spend, and no parallel helper set was added; nothing needed widening.server/lib/wealthsimple/CLAUDE.mddocuments the branch point (and the interface it shares with BTAPI-76/77); rootCLAUDE.md,docs/DEV-ENVIRONMENT.mdand.env.examplerecord the networking and the listener acceptance.Two decisions worth a reviewer's attention
api-sharedwidens the sync trigger listener's reach — a conscious acceptance, and not free. The listener has no auth by design: its access control is the set of networkssyncis attached to, and it binds0.0.0.0within them. Addingapi-sharedtherefore lets every first-party container on that network ring the doorbell unauthenticated — spendingWS_MAX_SYNCS_PER_HOURheadroom and arming the retry ladder. Accepted on the basis that allapi-sharedmembers are first-party, and documented intrigger-listener/index.ts(the full reasoning, plus what would invalidate it), rootCLAUDE.md,docs/DEV-ENVIRONMENT.mdand.env.example. No auth was added. The original plan assumed this was free; that was wrong.WS_CREDIT_CARD_ACCOUNT_IDstays an optional post-fetch filter on the new branch, unlike the hard stop it is on the direct branch (which still throws rather than guessing between two open cards). Because items the feed leaves unattributed are kept — an absentaccountIdmeans "the feed did not say", not "a different account" — a feed spanning more than one account is aggregated into one budget as one owner. So a warning is logged whenever the feed names more than one account, with or without a pin, naming the count and the distinct ids; with a pin set it also states that the other accounts' items were filtered out. The count is always taken from the whole feed, since one taken after the pin filter could never reach two.Notable fixes from review
sincewindow is applied to the raw timestamp before mapping. Applying it aftertoActivity— which throws on a purchase missing a field the schema requires — let one malformed legacy purchase kill every run from then on, since this branch fetches the whole cached history unfiltered. An item whose timestamp is absent or unreadable is now dropped and named in a warning; an in-window item with a bad field still throws, exactly as on the direct branch.WealthsimpleApiClientError(mirroringSyncTriggerUnavailableError) is kept out of the engine's 401/429 refusal cooldown: a 401 fromwealthsimple-apimeansWS_API_KEY≠ that stack'sINTERNAL_API_KEY, not a refused credential — there is no session to refresh, and a cooldown would only delay the fix its message names. It still counts toward the consecutive-failure streak and the ordinary outage alert.sync/app.ts, outside the boot IIFE'stry, so a missing key exits non-zero rather than being caught and restart-looping underrestart: unless-stopped.lib/logger's pretty-print selection now excludes the test runner regardless ofNODE_ENVor TTY. Bun's runner only setsNODE_ENV=testwhen the environment doesn't already set it, and the dev container exportsNODE_ENV=development— so under a TTY the documented local test command had 5 pre-existing failures across 4 files, not just this ticket's own tests. Production output and normal dev-container use are unchanged.Tests
Whole suite green: 831 pass, 0 fail (832 tests, 58 files). The 1 skip is the pre-existing curl-impersonate integration test, which self-skips when the binary isn't present on a plain checkout.
bun --cwd=./server run verifypasses all five checks (type-check, lint, format, format:check, test), and the suite is green both in CI's non-TTY environment and under a pty withNODE_ENV=development.Coverage added: the new client (URL, bearer header, 200 parse, non-2xx with status, unreachable and real-timeout without status, malformed body), the new branch on the activities module (mapping, non-purchase exclusion,
wsIdderivation, sign/rounding, the client-side window, the optional account pin including unattributed items, single non-paginated request, malformed-purchase throw, 503 with no fallback), both dispatcher directions, the multi-account warning decision and its call site (asserted on the pino records the module actually emits), the engine-level internal-hop 401, and the logger's selection logic.Operator steps this branch deliberately does NOT perform
The
synccontainer is live production, so these were left to a human. Both are required before the flag is flipped:WS_API_KEYto the same value aswealthsimple-api'sINTERNAL_API_KEY. They live in two separate.envfiles and neither codebase can verify the match —assertWealthsimpleApiClientConfigcatches only an empty key. A mismatch surfaces as a 401 on the first flagged sync.ws:activitiesagainst a realwealthsimple-apiwith the flag on, confirming the returned activities match itsGET /activity-feedcache, filtered and mapped identically to the direct path.Flipping the flag also needs the container recreated (
docker compose up -d --force-recreate <service>), not merely restarted: compose resolvesenv_file: .envat creation, so a plaindocker restartsilently keeps the old value.Follow-up worth filing (WSAPI, not this ticket)
Reviewers noticed in
wealthsimple-api's own source thatisCardPurchasehas no dead-status gate whileisMoreAuthoritativeprefers non-pending — so a dead node sharing a derived id with its live authorized twin could win the cache. Traced through from this side the consequence is bounded and low-severity: the purchase then simply isn't in the feed, and the only local effect is that the vanished-pending check may raise its (accurate) alert for that purchase's previously-synced pending row, deleting nothing.wealthsimple-apiwas not touched by this branch; a WSAPI ticket could tighten it if you want it closed.🤖 Generated with Claude Code