MOD-162 — Loan facility & component manager¶
System: SD05 | Repo: bank-credit | Status (at handoff time): In progress → Built → Deployed (CI-driven)
Purpose¶
Adds the grouping layer above credit.loan_accounts for the Flexible Loan Facility product (PRD-024). One facility envelope groups N components (one FLOATING residual + N FIXED tranches); each component is a pointer to an existing loan_account (which carries the actual principal, rate, term, status). Computes and persists the principal-weighted effective rate across active components.
13 AD ratifications¶
| AD | Decision | Source |
|---|---|---|
| k-1 | Layered model: loan_facility_components.loan_account_id FK; loan_accounts is the leaf settled unit (no schema changes) |
Orchestrator ruling |
| k-2 | One loan_account per component; all share originating application_id from MOD-029 |
Confirmed |
| k-3 | FLOATING-specific fields (rate_benchmark, benchmark_margin) live on loan_facility_components; loan_accounts unchanged |
Confirmed |
| k-4 | MOD-162 inserts into credit.loan_accounts directly (same repo, atomic with component insert) |
Confirmed |
| k-5 | Append-only component rows; current state = latest by (facility_id, component_seq, created_at DESC); terminal states Cat 1 immutable via fn_component_terminal_immutable |
Confirmed |
| k-6 | Effective rate Σ(principal × rate) / Σ(principal) over ACTIVE components; persisted in same txn as component write |
Confirmed |
| k-7 | MOD-112 consumes bank.credit.component_created filtered on component_type='FIXED'. Event payload includes component_type so MOD-112 doesn't have to join. No direct HTTP call. |
Override from proposed |
| k-8 | daily-maturity-sweep at UTC 22:00 = 10:00 NZST; DISABLED in non-prod | Confirmed |
| k-9 | reprice-floating = SQS + EB cross-bus consumer for bank.core.rate_changed; cross-bus IAM grant via MOD-104 handoff; deploy non-blocking |
Confirmed |
| k-10 | Consume bank.credit.application_accepted filtered on product_type='FLEXIBLE_FACILITY'. MOD-029 patch in this PR adds the event emission (it didn't publish it before). FLEXIBLE_FACILITY is a new product_type enum value. |
Override from proposed |
| k-11 | Idempotency keys: mod162:facility:{credit_decision_id}, mod162:component:{idempotency_key}, mod162:maturity-sweep:{YYYY-MM-DD}, mod162:reprice:{event_id} |
Confirmed |
| k-12 | SELECT FOR UPDATE on loan_facilities row inside create-component transaction; chk_component_floating_metadata CHECK constraint is the belt-and-braces second layer |
Confirmed |
| k-13 | @bank-credit/mod-162-contracts@1.0.0 day-1 via scripts/contract-add.py |
Confirmed |
Architecture¶
bank.credit.application_accepted (from MOD-029)
│ filtered to product_type='FLEXIBLE_FACILITY'
▼
┌──────────────────────────┐
│ create-facility (SQS) │ creates: facility + initial FLOATING loan_account + initial FLOATING component
└──────────────────────────┘
│
▼ (Function URL / IAM)
┌──────────────────────────┐
│ create-component │ allocates principal from FLOATING → creates new FIXED loan_account + component
└──────────────────────────┘ k-12: SELECT FOR UPDATE; k-6: effective rate recompute
│
▼ (Function URL / IAM, called by MOD-163 / back-office)
┌──────────────────────────┐
│ update-component-status │ ACTIVE → PREPAID|CANCELLED; principal returns to FLOATING
└──────────────────────────┘
bank.core.rate_changed (from MOD-006, CROSS-BUS k-9)
│
▼ (SQS consumer)
┌──────────────────────────┐
│ reprice-floating │ updates FLOATING loan_account.interest_rate; recomputes effective
└──────────────────────────┘
EventBridge Scheduler cron(0 22 * * ? *) UTC = 10:00 NZST
│
▼
┌──────────────────────────┐
│ daily-maturity-sweep │ FIXED components reaching maturity → MATURED; principal absorbed by FLOATING
└──────────────────────────┘
Outbound events (4, all NEW):
bank.credit.facility_created
bank.credit.component_created (k-7: payload includes component_type so MOD-112 filters FIXED)
bank.credit.component_status_changed
bank.credit.effective_rate_changed
Data model — per k-1 layered¶
credit.loan_facilities (NEW; mutable)¶
Facility envelope. UNIQUE on credit_decision_id — one facility per APPROVE decision.
| col | type | notes |
|---|---|---|
| id | uuid PK | |
| customer_id | uuid NOT NULL | aka party_id |
| credit_decision_id | uuid FK credit_decisions(id) UNIQUE | |
| application_id | uuid FK credit_applications(id) | |
| facility_limit | numeric(18,2) > 0 | |
| currency | char(3) | |
| expiry_date | date | |
| jurisdiction | char(2) NZ/AU | |
| effective_interest_rate | numeric(8,6) | k-6 — recomputed on every component event |
| status | text (ACTIVE/EXPIRED/CANCELLED) | |
| master_agreement_ref | text NULL |
credit.loan_facility_components (NEW; append-only, terminal-state Cat 1 immutable)¶
| col | type | notes |
|---|---|---|
| id | uuid PK | |
| facility_id | uuid FK loan_facilities | |
| component_seq | int ≥ 1 | latest row at this seq is the current state (k-5) |
| loan_account_id | uuid FK loan_accounts(id) | k-1 leaf pointer |
| component_type | text CHECK (FIXED, FLOATING) | |
| status | text CHECK (PENDING, ACTIVE, MATURED, PREPAID, CANCELLED) | |
| rate_benchmark | text NULL CHECK (BKBM, BBSY) | k-3 FLOATING-only |
| benchmark_margin | numeric(8,6) NULL | k-3 FLOATING-only |
| trigger_reason | text CHECK (7 values) | |
| previous_component_id | uuid NULL FK self | rollover audit chain |
| model_version | text DEFAULT 'v1.0.0' | |
| trace_id, created_at |
Cross-column constraint chk_component_floating_metadata: FLOATING requires rate_benchmark + benchmark_margin; FIXED forbids them.
Cross-table changes¶
credit.credit_applications.product_type CHECK extended: 5 → 6 values (adds FLEXIBLE_FACILITY).
credit.loan_accounts.product_type CHECK extended: same.
Policies satisfied¶
- CRE-002 AUTO — FR-741 limit enforcement. DB-layer (
chk_component_floating_metadata+ SELECT FOR UPDATE) + handler-layer (LIMIT_EXCEEDEDfromcreateFixedComponentAtomically). - CON-004 AUTO — Principal-weighted effective rate recomputed + persisted in the same transaction as every component lifecycle event. FR-743 lock-in via
computeEffectiveRateSQL. - REP-004 AUTO — Component lifecycle events feed downstream (MOD-030 IFRS 9 on facility_created; MOD-031 ECL EIR on effective_rate_changed; CDC on all 4).
- NFR-024 LOG — Terminal-state component rows immutable via
fn_component_terminal_immutable.
MOD-029 patch shipped in same PR¶
The k-10 ruling requires MOD-029 to publish bank.credit.application_accepted. The current MOD-029 doesn't emit this. This PR additively patches MOD-029:
event-publisher.ts— addsApplicationAcceptedDetailinterface +publishApplicationAcceptedmethodaccept-application.ts— emits the event afterapplicationStore.setStatus(...,"ACCEPTED"); failure is non-fatal (logged, ack continues)AcceptHandlerDeps— addseventPublisher+fetchApprovedAmountdependencies- Test suites (
accept-handler.test.ts,pol-con-004-gate.test.ts) — updated to provide the new deps
The patch is backwards-compatible: no breaking change to MOD-029's API surface; the new event is purely additive.
Filed handoffs¶
MOD-162-complete.handoff.md— wiki: status In progress; AD set k-1..k-13; 13 outbound + 2 inbound events.MOD-162-data-model-gap.handoff.md— wiki: 2 new tables + extended product_type enum + 4 new events +bank.credit.application_acceptedevent from MOD-029.MOD-104-cross-bus-grant-bank-core.handoff.md— bank-platform: grant BankCreditRole events:PutRule/PutTargets on the bank-core bus ARN for the rate_changed consumer (k-9 non-blocking deploy pattern).MOD-029-application-accepted-event.handoff.md— bank-wiki: catalogue addendum for the newbank.credit.application_acceptedevent + theFLEXIBLE_FACILITYproduct_type enum extension.