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-writecontract; 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.postingsfor 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_ratesONCE 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_idPKaccount_idFK → accounts.accountsproduct_codeFK → accounts.account_productsnotice_period_days intannual_interest_rate numeric(8,6) NOT NULL— snapshot at lodgement time (per correction §2)amount numeric(18,2)(NULL = full balance at release)currency,jurisdictionlodged_at,withdrawal_available_datestatus∈ {pending, withdrawn, cancelled, lapsed}withdrawn_at,cancelled_at,posting_id,penalty_amount,final_proceedsidempotency_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_reasonCHECK (MOD-007 territory) — V001 lockstep ALTER adds'NOTICE_PENDING'to the enum (per correction §1)accounts.account_state_history.restriction_reasonCHECK (MOD-007 territory) — same V001 lockstep ALTERaccounts.account_products(MOD-001 schema) — V004 INSERTs 4 customer notice product_codes + 2 internal penalty-recovery codes viaON CONFLICT DO NOTHINGaccounts.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 oncore.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.accountsANDaccounts.account_state_historyin the same Pg transaction. - Penalty rate snapshot at lodgement time (correction §2) — the
core.notice_lodgements.annual_interest_ratecolumn 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_snapshotand HTTP-poll the buckets endpoint. v1 publishes into the void. - Partial early withdrawal — out of v1 (AD-7).
lapsedlodgement 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}_eventstables. Promote to shared abstraction.