Skip to content

MOD-112 — Amortisation schedule engine

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


1. Purpose

Generate and maintain the amortisation schedule for instalment loan products (PRD-009 Amortising Loan) and the minimum-monthly-repayment calc for revolving credit (PRD-008 Revolving Credit Facility). At origination produce a full schedule using the declining-balance formula. Recalculate within 5 min when a schedule-changing event arrives — variable rate change, extra repayment, interest-only expiry, or hardship restructure. Old schedules retained for audit; only one is_current row per loan account.

2. Architecture

HTTP POST /internal/v1/loans/{account_id}/originate-schedule       ─▶ Mod112OriginateScheduleHandler
HTTP POST /internal/v1/loans/{account_id}/recalc-rate              ─▶ Mod112RecalcRateChangeHandler
HTTP POST /internal/v1/loans/{account_id}/extra-repayment          ─▶ Mod112ExtraRepaymentOptionsHandler
HTTP POST /internal/v1/loans/{account_id}/extra-repayment/accept   ─▶ Mod112AcceptExtraRepaymentHandler
HTTP GET  /internal/v1/loans/{account_id}/schedule                 ─▶ Mod112GetCurrentScheduleHandler
HTTP GET  /internal/v1/loans/{account_id}/total-cost               ─▶ Mod112GetTotalCostHandler

EventBridge bank.core.rate_change_propagated (from MOD-006)         ─▶ Mod112ConsumeRateChangeHandler
EventBridge cron(5 12 * * ? *) UTC = 00:05 NZST (FR-501 sweep)     ─▶ Mod112SweepRevolvingDailyHandler

8 Lambdas — 6 HTTP + 1 EventBridge consumer + 1 daily cron.

Cross-module integration

  • MOD-001 — direct READs of accounts.accounts (balance, product_code, currency, jurisdiction). Ledger-direct-write contract reserved for v2 (auto-post path; see §11.3).
  • MOD-005 — declared OPTIONAL dependency. The declining-balance formula computes interest from rate × outstanding principal directly, no read of accounts.accruals_daily in v1.
  • MOD-006 — consumes bank.core.rate_change_propagated. Per-account fan-out happens inside Mod112ConsumeRateChangeHandler.
  • MOD-110 — fee-assessment HTTP API for FR-501 late-payment fees. v1 wires the URL via SSM but does not invoke (see §11.4 follow-up).
  • MOD-063 / MOD-113 — customer notification + document vault. v1 publishes EventBridge events; both consumers are unbuilt and the delivery path is schema-first (see §11.1).
  • MOD-065 — hardship restructure consumer. v1 schema only; not built.

3. Data model

loans schema (V001) — new

Per orchestrator correction §1: this module owns the new loans schema in bank_core. The credit schema name is reserved for SD05's bank_credit Neon database.

loans.amortisation_schedules (V001)

  • schedule_id PK (gen_uuidv7)
  • account_id FK → accounts.accounts
  • product_code FK → accounts.account_products
  • version int ≥ 1
  • schedule_type ∈ {PI, IO, REVOLVING}
  • generated_by ∈ {origination, rate_change, extra_repayment, io_expiry, restructure}
  • rate_at_generation numeric(8,6)
  • principal numeric(18,2), term_months int, payment_frequency ∈ {MONTHLY, FORTNIGHTLY, WEEKLY}
  • currency, jurisdiction
  • total_payment_amount numeric(18,2), total_interest_amount numeric(18,2) — aggregates pre-computed
  • is_current boolean, superseded_at timestamptz, superseded_by_schedule_id uuid
  • idempotency_key text UNIQUE NOT NULL, trace_id, correlation_id
  • Coherence CHECKs: superseded fields populated iff is_current=false
  • Partial unique index uniq_amortisation_schedules_one_current_per_account on (account_id) WHERE is_current = true — at most one current schedule per account is structurally guaranteed.

loans.schedule_instalments (V002)

  • instalment_id PK (gen_uuidv7)
  • schedule_id FK → loans.amortisation_schedules
  • payment_number int, due_date date
  • payment_amount, principal_amount, interest_amount, opening_balance, closing_balance (all numeric(18,2))
  • status ∈ {PENDING, PAID, MISSED, PARTIAL}
  • status_changed_at timestamptz, settled_posting_id uuid, paid_amount numeric(18,2)
  • Coherence CHECKs:
  • principal + interest = payment_amount (cent invariant)
  • closing = opening - principal (cent invariant)
  • (schedule_id, payment_number) UNIQUE

loans.amortisation_events (V004) — full append-only

Per-module governance log (REP-001 LOG / NFR-024). 8 event types: SCHEDULE_GENERATED, SCHEDULE_RECALCULATED, EXTRA_REPAYMENT_OFFERED, EXTRA_REPAYMENT_ACCEPTED, IO_EXPIRY_TRANSITION, HARDSHIP_RESTRUCTURED, REVOLVING_MIN_REPAYMENT_POSTED, REVOLVING_LATE_FEE_ASSESSED. Triggers in V004 reject every UPDATE/DELETE/TRUNCATE.

V005 cross-schema seed (accounts.account_products)

4 loan product codes via ON CONFLICT DO NOTHING: - NZ_LOAN_AMORTISING, AU_LOAN_AMORTISING — PRD-009 - NZ_REVOLVING_CREDIT, AU_REVOLVING_CREDIT — PRD-008

4. ADR-048 DB-enforced invariants register

Invariant Migration Negative test
trg_amortisation_schedules_guard (semi-permissive — only is_current=true → false + supersession fields permitted; all other fields immutable) V003 tests/integration/db-trigger-schedule-immutability.test.ts
trg_schedule_instalments_guard (semi-permissive status-transition graph; monetary fields immutable) V003 tests/integration/db-trigger-schedule-immutability.test.ts
trg_amortisation_events_no_{update,delete,truncate} (full append-only) V004 tests/integration/db-trigger-events-immutable.test.ts
chk_instalment_amount_decomposition (principal + interest = payment_amount) V002 covered by FR-498 + FR-499 integration
chk_instalment_balance_decomposition (closing = opening − principal) V002 covered by FR-498 + FR-499 integration
chk_superseded_iff_not_current V001 covered by FR-499 recalc test
uniq_amortisation_schedules_one_current_per_account (partial unique index) V001 covered by FR-499 + extra-repayment integration
uniq_amortisation_schedules_idempotency_key (UNIQUE) V001 covered by FR-498 replay

5. FR mapping

FR Mode Implementation
FR-498 (full schedule at origination, declining-balance, 60s) structural originate-schedule handler in one Pg tx: insert schedule row + bulk insert instalments + governance event + post-commit publish bank.core.amortisation_schedule_generated
FR-499 (recalc within 5 min on schedule-changing event) event-driven consume-rate-change Lambda subscribes to bank.core.rate_change_propagated, fans out per affected account; idempotency key derived from event_id + account_id; publishes bank.core.amortisation_schedule_recalculated
FR-500 (extra repayment offers two options) gated Two-step API: preview returns both reduce_term + reduce_instalment previews + emits options event; accept commits the chosen option as the new version, supersedes prior
FR-501 (revolving min monthly = max(floor, balance × pct)) scheduled Daily cron at 00:05 NZST sweeps PRD-008 accounts; computes minimum; records governance event + posts bank.core.revolving_minimum_repayment_posted. v1 stops at the governance log; auto-post + late-fee assessment are §11 v2 follow-ups

6. Policies satisfied

Policy Mode How satisfied Test
CRE-002 AUTO Schedule + ALL instalments + SCHEDULE_GENERATED event in one Pg tx; integration test asserts atomic visibility tests/policy/cre-002-auto-disclosure.test.ts
CON-004 AUTO Recalc emits SCHEDULE_RECALCULATED governance event tied to new schedule version; post-commit publish to MOD-063 + MOD-113 (schema-first; consumers are v2) tests/policy/con-004-auto-recalc-delivery.test.ts
CON-005 CALC total_interest_amount + total_payment_amount aggregates stored on schedule row at generation time; GET /total-cost reads them; cent-invariant test asserts schedule aggregate matches sum of instalment.interest_amount tests/policy/con-005-calc-total-cost.test.ts

7. SSM outputs

Path Value
/bank/{stage}/mod-112/api/base-url API GW base URL
/bank/{stage}/mod-112/lambdas/{originate-schedule,recalc-rate-change,consume-rate-change,extra-repayment-options,accept-extra-repayment,get-current-schedule,get-total-cost,sweep-revolving-daily}/arn per-handler ARN
/bank/{stage}/mod-112/tables/{amortisation-schedules,schedule-instalments,amortisation-events}/name table FQNs

SSM inputs

Path Source Purpose
/bank/{stage}/iam/lambda/bank-core/arn MOD-104 Lambda execution role
/bank/{stage}/observability/adot-nodejs-layer-arn MOD-104 ADOT layer
/bank/{stage}/eventbridge/bank-core/arn MOD-104 bank-core bus (consume + publish)
/bank/{stage}/eventbridge/bank-platform/arn MOD-104 platform bus (MOD-063 / MOD-113 consumer)
/bank/{stage}/sns/alerts/arn MOD-104 CloudWatch alarm sink
/bank/{stage}/mod-110/api/base-url MOD-110 fee assessment endpoint (v1 reads but doesn't invoke)
/bank/{stage}/neon/direct-host MOD-103 Postgres host (flyway)

8. Cross-module touches

  • accounts.account_products (MOD-001 schema) — V005 INSERTs 4 loan product codes via ON CONFLICT DO NOTHING.
  • accounts.accounts (MOD-001 schema) — runtime READs only (loan account snapshot for schedule generation + recalc + revolving sweep).

No source-code changes to other modules.

9. Test approach + results

Tier Files Local result
Unit tests/unit/{amortisation-calc-pure, minimum-repayment-pure, schedule-validator-pure, errors, logger, emf}.test.ts 56 / 56
Contract tests/contract/amortisation-events.test.ts 5 / 5
FR integration tests/integration/fr-{498,499,500,501}-*.test.ts run in CI
ADR-048 negative tests/integration/db-trigger-{events-immutable,schedule-immutability}.test.ts run in CI
Policy tests/policy/{cre-002,con-004,con-005}-*.test.ts run in CI

Local total: 61 / 61 unit + contract.

10. Architectural decisions captured here (orchestrator-confirmed)

  • AD-1 — loans schema (orchestrator correction §1): own a new loans schema in bank_core. Reserved credit for SD05.
  • AD-2 — Loan account model: regular accounts.accounts rows with product_code ∈ {NZ_LOAN_AMORTISING, AU_LOAN_AMORTISING, NZ_REVOLVING_CREDIT, AU_REVOLVING_CREDIT}.
  • AD-3 — Decimal precision: monetary numeric(18,2); rate numeric(8,6); HALF_EVEN banker's rounding for all per-period interest calcs (matches MOD-005 / MOD-110 / MOD-111 / MOD-143).
  • AD-4 — Schedule versioning with partial unique index ensuring at most one is_current=true row per account.
  • AD-5 — Append-only schedule rows + semi-permissive instalment status: schedule rows mutate ONLY via the supersession transition; instalment rows mutate ONLY via the legal status-transition graph (PENDING → PAID/MISSED/PARTIAL; PARTIAL → PAID/MISSED; MISSED → PAID).
  • AD-6 — Two-step extra-repayment API: preview returns both options
  • emits options event; accept commits the chosen option.
  • AD-7 — Revolving daily cron at 00:05 NZST (matches MOD-130 cadence).
  • AD-8 — Hardship restructure schema-first: consumer wired but not exercised end-to-end; MOD-065 not built.
  • AD-9 — Customer delivery via MOD-063 + MOD-113 schema-first: publishes events; consumers are v2.
  • AD-10 — Per-module governance log with 8 event types.
  • AD-11 — v1 origination endpoint as a placeholder for a future loan-origination workflow module.

11. Compliance debt — explicitly documented

These gaps are intentional v1 limitations. Each one is named here so the regulatory team can see what is and isn't in scope.

11.1 Customer delivery (CON-004) is event-only in v1

v1 publishes bank.core.amortisation_schedule_generated and bank.core.amortisation_schedule_recalculated to the platform bus. MOD-063 (notification engine) and MOD-113 (document vault) are not built; the actual customer email + signed schedule document are not yet delivered. The CON-004 AUTO test asserts the SCHEDULE_RECALCULATED governance event is recorded — the auto-delivery contract endpoint.

v2 plan: stand up MOD-063 / MOD-113 consumers; round-trip test end-to-end delivery within 24h.

11.2 Hardship restructure (FR-499) is schema-only

The amortisation events register includes HARDSHIP_RESTRUCTURED and the schedule_store supports generated_by='restructure', but the EventBridge consumer for bank.core.hardship_restructure_committed (from MOD-065) is not built. v1 contract tests cover the schema; the end-to-end restructure path is a v2 follow-up after MOD-065 ships.

11.3 Revolving auto-post + late-fee assessment (FR-501) stops at the governance log

The daily cron computes the minimum repayment and records REVOLVING_MIN_REPAYMENT_POSTED in loans.amortisation_events — but it does NOT auto-post the debit via MOD-001 ledger-direct-write or trigger a MOD-110 late fee for shortfalls. v1 produces the audit + monitoring trail; the operational posting + fee assessment is v2.

v2 plan: extend the daily handler to (1) check for received payments via accounts.postings since last due date; (2) auto-post minimum debit + corresponding internal-account credit; (3) call MOD-110's fee-assessment endpoint for shortfalls.

11.4 IO-expiry transition is schema-first

Schedule type IO is supported at origination, but the cron-driven transition from IO → PI at expiry is not yet implemented. The governance event type IO_EXPIRY_TRANSITION exists in v1 and the data model permits it, but no handler emits it.

v2 plan: add an IO-expiry sweeper that finds schedules with schedule_type='IO' whose IO period has elapsed and recalcs them as P&I-only.

11.5 Effective-Annual-Rate is the contract rate, not compounded

GET /total-cost returns effective_annual_rate = rate_at_generation verbatim. NZ/AU consumer-credit disclosures regulate the contract (declared) rate, so this matches what the regulator expects to see. A true compounded EAR (with periodic compounding factored in) is a v2 follow-up if a future regulator adds it as a separate disclosure requirement.

12. Required wiki updates (separate bank-wiki commit)

12.1 SD01 data model

Add ## Schema: loans section under SD01 with loans.amortisation_schedules, loans.schedule_instalments, loans.amortisation_events.

12.2 ADR-048 register additions

  • loans.trg_amortisation_schedules_guard (semi-permissive supersession-only)
  • loans.trg_schedule_instalments_guard (semi-permissive status graph)
  • loans.amortisation_events_reject_mutation (full append-only)

12.3 Event catalogue

5 new events under bank.core.*. All schema_version = "1". Per the orchestrator's correction §3 these are added after bank.core.obr_state_verified.

DetailType Producer Consumers
bank.core.amortisation_schedule_generated MOD-112 MOD-063 + MOD-113 (CON-004)
bank.core.amortisation_schedule_recalculated MOD-112 MOD-063 + MOD-113 (CON-004)
bank.core.amortisation_extra_repayment_options MOD-112 customer-app preview
bank.core.revolving_minimum_repayment_posted MOD-112 audit + dashboards
bank.core.revolving_late_payment_assessed MOD-112 MOD-110 (v2) + audit

12.4 MOD-005 dependency status

Per orchestrator correction §2, MOD-005 dependency for MOD-112 is declared OPTIONAL. The forward-reference scenario (offset accounts deducting from loan principal pre-accrual) is documented as a v2 extension when MOD-005 picks up loan accrual.

13. Verification results (today)

Gate Result
pnpm install clean
pnpm typecheck MOD-112 clean
pnpm test:unit MOD-112 61 / 61
pnpm test:integration MOD-112 (run in CI under RUN_INTEGRATION=1)
MOD-112 V001-V005 migrate against dev Neon (run by reusable-lambda.yml)

14. Known follow-ups (v2)

  • MOD-063 + MOD-113 consumers for end-to-end customer delivery (§11.1)
  • MOD-065 hardship-restructure consumer wiring (§11.2)
  • Revolving auto-post + late-fee (§11.3)
  • IO-expiry sweeper (§11.4)
  • Compounded EAR (§11.5)
  • MOD-005 offset-account interest — when MOD-005 picks up loan accrual the schedule engine reads the net daily accrual instead of multiplying balance × rate.
  • Per-instalment payment matching — a future module ingests accounts.postings and updates instalment status (PENDING → PAID).