[WSAPI-6] Scheduled sync jobs — activity feed and positions/cash (daily) #5

Merged
chris merged 11 commits from feature/WSAPI-6 into main 2026-09-11 09:36:20 -06:00
Owner

Ticket

WSAPI-6 Scheduled sync jobs — activity feed and positions/cash (daily)

Summary

  • The activity-feed sync now also runs on its own daily cron, in addition to staying triggerable on-demand via POST /sync/trigger.
  • A new daily cron drives a sync/balances job that populates the previously-unwritten lib/account-balances-cache, so /positions and /accounts serve real data instead of an always-empty cache.
  • Both jobs run on a shared lib/cron-job scheduler (cron expression + timezone from Config, with a random start-delay jitter on top of the fixed cron time) and write their outcomes to lib/sync-status.
  • Neither job retry-loops on failure — a failed run logs and records status; recovery follows the existing halt-and-recover session contract rather than automatic retries.
  • Ported the BTAPI-70 canonicalId dedupe-key derivation (lib/external-id) into the activity-feed sync, scoped specifically to card-purchase activity so it doesn't affect other activity types.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn

## Ticket [WSAPI-6](http://192.168.2.100:7123/home/browse/WSAPI-6/) Scheduled sync jobs — activity feed and positions/cash (daily) ## Summary - The activity-feed sync now also runs on its own daily cron, in addition to staying triggerable on-demand via `POST /sync/trigger`. - A new daily cron drives a `sync/balances` job that populates the previously-unwritten `lib/account-balances-cache`, so `/positions` and `/accounts` serve real data instead of an always-empty cache. - Both jobs run on a shared `lib/cron-job` scheduler (cron expression + timezone from `Config`, with a random start-delay jitter on top of the fixed cron time) and write their outcomes to `lib/sync-status`. - Neither job retry-loops on failure — a failed run logs and records status; recovery follows the existing halt-and-recover session contract rather than automatic retries. - Ported the BTAPI-70 canonicalId dedupe-key derivation (`lib/external-id`) into the activity-feed sync, scoped specifically to card-purchase activity so it doesn't affect other activity types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Generalizes budget-tracker-api's startWealthsimpleSyncJob/startDelayMs
pattern into scheduleDailyJob, parameterized by name/cronTime/timezone/onTick
so both the activity-feed and balances cron jobs can share one
implementation from day one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
lib/external-id/deriveExternalId strips the posting-token segment
settlement injects into a Wealthsimple canonicalId, so a pending and its
posted counterpart derive to the same dedupe key. Ported near-verbatim from
budget-tracker-api; lives at the repo root (not lib/wealthsimple/) since it's
a pure string transform with no Wealthsimple API call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
dedupeActivityItems collapses a pending/settled pair sharing one derived
external id (adapted from budget-tracker-api's dedupeByWsId/BTAPI-73),
applied inside performSync before the cache write so both the on-demand
trigger and the new cron get the same protection. startActivityFeedCronJob
wires this sync onto Config.activityFeedSyncCron via the shared scheduler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Mirrors lib/activity-feed-cache's set(): an upserting singleton write,
replacing the whole cached document since the balances sync always fetches
every open account's balances with no incremental condition. This is now
sync/balances's writer, so the header comment explaining why set() didn't
exist yet is rewritten to describe the actual writer instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
sync/balances/runBalancesSync fetches every account, excludes closed ones,
fetches their balances, and writes AccountBalancesCache + SyncStatus —
cron-only, no on-demand trigger for this ticket. Deliberately duplicates
sync/activity-feed's try/catch shape rather than extracting a shared
"guarded sync" helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Starts the activity-feed and balances cron jobs alongside the HTTP server,
and stops both ahead of the debounce reset/server close/DB close in the
shutdown sequence — cron jobs that can start new work are torn down before
what they'd write to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Root CLAUDE.md's intro and architecture tree now describe both nightly
cron jobs (sync/activity-feed, sync/balances) plus the new lib/cron-job and
lib/external-id modules, and drop the "read-only until WSAPI-6" note on
lib/account-balances-cache. lib/wealthsimple/CLAUDE.md's two WSAPI-6
forward-references are updated to describe the shipped feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Scopes dedupeActivityItems to CREDIT_CARD/PURCHASE items only (the only
subtype with the pending/posted duplicate-node problem), so every other
activity keys on its own already-unique canonicalId instead of spamming
external-id's unrecognized-shape warning and risking a false collision.
Makes ScheduledJob.stop() drain an in-flight onTick() before resolving,
and routes onTick through Promise.resolve().then(...) so a synchronous
throw can't escape uncaught. Tightens the balances no-retry-loop tests to
assert exact call counts, adds a failing-tick test for the balances cron,
adds a reversed-order dedupe collision test plus direct dedupeActivityItems
unit tests, drops two Config default tests that reintroduced the exact
ambient-env fragility the file already documents avoiding, refreshes
several stale "WSAPI-6 is future work" comments now that this ticket has
shipped, and fixes a CLAUDE.md misattribution of which module actually
calls positionsFor()/cashBalanceFor().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
[WSAPI-6] Fix follow-up review findings from the pre-PR remediation commit.
All checks were successful
server / check (pull_request) Successful in 25s
497b1271b4
- lib/cron-job: compare against the settled promise returned by .finally(), not the
  original tick, so the inFlight self-clear guard actually fires.
- lib/sync-status: describe activityFeed as the on-demand trigger plus its nightly
  cron, not just "the on-demand sync" — it's had both since this branch.
- routes/positions, routes/accounts .test.ts: correct the comment on the
  missing-syncedAt seed to describe the legacy/malformed-document case it actually
  exercises, matching lib/account-balances-cache/.test.ts's wording, rather than the
  fresh-deployment case (which has no cached document at all and is already covered
  by a separate test).
- lib/cron-job/.test.ts: add a case covering onTick throwing synchronously (rather
  than rejecting), the actual reason Promise.resolve().then(onTick) routing exists;
  also await stop() in afterEach instead of firing-and-forgetting it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
chris merged commit 97ace74bca into main 2026-09-11 09:36:20 -06:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chris/wealthsimple-api!5
No description provided.