Skip to content

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_EXCEEDED from createFixedComponentAtomically).
  • CON-004 AUTO — Principal-weighted effective rate recomputed + persisted in the same transaction as every component lifecycle event. FR-743 lock-in via computeEffectiveRate SQL.
  • 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 — adds ApplicationAcceptedDetail interface + publishApplicationAccepted method
  • accept-application.ts — emits the event after applicationStore.setStatus(...,"ACCEPTED"); failure is non-fatal (logged, ack continues)
  • AcceptHandlerDeps — adds eventPublisher + fetchApprovedAmount dependencies
  • 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

  1. MOD-162-complete.handoff.md — wiki: status In progress; AD set k-1..k-13; 13 outbound + 2 inbound events.
  2. MOD-162-data-model-gap.handoff.md — wiki: 2 new tables + extended product_type enum + 4 new events + bank.credit.application_accepted event from MOD-029.
  3. 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).
  4. MOD-029-application-accepted-event.handoff.md — bank-wiki: catalogue addendum for the new bank.credit.application_accepted event + the FLEXIBLE_FACILITY product_type enum extension.