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_dailyin v1. - MOD-006 — consumes
bank.core.rate_change_propagated. Per-account fan-out happens insideMod112ConsumeRateChangeHandler. - 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_idPK (gen_uuidv7)account_idFK → accounts.accountsproduct_codeFK → accounts.account_productsversion int≥ 1schedule_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,jurisdictiontotal_payment_amount numeric(18,2),total_interest_amount numeric(18,2)— aggregates pre-computedis_current boolean,superseded_at timestamptz,superseded_by_schedule_id uuididempotency_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_accounton(account_id) WHERE is_current = true— at most one current schedule per account is structurally guaranteed.
loans.schedule_instalments (V002)¶
instalment_idPK (gen_uuidv7)schedule_idFK → loans.amortisation_schedulespayment_number int,due_date datepayment_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 viaON 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 —
loansschema (orchestrator correction §1): own a newloansschema in bank_core. Reservedcreditfor SD05. - AD-2 — Loan account model: regular
accounts.accountsrows withproduct_code∈ {NZ_LOAN_AMORTISING, AU_LOAN_AMORTISING, NZ_REVOLVING_CREDIT, AU_REVOLVING_CREDIT}. - AD-3 — Decimal precision: monetary
numeric(18,2); ratenumeric(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=truerow 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).