Skip to content

MOD-130 — Notice account management

System: SD01 Core Banking · Repo: bank-core · Phase: 6 Status: Built (pre-deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD01-core-banking/modules/MOD-130-notice-account-management/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048


1. Purpose

MOD-130 owns the notice-period lifecycle for notice account products (PRD-021). Customer lodges a notice; the module records the lodgement, calculates withdrawal_available_date = today + notice_period_days, transitions the account to RESTRICTED + restriction_reason='NOTICE_PENDING' via MOD-007 (so the existing V005 posting trigger blocks DEBITs during the window), and dispatches a confirmation event for MOD-063.

A daily cron at 00:05 NZST does three sweeps: - Auto-release (FR-551 / CON-001 AUTO) — lodgements maturing today - 7-day reminder (CON-004 AUTO) — lodgements maturing in 7 days - Liquidity-bucket snapshot (FR-552 / CLQ-002 CALC) — for MOD-032 LCR/NSFR

Early withdrawal goes through a disclosure-and-acceptance gate (CON-005). Penalty notice_period_days × annual_rate/365 × amount calculated from the lodgement-time snapshot of the rate (per orchestrator correction §2 — never re-read from accounts.interest_rates).

2. Architecture

HTTP POST /internal/v1/notice-accounts/lodgements                  ─▶ Mod130LodgeNoticeHandler
HTTP POST /internal/v1/notice-accounts/lodgements/{id}/early-withdrawal ─▶ Mod130RequestEarlyWithdrawalHandler
HTTP POST /internal/v1/notice-accounts/disclosures/{id}/accept     ─▶ Mod130AcceptDisclosureHandler
HTTP GET  /internal/v1/notice-accounts/lodgements/{id}             ─▶ Mod130GetLodgementHandler
HTTP GET  /internal/v1/notice-accounts/by-account/{id}             ─▶ Mod130ListLodgementsHandler
HTTP GET  /internal/v1/notice-accounts/liquidity-buckets           ─▶ Mod130GetLiquidityBucketsHandler

EventBridge cron(5 12 * * ? *) UTC = 00:05 NZST                    ─▶ Mod130ApplyNoticeDailyHandler
   ├─ Phase A — auto-release (FR-551)
   ├─ Phase B — 7-day reminder (CON-004)
   └─ Phase C — liquidity-bucket snapshot (FR-552 / CLQ-002)

7 Lambdas (6 HTTP + 1 EventBridge-scheduled).

Cross-module integration

  • MOD-007 — declared account-status-write contract; MOD-130 calls the transition API to flip accounts in/out of RESTRICTED+NOTICE_PENDING. V005 trigger blocks DEBITs on RESTRICTED accounts → notice gate enforced structurally without a new MOD-130 trigger.
  • MOD-001 — direct-write to accounts.postings for the disbursement
  • penalty legs at acceptance time (under MOD-130's ledger-direct-write contract; mirrors MOD-005 / MOD-110 / MOD-111 / MOD-004).
  • MOD-005 — read accounts.interest_rates ONCE at lodgement time to snapshot the rate (per correction §2).
  • MOD-063 — consumes 3 of MOD-130's 6 outbound events for customer notifications.
  • MOD-032 (when it ships) — consumes bank.core.notice_liquidity_snapshot.

3. Data model

core.notice_lodgements (V001)

  • lodgement_id PK
  • account_id FK → accounts.accounts
  • product_code FK → accounts.account_products
  • notice_period_days int
  • annual_interest_rate numeric(8,6) NOT NULL — snapshot at lodgement time (per correction §2)
  • amount numeric(18,2) (NULL = full balance at release)
  • currency, jurisdiction
  • lodged_at, withdrawal_available_date
  • status ∈ {pending, withdrawn, cancelled, lapsed}
  • withdrawn_at, cancelled_at, posting_id, penalty_amount, final_proceeds
  • idempotency_key UNIQUE NOT NULL, trace_id, correlation_id

core.notice_early_withdrawals (V002) — semi-permissive immutability

Per orchestrator AD-2 (table name) + the §2 correction from MOD-111 (verbatim semi-permissive trigger pattern): - id PK, lodgement_id FK - penalty_amount, calculation_basis jsonb ({snapshot_annual_interest_rate, notice_period_days, lodgement_amount, formula_version: "v1"}) - disclosed_at, accepted_at (mutable NULL → ts once), accepted_via ∈ {app, agent} - Immutable fields: id, lodgement_id, penalty_amount, calculation_basis, disclosed_at

core.notice_events (V003) — full append-only

Per-module governance log. 7 event types. NFR-024 immutability triggers + privilege revocation.

Cross-module ALTER (V001 lockstep)

Per orchestrator correction §1 / MOD-134 V001 precedent — the restriction_reason CHECK enum lives on BOTH accounts.accounts AND accounts.account_state_history, and they MUST stay identical. V001 expands both in one Pg transaction:

ALTER TABLE accounts.accounts ... CHECK (restriction_reason IN (
  'SANCTIONS','FRAUD_INVESTIGATION','HARDSHIP_ARRANGEMENT',
  'ADMIN','INSUFFICIENT_SIGNATORIES','NOTICE_PENDING'
));

ALTER TABLE accounts.account_state_history ... CHECK (... 'NOTICE_PENDING');

The audit-ci compile-time check enforces lockstep — any migration that ALTERs only one of the two would be rejected.

V004 cross-schema seed

accounts.account_products and accounts.accounts INSERTs via ON CONFLICT DO NOTHING: - 4 customer notice products: NZ_NOTICE_30, NZ_NOTICE_90, AU_NOTICE_30, AU_NOTICE_90 - 2 internal penalty-recovery products + accounts: INTERNAL_NOTICE_PENALTY_RECOVERY_NZ (deterministic UUID …130), INTERNAL_NOTICE_PENALTY_RECOVERY_AU (…131)

4. ADR-048 DB-enforced invariants register

Invariant Migration Negative test
trg_notice_early_withdrawals_guard (semi-permissive — accepted_at NULL → ts only) V002 tests/integration/fr-550-penalty-disclosure.test.ts + tests/policy/con-005-gate-acceptance.test.ts
trg_notice_lodgements_no_update_after_terminal (terminal withdrawn/cancelled UPDATE blocked) V003 tests/integration/db-trigger-terminal-state.test.ts
trg_notice_events_no_{update,delete,truncate} (full append-only audit log; NFR-024) V003 tests/integration/db-trigger-events-immutable.test.ts
chk_withdrawal_after_lodged (CHECK) V001 covered by validator + integration
chk_withdrawn_at_iff_withdrawn, chk_cancelled_at_iff_cancelled, etc. V001 covered by lodgement-store integration
uniq_notice_lodgements_idempotency_key (UNIQUE) V001 implicit via insert-replay tests
uniq_notice_events_idempotency_key (UNIQUE) V003 implicit via cron replay tests
Lockstep CHECK on accounts.accounts.restriction_reason AND accounts.account_state_history.restriction_reason (NOTICE_PENDING) V001 covered indirectly by FR-549

5. FR mapping

FR Mode Implementation
FR-549 (notice gate, no override) structural Lodge handler calls MOD-007 to flip account → RESTRICTED+NOTICE_PENDING. MOD-007's V005 posting trigger blocks DEBITs structurally. The only path to early funds is the disclosure-and-acceptance flow.
FR-550 (penalty calc + disclosure + acceptance) gated request-early-withdrawal calculates penalty from notice_lodgements.annual_interest_rate snapshot (per correction §2). accept-disclosure re-computes from same snapshot, asserts equality, then posts. The semi-permissive trigger structurally enforces the one-shot acceptance.
FR-551 (daily auto-release, idempotent) scheduled apply-notice-daily Phase A — per-lodgement Pg tx; idempotent on replay (terminal-state trigger blocks re-processing).
FR-552 (daily liquidity bucketed feed for MOD-032) scheduled + on-demand apply-notice-daily Phase C — emits bank.core.notice_liquidity_snapshot. Also exposed as a pollable HTTP endpoint.

6. Policies satisfied

Policy Mode How satisfied Test
CON-001 AUTO Daily cron auto-releases every matching lodgement; per-lodgement tx; idempotent tests/policy/con-001-auto-release.test.ts
CON-004 AUTO Three notification events fire from automated triggers (lodgement event at lodge time / 7-day-reminder cron / day-of-availability cron); MOD-063 dispatches tests/policy/con-004-auto-notifications.test.ts
CON-005 GATE Penalty calc + disclosure + acceptance trail; semi-permissive immutability trigger; CON-005 GATE invariant assertion in accept handler (recomputed == disclosed) tests/policy/con-005-gate-acceptance.test.ts + tests/integration/fr-550-penalty-disclosure.test.ts
CLQ-002 CALC Daily snapshot exposed as HTTP endpoint AND EventBridge event with field names per AD-5 (within_30_days, days_31_to_60, days_61_to_90); MOD-032 consumes for LCR/NSFR tests/policy/clq-002-calc-liquidity.test.ts

7. SSM outputs

Path Value
/bank/{stage}/mod-130/api/base-url API GW base URL
/bank/{stage}/mod-130/lambdas/{lodge-notice,request-early-withdrawal,accept-disclosure,get-lodgement,list-lodgements,get-liquidity-buckets,apply-notice-daily}/arn per-handler ARN
/bank/{stage}/mod-130/tables/{notice-lodgements,notice-early-withdrawals,notice-events}/name table FQNs

8. Cross-module touches

  • accounts.accounts.restriction_reason CHECK (MOD-007 territory) — V001 lockstep ALTER adds 'NOTICE_PENDING' to the enum (per correction §1)
  • accounts.account_state_history.restriction_reason CHECK (MOD-007 territory) — same V001 lockstep ALTER
  • accounts.account_products (MOD-001 schema) — V004 INSERTs 4 customer notice product_codes + 2 internal penalty-recovery codes via ON CONFLICT DO NOTHING
  • accounts.accounts (MOD-001 schema) — V004 INSERTs 2 internal penalty-recovery accounts (deterministic UUIDs)
  • accounts.postings (MOD-001 schema) — runtime writes under MOD-130's ledger-direct-write contract (early-withdrawal + penalty postings)
  • accounts.interest_rates (MOD-005 V001 schema; MOD-006-owned for writes from MOD-006 ship onwards) — runtime READ ONCE at lodgement time (snapshot stored on core.notice_lodgements.annual_interest_rate)
  • MOD-007 transition API — runtime calls at lodge / accept-disclosure / auto-release to flip restriction_reason

No source-code changes to other modules.

9. Test approach + results

Tier Files Local result
Unit tests/unit/{lodgement-validator-pure, penalty-calc-pure, liquidity-bucket-pure, errors, logger, emf}.test.ts 63 / 63
Contract tests/contract/notice-events.test.ts 8 / 8
FR integration tests/integration/fr-{549,550,551,552}-*.test.ts run in CI
ADR-048 negative tests/integration/db-trigger-{events-immutable, terminal-state}.test.ts run in CI
Policy tests/policy/{con-001, con-004, con-005, clq-002}-*.test.ts run in CI

Local total: 71 / 71 unit + contract.

10. Architectural decisions captured here

  • Notice gate via MOD-007 RESTRICTED + NOTICE_PENDING (AD-1 confirmed) — reuses MOD-007's V005 trigger for the DEBIT block, no new posting trigger in MOD-130. Cross-module additive ALTER on the CHECK constraint is the only schema coupling, and it follows the MOD-134 V001 precedent exactly.
  • Lockstep ALTER on both restriction_reason CHECK columns (correction §1) — V001 ALTERs accounts.accounts AND accounts.account_state_history in the same Pg transaction.
  • Penalty rate snapshot at lodgement time (correction §2) — the core.notice_lodgements.annual_interest_rate column is set ONCE at lodgement and never re-read. The accept handler asserts the recomputed penalty matches the disclosure's penalty before posting (defence in depth).
  • Single daily cron, three phases (AD-3 + AD-4 + AD-5) — auto-release + 7-day reminder + liquidity snapshot in one daily fire at 00:05 NZST.
  • Cron schedule cron(5 12 * * ? *) UTC (AD-3) — different from the 01:00 NZST shape used by MOD-006/MOD-111/MOD-140; the runner uses Intl.DateTimeFormat('en-CA', { timeZone: 'Pacific/Auckland' }) so the "today" computation is DST-safe.
  • No partial early withdrawal (AD-7) — the lodgement is for the full amount; partial withdrawal is a future product enhancement.
  • No MOD-050 disclosure dependency (AD-8) — disclosure flow is inline via core.notice_early_withdrawals (mirrors MOD-111 break_cost_disclosures). Notifications go directly to MOD-063 via EventBridge events.
  • Customer-driven; no maker/checker — same as MOD-111.

11. Required wiki updates (apply via separate bank-wiki commit)

11.1 SD01 data model

Add three new tables under core schema: notice_lodgements, notice_early_withdrawals, notice_events.

Update the accounts.accounts.restriction_reason and accounts.account_state_history.restriction_reason CHECK enum documentation to include 'NOTICE_PENDING' (set by MOD-130).

11.2 ADR-048 register additions

See §4 above — three new triggers.

11.3 Event catalogue

Add 6 new events under bank.core.*. All schema_version = "1".

DetailType Producer Consumers
bank.core.notice_lodged MOD-130 MOD-063 (CON-004)
bank.core.notice_pre_withdrawal_reminder MOD-130 MOD-063 (CON-004)
bank.core.notice_funds_available MOD-130 MOD-063 (CON-004)
bank.core.notice_early_withdrawal_disclosed MOD-130 audit + dashboards
bank.core.notice_early_withdrawal_accepted MOD-130 audit + dashboards
bank.core.notice_liquidity_snapshot MOD-130 MOD-032 (FR-552 / CLQ-002 LCR/NSFR)

12. Verification results (today)

Gate Result
pnpm install clean
pnpm typecheck (workspace, 16 packages) clean
pnpm test:unit MOD-130 71 / 71
pnpm test:integration MOD-130 (run in CI under RUN_INTEGRATION=1)
MOD-130 V001-V004 migrate against dev Neon (run by reusable-lambda.yml)

13. Known follow-ups

  • MOD-032 LCR/NSFR consumer — subscribe to bank.core.notice_liquidity_snapshot and HTTP-poll the buckets endpoint. v1 publishes into the void.
  • Partial early withdrawal — out of v1 (AD-7).
  • lapsed lodgement state — schema enum includes 'lapsed' but v1 cron doesn't transition there. Operations cleanup pass is a follow-up.
  • External nominated-account disbursement (MOD-020) — v1 supports internal bank-core destinations only.
  • Public-holiday calendar — v1 uses calendar days for the 7-day reminder + notice-period math.
  • DST-aware EventBridge schedules — same named tech debt as MOD-005 / MOD-006 / MOD-111 / MOD-140.
  • Generalising per-module governance logs — MOD-130 makes 7 modules with parallel core.{domain}_events tables. Promote to shared abstraction.

14. Build status update

# bank-wiki processes docs/handoffs/MOD-130-ci-{built,deployed}-{sha}.handoff.md
# automatically once CI lands.