Skip to content

MOD-132 — Loan restructure & variation workflow

System: SD05 Credit Decisioning · Repo: bank-credit Status: In progress (will advance via CI handoff) ADRs: ADR-031, ADR-048, ADR-053, ADR-063, ADR-064 FRs: FR-589, FR-590, FR-591, FR-592 · NFR: NFR-007 Policies: CRE-001 GATE, CON-004 AUTO, CON-005 GATE, REP-004 LOG


1. Purpose

Orchestrates customer-initiated changes to the terms of an existing loan — term extension, repayment frequency change, fixed↔variable rate switch, repayment restructure, arrears capitalisation, partial / full early repayment. Distinct from MOD-139 hardship; this is a routine commercial product feature governed by a structured workflow.

Two regulatory gates:

  1. Creditworthiness re-assessment via MOD-029 for material variations (CRE-001 GATE). Materiality is decided by the in-module rules table (k-4 ratification — MATERIALITY_RULES_VERSION='v1.0.0').
  2. Break-cost disclosure ack via MOD-050 for any fixed-rate-period exit (CON-005 GATE). MOD-163 is the canonical break-cost calculator (k-2 — the wiki spec said MOD-116 but the actual engine is MOD-163; MOD-116 wraps MOD-163 for mortgage-specific flows).

2. Architecture

HTTP POST  request-variation                ─▶ Lambda (URL/IAM)     202 async
EventBridge bank.credit.credit_decision_made (same-bus, SQS+DLQ)
                                            ─▶ Lambda (SQS consumer)
HTTP POST  issue-break-cost-disclosure      ─▶ Lambda (URL/IAM)     synchronous (MOD-163 + MOD-050)
HTTP POST  confirm-variation                ─▶ Lambda (URL/IAM)     synchronous, CON-004 + CON-005 GATE + MOD-112 stub
HTTP POST  reject-variation                 ─▶ Lambda (URL/IAM)
HTTP POST  escalate-to-case                 ─▶ Lambda (URL/IAM)     agent-only, k-10
EventBridge Scheduler cron(0 13 * * ? *) UTC
                                            ─▶ Lambda (expire-disclosed-sweep)

7 Lambdas in total. The SQS+DLQ pair handles MOD-029 decision results (EB → SQS → consumer; ReportBatchItemFailures). DLQ retention 14 days, queue retention 4 days, visibility 60s, max receive 5.

The expire-disclosed sweep cron is DISABLED in non-prod (matches MOD-162 / MOD-163 patterns); prod enabled at 13:00 UTC = 01:00 NZST.

Cross-service integration

Upstream Pattern SSM path Behaviour
MOD-029 same-bus EB consumer + same-DB lookup of credit.credit_decisions n/a Decisions arrive on bank.credit.credit_decision_made filtered on idempotency_key.
MOD-050 SigV4 HTTP lambda /bank/{env}/customer/disclosures/{issue,lookup}-api Template id loan-variation-revised-terms-v1. Synchronous lookup on confirm; CON-004 AUTO.
MOD-053 SigV4 HTTP lambda /bank/{env}/customer/cases/create-api Agent-only escalation path (k-10). Optional dep.
MOD-163 SigV4 HTTP lambda /bank/{env}/credit/break-cost/{calculate-binding,confirm-acknowledgement}-api-endpoint Break-cost calc + ack confirm. Hash includes formula_version (k-4 on MOD-163).
MOD-112 SigV4 HTTP lambda (k-3 STUB) /bank/{env}/core/loans/recalc-variation-api (not yet published) Placeholder URL → schedule-client returns UNAVAILABLE. Non-fatal. bank-core issue #36 carries the upstream decision.

3. Data model

Two tables in the credit schema of the consolidated bank Neon DB:

credit.loan_variations — mutable workflow row

Frozen at INSERT, status + lifecycle reference fields UPDATE-able:

Column Type Purpose
variation_id uuid PK
loan_account_id uuid NOT NULL FK → credit.loan_accounts(id) k-1
variation_type text CHECK IN (6 enum values)
previous_terms / proposed_terms jsonb NOT NULL Snapshot of pre/post terms
assessment_required / break_cost_required bool Routing flags from materiality (k-4)
materiality_rules_version text NOT NULL 'v1.0.0' for v1
status text CHECK IN (requested, assessing, assessed, disclosed, confirmed, rejected, expired) k-5
credit_check_id / disclosure_id / break_cost_calculation_id / break_cost_acknowledgement_id / case_id nullable refs Lifecycle collection
schedule_regen_status text CHECK IN (PENDING,SCHEDULED,UNAVAILABLE,APPLIED) k-3 stub state
schedule_regen_ref text NULL MOD-112 ref when applied
requested_by_party_id / requested_by_type / agent_id k-11 discriminator
rejection_reason / rejection_source (CHECK 4 values) nullable k-5
idempotency_key (UNIQUE) / trace_id text k-7 + observability
expires_at timestamptz NULL Set on disclosed (5 NZ business days)
created_at / updated_at timestamptz Audit

Partial UNIQUE (k-8): UNIQUE (loan_account_id) WHERE status IN ('requested','assessing','assessed','disclosed') — only one in-flight variation per loan_account at a time.

Frozen-column trigger: fn_loan_variations_frozen_columns blocks DELETE outright and refuses UPDATE on any column other than the lifecycle set (status, ref IDs, rejection fields, expires_at, schedule_regen_status, schedule_regen_ref, acknowledged_at, updated_at).

credit.loan_variation_events — Cat 1 immutable

Append-only event log satisfying REP-004 LOG:

Column Notes
event_id uuid PK
variation_id FK → loan_variations
event_type text CHECK IN (13 values: REQUESTED, ASSESSMENT_INVOKED, ASSESSMENT_APPROVED, ASSESSMENT_DECLINED, BREAK_COST_CALCULATED, BREAK_COST_ACKNOWLEDGED, DISCLOSURE_DISPATCHED, CONFIRMED, REJECTED, EXPIRED, CASE_ESCALATED, SCHEDULE_REGEN_REQUESTED, SCHEDULE_REGEN_FAILED)
actor_party_id + actor_type (CUSTOMER/AGENT/SYSTEM)
detail jsonb Before/after deltas, reason codes, refs
trace_id text
created_at timestamptz

Cat 1 trigger trg_loan_variation_events_immutable reuses the shared credit.fn_immutable_row() from MOD-128. UPDATE and DELETE both blocked at the DB layer.

Shared credit.idempotency_keys (MOD-128 V002) is used with module_id='MOD-132', 24h TTL (k-7).


4. State machine (k-5)

requested
   ├─ assessment_required           → assessing
   ├─ break_cost_required (no asmt) → disclosed (via issue-break-cost-disclosure)
   └─ neither                       → disclosed (via consume-credit-decision: not used here; direct path)

assessing
   ├─ APPROVED + no break_cost      → disclosed (immediately by consume-credit-decision)
   ├─ APPROVED + break_cost_required → assessed  (then → disclosed via issue-break-cost-disclosure)
   └─ DECLINED / REFERRED           → rejected (terminal)

assessed
   ├─ break-cost calc + disclosure  → disclosed (via issue-break-cost-disclosure)
   └─ rejection                     → rejected (terminal)

disclosed
   ├─ confirm (ack verified)        → confirmed (terminal)
   ├─ customer rejects              → rejected (terminal)
   └─ expires_at elapses (cron)     → expired (terminal)

Three terminal states: confirmed, rejected, expired.


5. Materiality rules (k-4 — v1.0.0)

Hard-coded table in src/lib/materiality.ts. Versioned via MATERIALITY_RULES_VERSION = 'v1.0.0' constant; persisted on every variation row for audit. Bumping the version is a code release. AppConfig- driven materiality deferred to v2.

Variation type Assessment required Break cost required
term_extension if months ≥ 12 yes no
term_extension if months < 12 no no
frequency_change no no
rate_type_switch FIXED→FLOATING no yes
rate_type_switch FLOATING→FIXED no no
capitalisation_of_arrears yes no
repayment_restructure yes no
early_repayment on FIXED no yes
early_repayment on FLOATING no no

Materiality decisions are logged in the REQUESTED event row of the event log (alongside before/after terms).


6. NFR-007 latency budget

Endpoint Mode Budget (p99)
request-variation URL/IAM, 202 async 500 ms (no upstream sync calls — DB writes only)
confirm-variation URL/IAM, sync 500 ms (MOD-050 lookup + MOD-112 stub + 1 DB tx)
issue-break-cost-disclosure URL/IAM, sync 1000 ms (MOD-163 calc + MOD-050 issue) — bounded by upstreams
reject-variation / escalate-to-case URL/IAM, sync 500 ms

request-variation is async per k-13: returns 202 immediately, lets consume-credit-decision resolve the MOD-029 result out-of-band.


7. Events published

Detail-type Trigger Consumers
bank.credit.loan_variation_requested request-variation handler MOD-076 (obs), MOD-063 (notify customer of receipt)
bank.credit.loan_variation_confirmed confirm-variation handler MOD-112 (schedule regen), MOD-063 (notify), MOD-076, MOD-042 (CDC)
bank.credit.loan_variation_rejected reject-variation handler MOD-063 (notify), MOD-076

Schema version v1; all three detail shapes documented in src/types/events.ts with field-level types. Contract test in tests/contract/events.test.ts asserts every required key is emitted.

Consumed

Detail-type Source bus Filter Action
bank.credit.credit_decision_made bank-credit (same bus) downstream by idempotency_key match consume-credit-decision transitions assessing → assessed / disclosed / rejected

No cross-bus consumers (k-12).


8. k-N ratifications

# Topic Decision
k-1 FK target loan_account_id REFERENCES credit.loan_accounts(id) (wiki spec said credit.loans — stale; data-model gap handoff filed).
k-2 Break cost caller MOD-132 → MOD-163 directly (calculate-binding + confirm-acknowledgement). MOD-116 is mortgage-only. FR-591 wiki addendum filed.
k-3 MOD-112 recalc STUB + HANDOFF. SCHEDULE_REGEN_URL placeholder → UNAVAILABLE. Non-blocking. bank-core issue #36 tracks.
k-4 Materiality Hard-coded v1.0.0; AppConfig deferred to v2; materiality_rules_version persisted on every row.
k-5 State machine 7-state machine ratified. Terminal: confirmed / rejected / expired.
k-6 Component-level Deferred to v2 — v1 supports loan_accounts-level variations only.
k-7 Idempotency Mandatory on request-variation; credit.idempotency_keys, 24h TTL.
k-8 In-flight lock Partial UNIQUE (loan_account_id, status) WHERE in-flight; second submission → 403 IN_FLIGHT_VARIATION_EXISTS.
k-9 Two-table split loan_variations mutable + loan_variation_events Cat 1 immutable.
k-10 MOD-053 escalation Agent-only endpoint; no auto-escalation in v1.
k-11 Requester discriminator requested_by_party_id + requested_by_type + agent_id.
k-12 Cross-bus consumers None in v1.
k-13 NFR-007 request-variation 202 async ≤ 500 ms; confirm-variation sync ≤ 500 ms.

9. Known gaps (v2)

  1. MOD-112 schedule regen — k-3 stub until bank-core issue #36 resolves. schedule_regen_status will start landing as SCHEDULED / APPLIED once the URL goes live.
  2. Component-level variations — k-6 deferred. The variation row currently FKs to loan_accounts(id). Component-level requires a separate variation_type or a polymorphic FK; v2 design TBD.
  3. AppConfig-driven materiality — k-4 deferred. Currently a code release to change the rules.
  4. Automated case escalation — k-10 ratification restricts to agent-triggered only; future auto-escalation policy (e.g. on ambiguous decisions) is a v2 product decision.

10. SSM outputs

/bank/{env}/credit/variation/request-function-arn
/bank/{env}/credit/variation/request-api-endpoint
/bank/{env}/credit/variation/issue-break-cost-function-arn
/bank/{env}/credit/variation/issue-break-cost-api-endpoint
/bank/{env}/credit/variation/confirm-function-arn
/bank/{env}/credit/variation/confirm-api-endpoint
/bank/{env}/credit/variation/reject-function-arn
/bank/{env}/credit/variation/reject-api-endpoint
/bank/{env}/credit/variation/escalate-function-arn
/bank/{env}/credit/variation/escalate-api-endpoint
/bank/{env}/credit/variation/expire-sweep-function-arn
/bank/{env}/credit/variation/consume-credit-decision-function-arn
/bank/{env}/credit/tables/loan-variations/name              -> "credit.loan_variations"
/bank/{env}/credit/tables/loan-variation-events/name        -> "credit.loan_variation_events"

Contract package @bank-credit/mod-132-contracts@1.0.0 exposes the minimum-viable shape (MODULE_ID + ssmPath helper). Cross-repo consumers adopt incrementally as integrations materialise.