Skip to content

MOD-100 — External asset connector

Purpose

Connects to Akahu (NZ open-banking aggregator) under explicit OAuth 2.0 customer consent, polls for KiwiSaver / superannuation / held-away bank balances daily off-peak, normalises provider-specific shapes into a canonical asset payload, and publishes bank-platform.external_asset_updated events. Cached external asset records and consent tokens are owned by this module (see "Architecture note" below); the eventual bank-core SD01 assets consumer subscribes to the EB event when that module is built.

FR scope: FR-401 (daily Akahu poll, retry, alert), FR-402 (normalisation + EB publish), FR-403 (consent token + revoke + 24h purge), FR-404 (provider-mapping config-driven).

Architecture

   Akahu API (real or MOD-157 stub via SSM `/bank/{env}/providers/akahu/url`)
              │ OAuth 2.0 user-token + app credentials
   ┌──────────┴──────────────────────────────────┐
   │ akahu-poller Lambda  (cron 14:00 UTC daily) │
   │   1. ensureSchema (DDL)                     │
   │   2. SELECT consents WHERE revoked_at IS NULL│
   │   3. for each consent:                      │
   │      a. listAccounts (3-attempt exp backoff)│
   │      b. normalise (provider registry)       │
   │      c. upsert assets_staged                │
   │      d. publish external_asset_updated      │
   │      e. on terminal failure: bump fails;    │
   │         at >=3 fire retrieval_failed        │
   └──────────┬──────────────────────────────────┘
   Neon  external_assets.assets_staged          ──▶ bank-platform EB bus
        external_assets.akahu_consent           ──▶ external_asset_updated
        external_assets.consent_audit_log       ──▶ bank-app EB bus
        external_assets.retrieval_failures      ──▶ external_asset_retrieval_failed

   ┌─────────────────────────────────────┐
   │ consent-revoke-cleaner Lambda       │
   │  (rate(1 hour))                     │
   │   PRI-003 GATE: deletes assets_staged│
   │   for revoked consents within 24h   │
   └─────────────────────────────────────┘

   ┌─────────────────────────────────────┐
   │ consent-api Lambda                  │
   │  (direct invoke; behind MOD-075     │
   │   route once app integration ships) │
   │   register / revoke / refresh       │
   │   writes consent_audit_log          │
   └─────────────────────────────────────┘

Architecture note — schema ownership

Per the post-MOD-158 architectural rule, this module owns its own external_assets schema in the bank_platform Neon database. The SD01 assets table — referenced verbatim in the wiki's FR-402 text — is owned by bank-core; that module does not yet exist. When it ships, it will subscribe to bank-platform.external_asset_updated and apply events into its own table. Same pattern as MOD-079 (Snowflake → Neon inbox + own delivery_log) vs the SD01 accounts write-back consumer.

This is the deliberate "modules own their schemas; cross-domain writes go via inbox + event" rule established when MOD-158 was pivoted away from owning DDL for downstream domain tables. FR-402 wording in the wiki ("writing to the assets and asset_party_relationships tables") predates the rule and needs a wiki amendment.

What MOD-100 owns

Resource Purpose
external_assets.akahu_consent Per-customer Akahu OAuth consent (token + scope + expiry + revocation)
external_assets.assets_staged Normalised cached asset rows. PK (customer_id, akahu_account_id). Deleted within 24h of consent revocation (PRI-003 GATE).
external_assets.consent_audit_log Append-only audit of consent lifecycle. UPDATE/DELETE revoked from app_user (PRI-001 LOG).
external_assets.retrieval_failures Per-customer rolling failure counter. Alerts at >=3 consecutive (FR-401).
akahu-poller Lambda Daily 14:00 UTC (02:00 NZST). Off-peak per FR-401.
consent-revoke-cleaner Lambda Hourly. Enforces PRI-003 24h SLA via 1h look-ahead.
consent-api Lambda Direct-invoke handler for register/revoke/refresh.
2× EventBridge schedules (daily poller, hourly cleaner)
SQS DLQ (KMS, 14d) Lambda panic safety net
4× CloudWatch alarms poller errors, cleaner errors, consent-api errors, DLQ depth
13× SSM downstream contract paths

Canonical asset shape (src/shared/asset.ts)

Field Type Required Notes
asset_id uuid Deterministic v5: uuidv5("mod100:{customer_id}:{akahu_account_id}")
customer_id uuid
akahu_account_id string Provider account id (Akahu's _id)
asset_type enum KIWISAVER|SUPERANNUATION|EXTERNAL_DEPOSIT
provider_name string Canonical name from registry, falls back to Akahu's
fund_name string optional
fund_type enum CONSERVATIVE|BALANCED|GROWTH|AGGRESSIVE|DEFAULT optional
member_number string optional
balance_minor integer Cents — avoids float drift
currency enum NZD|AUD
last_contribution_date ISO date optional
as_at ISO timestamp Stamped at fetch time
raw jsonb Verbatim provider payload for audit

Provider registry (FR-404)

src/config/provider-registry.ts ships V1 entries for the major NZ KiwiSaver schemes known to be present on Akahu (ANZ, ASB, Booster, Generate, Milford, Simplicity, Westpac). Adding a new provider is a single object literal — no code changes elsewhere:

{
  canonicalName: "Fisher Funds",
  aliases: ["fisher"],
  fundTypeKeywords: {
    CONSERVATIVE: ["conservative"],
    BALANCED: ["balanced"],
    GROWTH: ["growth"],
  },
}

Direct AU super fund APIs (Phase 2) become entries with treatInvestmentAs: "SUPERANNUATION" once their direct clients land.

SSM contract

Read

Path Owner
/bank/{env}/network/vpc-id, /private-subnet-ids MOD-104
/bank/{env}/kms/operational/arn MOD-104
/bank/{env}/sns/alerts/arn MOD-104
/bank/{env}/eventbridge/bank-platform/arn, /bank-app/arn MOD-104
/bank/{env}/neon/*, bank-neon/{env}/bank_platform/* MOD-103
/bank/{env}/providers/akahu/url MOD-157 stub (dev/uat); operator-set in prod
bank-platform/{env}/akahu/app (Secrets Manager) Akahu app credentials — app_id, app_secret

Write

Path Value
/bank/{env}/mod100/poller-lambda-arn, /poller-lambda-name Daily poller pointers
/bank/{env}/mod100/cleaner-lambda-arn, /cleaner-lambda-name Hourly cleaner pointers
/bank/{env}/mod100/consent-api-lambda-arn, /consent-api-lambda-name Consent-API pointers
/bank/{env}/mod100/staging-table external_assets.assets_staged
/bank/{env}/mod100/consent-table external_assets.akahu_consent
/bank/{env}/mod100/audit-log-table external_assets.consent_audit_log
/bank/{env}/mod100/dlq-arn, /dlq-url DLQ for ops drain
/bank/{env}/mod100/poller-schedule-name, /cleaner-schedule-name EB schedule names

EventBridge contract

Publish

bank-platform.external_asset_updated (one per asset row updated):

{
  "customer_id": "uuid",
  "asset_id": "uuid",
  "asset_type": "KIWISAVER|SUPERANNUATION|EXTERNAL_DEPOSIT",
  "provider_name": "Booster",
  "fund_name": "Booster Conservative Fund",
  "fund_type": "CONSERVATIVE",
  "balance_minor": 1234567,
  "currency": "NZD",
  "last_contribution_date": "2026-04-15",
  "as_at": "2026-04-28T02:03:14Z"
}

bank-app.external_asset_retrieval_failed (per customer at >=3 consecutive failures):

{
  "customer_id": "uuid",
  "consecutive_failures": 3,
  "last_error": "Akahu GET /accounts failed (503)",
  "last_failure_at": "2026-04-28T02:00:14Z"
}

Consume

None V1.

FR / Policy coverage

FR / Policy How
FR-401 (daily Akahu poll, retry, alert) EB schedule cron(0 14 * * ? *) (02:00 NZST). Per-customer 3-attempt exponential backoff via withRetry in src/shared/retry.ts. retrieval_failures.consecutive_failures counter; external_asset_retrieval_failed fired (once) at >=3
FR-402 (normalise + EB publish) src/shared/normalise.ts maps Akahu account+balance → CanonicalAsset; validator gate; publishAssetUpdated() fires the event after upsert. Deterministic asset_id makes re-publishes idempotent
FR-403 (consent + 24h purge) consent-api Lambda registers/revokes/refreshes; revoke writes revoked_at and cache_purge_due_at = now()+24h. consent-revoke-cleaner runs hourly with a 1h look-ahead and DELETEs rows, writes CACHE_PURGED audit
FR-404 (config-driven providers) src/config/provider-registry.ts is the only file that changes when a new provider is added. provider-mapping.ts consumes the registry; the lambda code does not need editing
PRI-001 LOG external_assets.consent_audit_log UPDATE/DELETE revoked from bank_platform_app_user at SQL level (see LOCK_DDL in src/shared/db-setup.ts). Audit covers GRANTED, REVOKED, REFRESHED, EXPIRED, RETRIEVAL_HALTED, CACHE_PURGED
PRI-003 GATE Revocation atomically sets revoked_at + cache_purge_due_at. Cleaner's hourly run with 1h look-ahead guarantees deletion within 24h even if a single run is missed. Negative test: poller's isRetrievable() returns false for revoked consent (proven in consent-state.test.ts)

V1 deferrals

Item Why Path forward
AU superannuation direct provider integration Spec marks Phase 2 Add direct provider clients per fund + register entries with treatInvestmentAs: "SUPERANNUATION"
bank-core SD01 assets write-back consumer Owning module not yet built When SD01 ships its asset-aggregator, subscribe to bank-platform.external_asset_updated and apply into its own assets table
Real OAuth 2.0 redirect flow + customer-facing app endpoint Requires app-side UX + MOD-075 route V1 exposes consent-api Lambda; the app integration lands when MOD-075 wires the route
Push-notification re-auth prompt Depends on MOD-063 trigger rule Add a TRIGGER_RULE to MOD-063 listening for external_asset_retrieval_failed once the customer-facing app surfaces the prompt
Token rotation / KMS field-level encryption beyond Neon TDE V1 stores tokens as-is Wrap with KMS envelope crypto when MOD-045 helper is generalised
Token expiry → EXPIRED audit row V1 logic relies on expires_at <= now check at retrieval time but does not record an EXPIRED row when it transitions Tiny background sweep in cleaner

Tests

  • 59 unit
  • asset.test.ts (12) — canonical validator
  • provider-mapping.test.ts (9) — FR-404 mapper invariants
  • normalise.test.ts (6) — Akahu → canonical
  • consent-state.test.ts (7) — state machine + 24h purge SLA
  • retry.test.ts (9) — FR-401 retry classification + backoff
  • uuid.test.ts (4) — deterministic asset_id
  • consent-api.test.ts (8) — register/revoke/refresh dispatcher
  • cleaner.test.ts (3) — PRI-003 GATE 24h cache deletion
  • 37 live verification via scripts/verify-deployment.mjs — Lambdas Active + VPC, schedules ENABLED at the right cadences, EB-PUT permissions on both buses, audit/consent/staging table FQNs published, DLQ KMS-encrypted with 14d retention, all 4 alarms wired, 13 SSM contract paths populated.

FR text vs architecture reconciliation

The wiki's FR-402 says the system "writes to the assets and asset_party_relationships tables via write-back Lambda". This wording predates the post-MOD-158 architectural rule (modules own their schemas; cross-domain writes go via inbox + event). The implementation follows the new rule: this module owns external_assets.assets_staged and fires bank-platform.external_asset_updated; a future bank-core SD01 module consumes the event and writes the SD01 tables. The wiki amendment should rephrase FR-402 to "writes to its own staging table and publishes a domain event" — coverage table above retains the original FR mapping for traceability.