Skip to content

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 + 1 AND default_instruction_applied_at IS NULL AND no captured instruction exists. INSERTs an auto_default-source instruction row, stamps default_instruction_applied_at, writes a DEFAULT_INSTRUCTION_APPLIED audit row.
  • Phase B — D maturity disbursement (FR-497) — picks deposits where maturity_date <= current_date AND lifecycle_state IN (PRE_MATURITY, MATURING). Resolves the effective instruction via instruction-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_productsNZ_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 via ON CONFLICT DO NOTHING
  • accounts.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 with previous_deposit_id self-FK back to the closed row.
  • break_cost_disclosures semi-permissive trigger (orchestrator correction §2) — exactly one transition allowed: accepted_at NULL → 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_notice for FR-494 customer-facing notifications. v1 publishes the event into the void; the audit row in core.term_deposit_events is 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_activated with is_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).

14. Build status update

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