Skip to content

Technical design — MOD-077 Account dashboard & insight feed

Module: MOD-077 System: SD08 Customer App & Back Office Platform Repo: bank-app FR scope: FR-369, FR-370, FR-371, FR-372 NFR scope: NFR-004, NFR-016, NFR-017 Policies satisfied: CON-005 (AUTO) Author: SD08 build agent (Claude) Date: 2026-05-19

Objective

Customer home screen — first paint on every app open. Aggregates real-time balances (projected from MOD-003), top-3 spending categories (MTD vs prior-month, computed from MOD-070's app.transaction_view), 14-day projected cash flow (recurring-transaction detection on the same projection), low-balance alert, and insight cards. TTI ≤2 s on 4G; refresh within 60 s of qualifying events.

MOD-077 is a pure read-aggregator + insight computer. It owns no authoritative state — all balance data traces back to SD01 MOD-003 events.

Internal architecture

                          bank-core bus
                          bank.core.balance_updated
                          mod-077-balance-updated-rule
                          consume-balance-updated Lambda
                            ↓ (idempotent on last_event_id)
                          INSERT app.account_summary
                          ON CONFLICT (account_id) DO UPDATE

Customer HTTP API (Cognito JWT authoriser):
  GET   /dashboard
    Parallel reads:
      listSummariesForParty()     →  app.account_summary
      fetchRecentTransactions()    →  app.transaction_view (MOD-070)
      computeForecast()            →  recurring-detect.ts (pure)
    Compute (pure):
      topThreeCategories()        →  FR-370 result
      evaluateLowBalance()         →  CAP-021 LOW_BALANCE card
    Refresh computed insight cards in app.dashboard_insight_card
    Read active card feed
    Response shape:
      accounts, net_worth, spending, cash_flow,
      insight_cards, health_score (stub)

  POST  /dashboard/cards/{card_id}/dismiss
    UPDATE app.dashboard_insight_card SET dismissed_at = now()

Three Lambdas, one HTTP API, one cross-bus EventBridge consumer rule, one CloudWatch log group, two Postgres projection tables.

Key design decisions

Decision: projection tables, not live API calls (m-1)

app.account_summary is the read-side projection of SD01 balances, fed by bank.core.balance_updated. The dashboard READs the projection — no live API call to MOD-003 in the hot path. This is required for NFR-017 (TTI ≤2 s) and FR-372 (optimistic UI with cached-first).

last_event_id carries the originating event_id. Consumer compares against the row's current last_event_id in the ON CONFLICT clause; identical IDs no-op (idempotent at-least-once delivery, m-1 ruling). as_at carries the balance snapshot timestamp from the event.

Decision: dashboard_insight_card is mutable (m-1)

Cards are inserted, refreshed, displayed, and dismissed — all UPDATE paths. Not append-only; no immutability trigger. The card_type and cta_type CHECK constraints enforce that v2 additions to either enum require an explicit migration (m-7 ruling).

Decision: spending categories from MOD-070, not MOD-041 directly (m-3)

MOD-041 (categorisation model) is a Snowflake-native module; reaching into Snowflake from a Lambda at request time isn't viable for FR-372's 2-second TTI. MOD-041 enriches MOD-070's app.transaction_view via the bank.transactions.categorised consumer that MOD-070 already operates. MOD-077 reads the result via cross-schema SELECT.

Practical consequence: MOD-070 is now the hard dependency; MOD-041 is the indirect (via the projection).

Decision: 14-day cash flow via recurring-detection (m-4)

Pure-function (src/lib/recurring-detect.ts) — bucket debits by display_name + amount band, find groups with ≥2 occurrences in the past 90 days, compute mean gap, project next occurrence within 14 days. Confidence ladder: 2 occ = low, 3 = medium, 4+ = high.

v2 (deferred): MOD-040 forecast service replaces this with a real ML model. The function signature stays compatible for an additive swap.

Decision: low-balance threshold = 7 days of avg daily outflow (m-6)

available_balance < (14-day projected outflow / 14) × 7 → LOW_BALANCE insight card with priority 1 (top of feed) and CTA = INITIATE_TRANSFER. Push routing is MOD-063's concern (the dashboard surfaces the in-app card only).

Decision: net worth = internal accounts only in v1 (m-5)

CDR-linked external account aggregation depends on bank-payments / bank-core CDR feeds that aren't yet shipped. v1 net worth = sum of internal balances in the customer's home currency (NZD / AUD by jurisdiction). UI shows a "Link external accounts" affordance that routes to MOD-049 consent flow.

Decision: MOD-040 health score stub (m-2)

MOD-040 (customer churn / health score) not yet deployed. Stub returns {is_available: false, reason: "MOD-040 not yet deployed"} matching the MOD-074/MOD-083 pattern. Insight ranking falls back to local-only signals (LOW_BALANCE priority 1; RECURRING_FORECAST priority 4) until MOD-040 lands; the API shape stays stable so v2 is purely additive.

Decision: cross-bus grant reuses MOD-070's (m-8)

bank-core bus → BankAppRole is already granted (MOD-070 has two rules on it). The IAM grant is bus-wide, not per-event-type, so MOD-077's third rule for balance_updated deploys cleanly without a new handoff.

External dependencies

  • Database: consolidated bank Neon DB (ADR-064).
  • WRITE: app.account_summary, app.dashboard_insight_card.
  • READ cross-schema: app.transaction_view (MOD-070 projection).
  • EventBridge:
  • Consumes bank.core.balance_updated on the bank-core bus.
  • Publishes nothing.
  • Secrets Manager: bank-neon/{env}/app_app_user (ADR-064 Neon pooled URL).
  • SSM (read): bank-core bus ARN + DLQ ARN; BankAppRole; ADOT layer; operational KMS.

SSM outputs table

Output SSM path Consumers
Dashboard API base URL /bank/{env}/mod077/dashboard-api/url MOD-069 app shell
Dashboard API ID /bank/{env}/mod077/dashboard-api/id API Gateway authoriser attachment
get-dashboard Lambda ARN /bank/{env}/mod077/get-dashboard/fn-arn Operational metrics
dismiss-card Lambda ARN /bank/{env}/mod077/dismiss-card/fn-arn Operational metrics
consume-balance-updated Lambda ARN /bank/{env}/mod077/consume-balance-updated/fn-arn Operational metrics
Dashboard events log group ARN /bank/{env}/mod077/dashboard-events-log/group-arn MOD-076 SIEM subscription
Dashboard events log group name /bank/{env}/mod077/dashboard-events-log/group-name Same

Security and data handling

  • All HTTP routes Cognito-JWT authenticated; party_id from custom claims (ADR-065). No DB lookup at request time.
  • Dismiss returns 404 when the card belongs to a different party (prevents card-id enumeration).
  • No PII beyond cross-domain UUIDs and money amounts; logger emits party_id only when available, never logs balance values.

Performance approach

  • Parallel fan-out on the hot dashboard load (Promise.all over 3 independent fetches: account_summary, transaction_view, forecast).
  • Single covering index on (party_id, account_type) for account_summary; (party_id, priority) WHERE dismissed_at IS NULL partial for active insight cards.
  • Recurring detection is O(N) over 90 days of transactions; for a typical retail customer (~200 txn / 90d) the pure compute is ≪10 ms.

Error handling

  • Sync GET /dashboard — standard error envelope. Codes: UNAUTHORIZED, DB_TIMEOUT (retryable), INTERNAL_ERROR.
  • POST /dashboard/cards/{id}/dismiss — 404 NOT_FOUND if the card_id doesn't belong to the party or is already dismissed.
  • Async consumer (consume-balance-updated) — malformed event logged as balance_updated_invalid and acked (re-driving won't help); transient DB errors re-raise → EventBridge retry-then-DLQ. Duplicates logged as balance_updated_duplicate and acked.

Event types in structured logs

Registered in src/lib/logger.ts:

  • dashboard_served
  • balance_updated_consumed / balance_updated_duplicate / balance_updated_invalid
  • insight_card_refreshed / insight_card_dismissed
  • recurring_forecast_computed
  • low_balance_card_emitted
  • health_score_stub_returned
  • session_invalid / validation_failed / internal_error
  • trace_id_missing_from_upstream — observability standard WARN

Test approach

Tier Location Count
Unit tests/unit/ trace, logger, errors, recurring-detect, low-balance, category-aggregation, health-score-stub — 7 suites
Contract tests/contract/ bank.core.balance_updated consumer schema
Policy tests/policy/ CON-005 AUTO source-token scan + structural
FR integration tests/integration/fr/ FR-369 idempotent UPSERT, FR-370 cross-schema read, FR-371 stub, FR-372 card lifecycle
Post-deploy smoke tests/verify-deployment.mjs 401/403 on unauth GET /dashboard

CON-005 AUTO is satisfied by (a) structural assertion that response shape always exposes balance, available_balance, accrued_interest, currency for every account; (b) source-level scan rejecting suppression tokens (hide_balance, mask_balance, suppress_fee, redact_interest etc.).

v2 follow-ups

  • Replace health-score-stub.ts with a real MOD-040 client once MOD-040 deploys.
  • Add a MOD-040-driven IDLE_CASH card type (the schema already carries the enum value).
  • CDR-linked external account aggregation (CAP-023 full coverage) once bank-payments CDR feed lands.
  • Per-account outflow attribution in the forecast — v1 splits the party-wide forecast uniformly across home-currency accounts.
  • Wire the cold-fetch path (MOD-003 /balance/multi/url) for first- open backfill on the customer's first dashboard load.