Skip to content

MOD-163 — Break-cost calculator

System: SD05 | Repo: bank-credit | Status (at handoff time): In progress → Built → Deployed (CI-driven)

Purpose

Calculates the break cost a customer owes (or is owed) when a FIXED-rate component of a Flexible Loan Facility (PRD-024) is prepaid or restructured before maturity. Two flavours:

  • Indicative (FR-745) — informational quote, no obligations, sub-500ms.
  • Binding (FR-746) — locked quote, gated by MOD-050 disclosure acknowledgement (CON-005), valid for 5 NZ business days. Emits bank.credit.break_cost_acknowledged once the customer acknowledges; MOD-065 applies the result at prepayment time (positive: customer pays; negative: bank credits the customer via MOD-001).

13 AD ratifications

AD Decision Source
k-1 Formula v1.0.0 baked into in-module src/lib/break-cost-formula.ts; shared @bank/break-cost lib deferred to v2; every row carries formula_version for audit Confirmed
k-2 Linear interpolation between surrounding MOD-085 tenor buckets; 4dp on rate; TENOR_OUT_OF_RANGE outside [3m, 60m] Confirmed
k-3 Market rate staleness threshold 15 min; binding HARD refuses (MARKET_RATE_STALE); indicative proceeds with market_rate_warning + x-market-rate-warning header. Staleness measured against credit.swap_rates_mirror.created_at (the mirror-write timestamp) — curve_date is a DATE and is returned in the response as the business date. Revised 2026-05-18 — ADR-046
k-4 Synchronous SigV4 HTTP to MOD-050 /disclosures; content_hash SHA-256 includes formula_version as a top-level field of the canonical JSON Amendment
k-5 idempotency_key mandatory for binding, optional for indicative; 24h TTL on credit.idempotency_keys (module_id='MOD-163'); shorter than valid_until is intentional Confirmed
k-6 MOD-050 disclosure-issue is SYNCHRONOUS on the binding path; if MOD-050 unavailable → 503 DISCLOSURE_SERVICE_UNAVAILABLE and no DB row left in a half-issued state. Swap rates: direct Postgres read of credit.swap_rates_mirror; if no rows for the (jurisdiction, tenor) bucket → 503 MARKET_RATE_UNAVAILABLE. Override + Revised 2026-05-18 — ADR-046
k-7 MOD-163 computes and persists negative break_cost (break benefit) but does NOT post the GL credit. MOD-065 posts via MOD-001 at prepayment time. Clean separation. Confirmed
k-8 Binding requires component_id NOT NULL (rejected COMPONENT_REQUIRED_FOR_BINDING); indicative supports whole-facility quote with component_id=null Confirmed
k-9 valid_until = now + 5 NZ business days. Hard-coded nz-calendar.ts 2026+2027 holiday list with annual-refresh TODO. AppConfig deferred to v2. Confirmed
k-10 FLOATING components have no break cost. Reject with NO_BREAK_COST_ON_FLOATING (422). Whole-facility indicative aggregates FIXED-only and flags floating_components_excluded: true. Confirmed
k-11 Status machine ACTIVE → EXPIRED (cron 23:00 UTC) → SUPERSEDED (new binding on same component) → ACKNOWLEDGED → APPLIED (MOD-065 direct DB write) \| CANCELLED. All transitions whitelisted in the immutability trigger. Confirmed
k-12 SELECT ... FOR UPDATE on the component row inside insertBindingAtomically. Before INSERT, SUPERSEDE any existing BINDING+ACTIVE row(s) for the same component_id in the same txn. Amendment
k-13 Reads credit.swap_rates_mirror Postgres table via PostgresSwapRateMirrorReader (src/services/swap-rate-mirror.ts); no Function URL; ADR-046 compliant. MOD-085 owns the table + write-back Lambda + bank.risk-platform.swap_curve_updated event. Revised 2026-05-18 — ADR-046 (bank-wiki issue #26 ruling)

Summary: originally 10 ratified clean + 2 amendments + 1 override. Revised 2026-05-18 per bank-wiki issue #26: k-3, k-6, k-13 updated to mirror pattern (ADR-046 — SD06 is never queried inline on customer-facing requests).

Architecture

                      ┌──────────────────────────────┐
                      │ MOD-162 facility + component │  read source-of-truth
                      └──────────────────────────────┘
                                  │  fetchFacility / fetchComponent
   ┌──────────────────────────┐   │
   │ calculate-indicative     │ ──┘   FR-745, single component OR whole-facility
   │  (Function URL / IAM)    │ ── credit.swap_rates_mirror read → break-cost-formula v1.0.0
   └──────────────────────────┘ ── persist INDICATIVE row; valid_until=NULL
                                   no outbound event
                                   k-3 stale → WARNING header

   ┌──────────────────────────┐
   │ calculate-binding        │ FR-746; k-12 atomic transaction
   │  (Function URL / IAM)    │ ── credit.swap_rates_mirror read → k-3 stale HARD REFUSE
   └──────────────────────────┘ ── break-cost-formula v1.0.0
                                   k-4 canonical hash (formula_version IN hash)
                                   k-6 MOD-050 issue SYNC
                                   k-12 SELECT FOR UPDATE → SUPERSEDE → INSERT
                                   persist BINDING row (status=ACTIVE);
                                   valid_until = now + 5 NZ business days (k-9)
                                   no outbound event yet

   ┌──────────────────────────┐
   │ confirm-acknowledgement  │ FR-747 + CON-005 GATE
   │  (Function URL / IAM)    │ ── MOD-050 lookup (SYNC; k-6)
   └──────────────────────────┘ ── verify status, party_id, content_hash (k-4)
                                   whitelisted UPDATE → status='ACKNOWLEDGED'
                                   emit bank.credit.break_cost_acknowledged

   ┌──────────────────────────┐
   │ expire-quotes-sweep      │ k-11; cron 23:00 UTC daily; DISABLED non-prod
   │  (EB Scheduler invoke)   │ ── UPDATE BINDING+ACTIVE WHERE valid_until<now → EXPIRED
   └──────────────────────────┘

           Downstream consumer:
              bank.credit.break_cost_acknowledged → MOD-065 (prepayment processor)
                                                  → (future) MOD-132
                If break_cost < 0 (k-7): MOD-065 posts GL credit via MOD-001.
                If break_cost > 0: MOD-065 ensures the customer pays before
                facility/component prepayment commits.

Data model — credit.break_cost_calculations

Single Cat 1 immutable table. Append-only with whitelisted status transitions. Frozen columns (immutable forever): id, calculation_type, loan_facility_id, component_id, party_id, all rate / principal / term / annuity / break_cost / formula_version fields, valid_until, idempotency_key, trace_id, created_at. Mutable: status, disclosure_acknowledgement_id, disclosure_content_hash (set once at issuance), acknowledged_at. fn_break_cost_immutable trigger raises on any non-whitelisted change and on DELETE.

Indexes:

  • idx_break_cost_calculations_facility — facility-scoped lookups
  • idx_break_cost_calculations_component (partial) — per-component history
  • idx_break_cost_calculations_party — customer-scoped lookups
  • idx_break_cost_calculations_active_binding (partial; component_id + status) — k-12 supersede + MOD-065 read at prepayment
  • idx_break_cost_calculations_expiry_sweep (partial; valid_until) — cron sweep
  • uq_break_cost_calculations_idempotency (unique; idempotency_key + calculation_type) — k-5

Cross-table CHECK constraints:

  • chk_binding_has_component_id — k-8
  • chk_binding_has_valid_until
  • chk_acknowledged_has_disclosure_id — CON-005 DB belt-and-braces
  • chk_acknowledged_has_timestamp

Grants (least-privilege): SELECT, INSERT, UPDATE to credit_app_user (UPDATE only succeeds on whitelisted columns; trigger enforces); SELECT to credit_readonly. No DELETE.

Lambdas + endpoints

Lambda Function URL (IAM) Memory Timeout Purpose
calculate-indicative yes 512 MB 30 s FR-745 quote; multi-component aggregation; idempotency optional
calculate-binding yes 1024 MB 30 s FR-746 binding quote; SELECT FOR UPDATE + supersede; MOD-050 issue SYNC; persists hash
confirm-acknowledgement yes 512 MB 30 s FR-747 + CON-005 GATE; verifies disclosure (status/party_id/hash); emits event
expire-quotes-sweep (cron) 512 MB 5 min k-11; cron 23:00 UTC daily

All arm64. ADOT layer attached. CloudWatch retention 1y prod / 1mo non-prod.

SSM outputs

Path Type Value
/bank/{env}/credit/break-cost/calculate-indicative-function-arn String Lambda ARN
/bank/{env}/credit/break-cost/calculate-indicative-api-endpoint String Function URL
/bank/{env}/credit/break-cost/calculate-binding-function-arn String Lambda ARN
/bank/{env}/credit/break-cost/calculate-binding-api-endpoint String Function URL
/bank/{env}/credit/break-cost/confirm-acknowledgement-function-arn String Lambda ARN
/bank/{env}/credit/break-cost/confirm-acknowledgement-api-endpoint String Function URL
/bank/{env}/credit/break-cost/expire-quotes-sweep-function-arn String Lambda ARN
/bank/{env}/credit/tables/break-cost-calculations/name String credit.break_cost_calculations

Upstream contracts

Module Surface Used for Status
MOD-103 bank-neon/{env}/credit_app_user.pooled_url DATABASE_URL (ADR-064) Deployed
MOD-104 BankCreditRole / bank-credit bus ARN / KMS / ADOT layer Lambda execution + EB publish Deployed
MOD-128 credit.fn_immutable_row() + credit.idempotency_keys Cat 1 helpers + idempotency Deployed
MOD-162 credit.loan_facilities + credit.loan_facility_components (joined to credit.loan_accounts) facility + component read Deployed
MOD-085 Postgres table credit.swap_rates_mirror (owned by MOD-085 — created in its Flyway migration; populated by its write-back Lambda on each Snowflake market.swap_curve refresh) swap rate by (jurisdiction, tenor_months) Direct read via shared credit Postgres pool. No SSM URL, no SigV4 — ADR-046 compliant (bank-wiki issue #26 ruling 2026-05-17). Returns MARKET_RATE_UNAVAILABLE (503) if no rows for the bucket — non-blocking deploy.
MOD-050 SSM /bank/{env}/customer/disclosures/issue-api + /lookup-api disclosure issue/lookup optionalSsm + placeholder fallback

Until MOD-085 / MOD-050 publish their SSM outputs, the Lambda env vars default to placeholder URLs and any runtime call fails with PROVIDER_ERROR. Redeploy after upstream SSM publication is non-breaking.

Events

Outbound

bank.credit.break_cost_acknowledged (NEW — catalogue addendum filed). Detail-type carried, fields per src/types/events.ts.

Primary consumer: MOD-065 (prepayment processor). Future consumer: MOD-132 (loan restructure).

Inbound

None. MOD-163 is call-driven (Function URL) plus the cron sweep.

Idempotency keys

Scope Key format TTL
Indicative idempotency_key if supplied; otherwise mod163:indicative:{uuid} 24h
Binding idempotency_key (required) 24h
Confirm ack confirm-ack:{break_cost_calculation_id}:{disclosure_acknowledgement_id} 24h
Event emit mod163:break-cost-acknowledged:{calculation_id} n/a (event-level)

All keys stored in shared credit.idempotency_keys with module_id='MOD-163'.

Quality gates

  • pnpm typecheck — zero errors
  • pnpm test:unit — formula CALC, NZ calendar, canonical hash (k-4), handler unit tests, sweep, event-publisher contract
  • pnpm test:policy — CRE-009 CALC, CON-005 GATE, NFR-024 immutability + accuracy
  • pnpm test:contract — break_cost_acknowledged event detail shape
  • pnpm test:integration — FR-745..748 (CI-only via RUN_INTEGRATION=1)
  • flyway validate — pre-deploy schema validation
  • flyway migrate + pg_dump schema snapshot — post-deploy
  • Smoke test: tests/verify-deployment.mjs — empty-body POST to calculate-binding → 422 INVALID_REQUEST

CI

  • Caller workflow: .gitlab/ci/mod-163.gitlab-ci.yml
  • has_postgres: true — Flyway runs before & after SST deploy
  • needs: chain: mod-128-deploy (optional) + mod-162-deploy (optional)

Open items handed off in this PR

  • ~~MOD-085-market-rate-ssm-publish.handoff.md~~ — RETRACTED 2026-05-18 per ADR-046 ruling. Superseded by the existing MOD-085-swap-rate-mirror.handoff.md (2026-05-08) and the bank-wiki-driven mod-085-swap-rate-mirror-ruling.handoff.md to bank-risk-platform.
  • MOD-050-disclosure-ssm-publish.handoff.md — request MOD-050 to publish issue + lookup SSM paths
  • MOD-163-event-catalogue-addendum.handoff.md — register bank.credit.break_cost_acknowledged in wiki event catalogue
  • MOD-163-data-model-gap.handoff.md — register credit.break_cost_calculations in SD05 data model