MOD-111 — Term deposit maturity engine¶
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-111-term-deposit-maturity-engine/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048, ADR-053
1. Purpose¶
MOD-111 owns the full term-deposit lifecycle: open → pre-maturity notification (30/14/7 days) → instruction capture (or D−1 auto-default) → maturity execution (rollover / withdrawal / partial) — plus the early-withdrawal break-cost gate (calc + disclose + accept before any funds are released, CON-005). Two daily EventBridge crons handle the time-based work.
CAP-052 (term-deposit lifecycle states) is owned here per MOD-007's
design-doc deferral: MOD-111 manages its own core.term_deposits.lifecycle_state
column (PRE_MATURITY → MATURING → MATURED / ROLLED / BROKEN_EARLY) and
calls MOD-007's transition API only on full account closure.
2. Architecture¶
HTTP POST /internal/v1/term-deposits ─▶ Mod111OpenDepositHandler
HTTP POST /internal/v1/term-deposits/{id}/instructions ─▶ Mod111CaptureInstructionHandler
HTTP POST /internal/v1/term-deposits/{id}/early-withdrawal ─▶ Mod111RequestEarlyWithdrawalHandler
HTTP POST /internal/v1/term-deposits/disclosures/{id}/accept ─▶ Mod111AcceptBreakCostDisclosureHandler
HTTP GET /internal/v1/term-deposits/{id} ─▶ Mod111GetDepositHandler
HTTP GET /internal/v1/term-deposits/{id}/disclosures ─▶ Mod111ListDisclosuresHandler
EventBridge cron(0 13 * * ? *) UTC = 01:00 NZST
├─▶ Mod111ApplyPreMaturityNoticesHandler — FR-494 (30/14/7 days)
└─▶ Mod111ApplyMaturityHandler — FR-495 D−1 default-application + FR-497 D maturity-disbursement
Module type¶
Application Lambda. 8 handlers (6 HTTP + 2 EventBridge-scheduled).
Daily cron — two phases per fire (per orchestrator correction §1)¶
- Phase A — D−1 default application (FR-495) — picks deposits where
maturity_date = current_date + 1ANDdefault_instruction_applied_at IS NULLAND no captured instruction exists. INSERTs anauto_default-source instruction row, stampsdefault_instruction_applied_at, writes aDEFAULT_INSTRUCTION_APPLIEDaudit row. - Phase B — D maturity disbursement (FR-497) — picks deposits where
maturity_date <= current_dateANDlifecycle_state IN (PRE_MATURITY, MATURING). Resolves the effective instruction viainstruction-resolver-pure, executes rollover or withdrawal under MOD-111's ledger-direct-write contract.
The cron fires at 01:00 NZST — the morning AFTER the customer's D−2 23:59 deadline has passed.
3. Data model¶
core.term_deposits (V001)¶
| Column | Notes |
|---|---|
deposit_id |
uuid PK, gen_uuidv7 |
account_id |
FK to accounts.accounts(id) |
principal, contract_rate, term_days, open_date, maturity_date |
term parameters |
default_instruction (+ default_rollover_term_days, default_nominated_account_id, default_partial_withdrawal_amount) |
configured at opening |
default_instruction_applied_at |
stamped by D−1 cron |
previous_deposit_id uuid REFERENCES self |
rollover chain (per orchestrator correction §3); NULL on first deposit |
lifecycle_state |
CHECK ∈ |
matured_at, accrued_interest, final_proceeds, rolled_into_deposit_id |
populated when terminal |
currency, jurisdiction |
denormalised for query speed |
idempotency_key UNIQUE NOT NULL, trace_id, correlation_id |
ADR-048 |
core.term_deposit_instructions (V002) — append-only¶
Latest by captured_at wins. source ∈ {customer_app, agent, auto_default}.
core.break_cost_disclosures (V002) — semi-permissive immutability¶
Per orchestrator correction §2: V002 installs trg_break_cost_disclosures_guard
allowing exactly one transition: accepted_at NULL → ts. Immutable
fields: id, term_deposit_id, break_cost_amount, calculation_basis,
disclosed_at. accepted_via mutates alongside accepted_at (single
atomic UPDATE).
core.term_deposit_events (V003) — append-only audit log¶
Mirrors MOD-006/MOD-110/MOD-140's per-module governance log pattern. 9 event_type values, idempotency_key UNIQUE NOT NULL.
V005 relaxation note¶
An earlier V003 draft added blanket DELETE/TRUNCATE-blocking triggers
on core.term_deposits and full UPDATE/DELETE/TRUNCATE-blocking
triggers on core.term_deposit_instructions. The orchestrator only
mandated terminal-state UPDATE immutability on term_deposits
(correction §3) and the semi-permissive trigger on disclosures
(correction §2) — DELETE on workflow tables wasn't required and the
extra triggers blocked legitimate test cleanup + future ops/archival
paths. V005 drops those over-strict triggers and restores standard
GRANTs. The audit log (term_deposit_events) and the disclosure
acceptance trail (break_cost_disclosures semi-permissive) remain
fully immutable.
Cross-schema seed (V004 — annotated requires: MOD-001 deployed)¶
Per orchestrator correction §4: V004 INSERTs are ON CONFLICT DO NOTHING
into the cross-schema tables MOD-001 owns (no MOD-001 coordination
handoff needed).
- accounts.account_products — NZ_TERM_DEPOSIT_01, AU_TERM_DEPOSIT_01,
INTERNAL_BREAK_COST_RECOVERY_NZ, INTERNAL_BREAK_COST_RECOVERY_AU
- accounts.accounts — two internal break-cost recovery accounts
(deterministic UUIDs 00000000-0000-0000-0000-000000000111 and …112)
4. ADR-048 DB-enforced invariants register¶
| Item | Migration | Negative test |
|---|---|---|
trg_break_cost_disclosures_guard (semi-permissive — accepted_at NULL → ts only) |
V002 | tests/integration/fr-496-break-cost-gate.test.ts + tests/policy/con-005-gate-break-cost-acceptance.test.ts |
trg_term_deposits_no_update_after_terminal (terminal MATURED/ROLLED/BROKEN_EARLY UPDATE blocked) |
V003 | tests/integration/db-trigger-terminal-state.test.ts |
trg_term_deposit_events_no_{update,delete,truncate} (full append-only audit log; NFR-024) |
V003 | tests/integration/db-trigger-events-immutable.test.ts |
| (V005 relaxation note — see below) | V005 | — |
chk_maturity_after_open (CHECK) |
V001 | covered by deposit-validator-pure |
chk_matured_at_iff_terminal (CHECK) |
V001 | covered by maturity-runner integration |
chk_rolled_into_iff_rolled (CHECK) |
V001 | covered by rollover semantics test |
chk_proceeds_iff_terminal (CHECK) |
V001 | covered by maturity-runner integration |
uniq_term_deposits_idempotency_key (UNIQUE) |
V001 | implicit via insert-replay tests |
uniq_term_deposit_events_idempotency_key (UNIQUE) |
V003 | covered by FR-494 idempotency replay |
5. FR mapping¶
| FR | Mode | Implementation |
|---|---|---|
| FR-494 (pre-maturity notices at 30/14/7) | scheduled | apply-pre-maturity-notices daily cron; per-(deposit, threshold) audit row + EventBridge event to MOD-063 |
| FR-495 (auto-default at D−1) | scheduled | apply-maturity daily cron Phase A — runs on D−1 morning AFTER customer's D−2 23:59 deadline (per orchestrator correction §1). Predicate: maturity_date = current_date + 1 AND default_instruction_applied_at IS NULL AND no captured instruction |
| FR-496 (break cost gate) | gate | request-early-withdrawal creates disclosure with computed amount; accept-break-cost-disclosure flips accepted_at via the V002 semi-permissive trigger; the early-withdrawal posting only happens AFTER acceptance |
| FR-497 (atomic disbursement, idempotent) | scheduled | apply-maturity Phase B — per-deposit Pg tx with rollover OR withdrawal; idempotent on replay (terminal-state trigger blocks re-processing) |
| NFR-012 (write p99 ≤ 10ms) | perf | All hot HTTP paths are single-tx INSERT + UPDATE; indexes match the access patterns |
| NFR-019 (RTO ≤ 4h / RPO ≤ 1h, Tier 1) | infra | accounts. + core. are on Neon (MOD-103 backup posture); EventBridge events flow through bank-core bus archive (30 day) |
6. Policies satisfied¶
| Policy | Mode | How satisfied | Test |
|---|---|---|---|
| CON-004 | AUTO | Daily cron emits 30/14/7-day notice events for every matching deposit; one audit row per (deposit, threshold) pair; idempotent replay = no-op (no duplicate notifications) | tests/policy/con-004-auto-pre-maturity-notices.test.ts |
| CON-005 | GATE | Break-cost calc + disclosure + acceptance trail. The V002 semi-permissive trigger structurally enforces "no funds released without accepted disclosure" — re-acceptance, mutation of immutable fields, and DELETE all RAISE | tests/policy/con-005-gate-break-cost-acceptance.test.ts + tests/integration/fr-496-break-cost-gate.test.ts |
| PAY-001 | AUTO | Maturity-runner Phase B — daily cron picks all maturing deposits, executes 2-leg posting under MOD-111's ledger-direct-write contract | tests/policy/pay-001-auto-maturity-disbursement.test.ts + tests/integration/fr-497-maturity-atomic.test.ts |
7. SSM outputs¶
| Path | Value |
|---|---|
/bank/{stage}/mod-111/api/base-url |
API GW base URL |
/bank/{stage}/mod-111/lambdas/{open-deposit,capture-instruction,request-early-withdrawal,accept-break-cost-disclosure,get-deposit,list-disclosures,apply-pre-maturity-notices,apply-maturity}/arn |
per-handler ARN |
/bank/{stage}/mod-111/tables/{term-deposits,term-deposit-instructions,break-cost-disclosures,term-deposit-events}/name |
table FQNs |
8. Cross-module touches¶
accounts.account_products(MOD-001 schema) — V004 INSERTs four new product_codes viaON CONFLICT DO NOTHINGaccounts.accounts(MOD-001 schema) — V004 INSERTs two internal break-cost recovery accounts (deterministic UUIDs)accounts.postings(MOD-001 schema) — runtime writes under MOD-111's ledger-direct-write contract (interest credit + principal disbursement + break-cost capture)accounts.interest_rates(MOD-006-owned for writes; MOD-005 V001 created the table) — runtime READs only (rollover rate + break-cost reinvestment rate lookup)core.accrual_postings(MOD-005 schema) — runtime READs only (sum to compute accrued interest at maturity)- MOD-007 transition API — runtime calls only when full withdrawal closes the underlying account
No source-code changes to other modules.
9. Test approach + results¶
| Tier | Files | Local result |
|---|---|---|
| Unit | tests/unit/{deposit-validator-pure, break-cost-calc-pure, instruction-resolver-pure, errors, logger, emf}.test.ts |
61 / 61 |
| Contract | tests/contract/td-events.test.ts |
11 / 11 |
| FR integration | tests/integration/fr-{494,495,496,497}-*.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-004, con-005, pay-001}-*.test.ts |
run in CI |
Local total: 72 / 72 unit + contract.
10. Architectural decisions captured here¶
- Two scheduled Lambdas (AD-4) — pre-maturity-notices + apply-maturity. apply-maturity runs both D−1 default-application and D maturity-disbursement in one fire (fewer ops surfaces).
- D−1 default-application timing (orchestrator correction §1) — cron
runs at 01:00 NZST on
maturity_date − 1, AFTER the customer's D−2 23:59 deadline. Predicate:default_instruction IS NULL AND default_instruction_applied_at IS NULL. - Rollover creates new core.term_deposits row (orchestrator correction §3) —
old row stamped
lifecycle_state='ROLLED'(terminal, no further updates), new row INSERTed withprevious_deposit_idself-FK back to the closed row. break_cost_disclosuressemi-permissive trigger (orchestrator correction §2) — exactly one transition allowed:accepted_atNULL → ts. Mutation of immutable fields, re-acceptance, DELETE, TRUNCATE all raise.- Break cost direct-posted by MOD-111, not via MOD-110 (AD-1) — MOD-110 has no externally-supplied-amount path; break cost is intrinsically per-deposit-calculated. New internal accounts seeded in V004.
- External nominated-account out of v1 (AD-2) — internal bank-core destinations only; MOD-020 integration is a follow-up.
- Term-deposit lifecycle in MOD-111-owned table (AD-3) — separate
from MOD-007's
accounts.accounts.status. Underlying account stays ACTIVE during the deposit's life. - Calendar-day proxy for "business day" (AD-7) — v1 doesn't integrate a public-holiday calendar; named follow-up.
- Accrued interest read from
core.accrual_postings(AD-8) — sum of MOD-005-written rows over the deposit's life. No HTTP call. - No maker/checker (AD-6) — customer-driven operations; idempotency_key UNIQUE NOT NULL is the safety net.
11. Required wiki updates (apply via separate bank-wiki commit)¶
11.1 SD01 data model¶
Add four new tables under the core schema:
- core.term_deposits (with previous_deposit_id self-FK + ROLLED state)
- core.term_deposit_instructions (append-only)
- core.break_cost_disclosures (semi-permissive immutability — accepted_at one-shot)
- core.term_deposit_events (append-only audit log)
11.2 SD01 ADR-048 DB-enforced invariants register additions¶
See §4 above — five new triggers + four new CHECKs.
11.3 Event catalogue¶
Add eight new events under bank.core.*. All schema_version = "1".
| DetailType | Producer | Consumers |
|---|---|---|
bank.core.term_deposit_opened |
MOD-111 | (audit + dashboards) |
bank.core.term_deposit_pre_maturity_notice |
MOD-111 | MOD-063 (FR-494 customer notification) |
bank.core.term_deposit_instruction_captured |
MOD-111 | (audit + dashboards) |
bank.core.term_deposit_break_cost_disclosed |
MOD-111 | (audit + dashboards) |
bank.core.term_deposit_break_cost_accepted |
MOD-111 | (audit + dashboards) |
bank.core.term_deposit_rolled_over |
MOD-111 | (audit + dashboards) |
bank.core.term_deposit_proceeds_disbursed |
MOD-111 | (audit + dashboards) |
bank.core.term_deposit_broken_early |
MOD-111 | (audit + dashboards) |
12. Verification results (today)¶
| Gate | Result |
|---|---|
pnpm install |
clean |
pnpm typecheck (workspace, 15 packages) |
clean |
pnpm test:unit MOD-111 |
72 / 72 |
pnpm test:integration MOD-111 |
(run in CI under RUN_INTEGRATION=1) |
| MOD-111 V001-V004 migrate against dev Neon | (run by reusable-lambda.yml) |
13. Known follow-ups¶
- MOD-063 consumer wiring — bank-platform module subscribes to
bank.core.term_deposit_pre_maturity_noticefor FR-494 customer-facing notifications. v1 publishes the event into the void; the audit row incore.term_deposit_eventsis the source of truth. - External nominated-account (MOD-020 integration) — v1 supports internal bank-core destinations only. When MOD-020 ships, MOD-111 calls MOD-020's payment API for external transfers.
- Public-holiday calendar — v1 uses calendar days for the D−1 default deadline. NZ/AU public-holiday integration is a future enhancement (impacts the cron scheduling math).
- DST-aware EventBridge schedules — same named tech debt as
MOD-005 / MOD-006 / MOD-140. Switch to
ScheduleExpressionTimezone. - MOD-007 CLOSED-on-full-withdrawal — v1 leaves the underlying account ACTIVE after WITHDRAW_ALL. Cleanup via MOD-007's transition API is a small follow-up that lands when the account-closure flow in MOD-007 is wired through MOD-111.
- MOD-006 retroactive correction wiring — when MOD-006 emits
bank.core.rate_change_activatedwithis_retroactive=true, MOD-005 (not MOD-111) handles the FR-064 correction. MOD-111 is unaffected — it reads accounts.interest_rates on demand at maturity. - Generalising per-module governance logs — same observation as MOD-006: shared abstraction at the 6th use case (now 6 instances: MOD-006, MOD-110, MOD-111, MOD-125, MOD-133, MOD-134, MOD-140).