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
bankNeon 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_updatedon 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 NULLpartial 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 asbalance_updated_invalidand acked (re-driving won't help); transient DB errors re-raise → EventBridge retry-then-DLQ. Duplicates logged asbalance_updated_duplicateand acked.
Event types in structured logs¶
Registered in src/lib/logger.ts:
dashboard_servedbalance_updated_consumed/balance_updated_duplicate/balance_updated_invalidinsight_card_refreshed/insight_card_dismissedrecurring_forecast_computedlow_balance_card_emittedhealth_score_stub_returnedsession_invalid/validation_failed/internal_errortrace_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.tswith 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.