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_acknowledgedonce 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 lookupsidx_break_cost_calculations_component(partial) — per-component historyidx_break_cost_calculations_party— customer-scoped lookupsidx_break_cost_calculations_active_binding(partial; component_id + status) — k-12 supersede + MOD-065 read at prepaymentidx_break_cost_calculations_expiry_sweep(partial; valid_until) — cron sweepuq_break_cost_calculations_idempotency(unique; idempotency_key + calculation_type) — k-5
Cross-table CHECK constraints:
chk_binding_has_component_id— k-8chk_binding_has_valid_untilchk_acknowledged_has_disclosure_id— CON-005 DB belt-and-braceschk_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 errorspnpm test:unit— formula CALC, NZ calendar, canonical hash (k-4), handler unit tests, sweep, event-publisher contractpnpm test:policy— CRE-009 CALC, CON-005 GATE, NFR-024 immutability + accuracypnpm test:contract— break_cost_acknowledged event detail shapepnpm test:integration— FR-745..748 (CI-only viaRUN_INTEGRATION=1)flyway validate— pre-deploy schema validationflyway migrate+pg_dumpschema 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 deployneeds: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 existingMOD-085-swap-rate-mirror.handoff.md(2026-05-08) and the bank-wiki-drivenmod-085-swap-rate-mirror-ruling.handoff.mdto bank-risk-platform. MOD-050-disclosure-ssm-publish.handoff.md— request MOD-050 to publish issue + lookup SSM pathsMOD-163-event-catalogue-addendum.handoff.md— registerbank.credit.break_cost_acknowledgedin wiki event catalogueMOD-163-data-model-gap.handoff.md— registercredit.break_cost_calculationsin SD05 data model