Skip to content

MOD-116 — Mortgage servicing engine

System: SD05 — Credit Decisioning & Loan Platform Repo: bank-credit Status (at handoff time): In progress → Built → Deployed (CI-driven)

Purpose

Post-drawdown mortgage lifecycle engine. FR-525/526/527/528:

  • FR-525 — Fixed rate state machine (VARIABLE → FIXED → EXPIRING → EXPIRED → re-fix); 90/60/30 day notifications; auto-revert on expiry.
  • FR-526 — Break cost calculation max(0, (contract_rate − reinvestment_rate) × outstanding_balance × remaining_fixed_days / 365). Disclosure + customer acceptance required before early repayment is processed (CON-005 GATE).
  • FR-527 — Discharge orchestration: break cost (if fixed) → arrears check (MOD-065) → final repayment via MOD-001 (posting_type='PAYMENT') → security release via MOD-115 → discharge notice (MOD-073 stub) → mark PAID_OFF → emit mortgage_discharged.
  • FR-528 — Arrears escalation Day 1 / 7 / 30 — MOD-116 reacts to MOD-065's bank.credit.arrears_triggered event filtered for mortgages.

Architecture

   elect-rate (URL/IAM)        ──────┐
   request-break-cost-quote    ──────┤    credit.mortgage_rate_periods
   accept-break-cost           ──────┤    credit.mortgage_notifications
   discharge-mortgage          ──────┤    credit.break_cost_disclosures
                                     │           ↑↓
                                     ▼           ↓
                              shared services + stores
                                     ↑           │
   daily-fixed-rate-sweep            │           │
   cron 19 UTC = 07:00 NZST          │           │
                                     │           │
   MOD-065 emits bank.credit.        │           │
   arrears_triggered ────────►SQS────┘           │
   discharge orchestration calls:                │
     MOD-001 PAYMENT (k-9 corrected)             │
     MOD-115 discharge-property-security         │
     MOD-007/050/063/073 stubs (v1)              │

   Outbound (6 events, all NEW):
     bank.credit.mortgage_rate_elected
     bank.credit.mortgage_rate_expired
     bank.credit.fixed_rate_expiring     (90/60/30)
     bank.credit.break_cost_disclosed
     bank.credit.mortgage_discharged
     bank.credit.mortgage_notification_dispatched   (k-7 + k-10 stub)

FRs satisfied

FR Mode Implementation
FR-525 LOG/ALERT rate-period-store state machine + daily-fixed-rate-sweep cron + 3 notification types
FR-526 GATE break-cost-calculator + break-cost-store with expires_at (k-5) + accept-break-cost handler enforces validity window
FR-527 GATE/LOG discharge-mortgage saga: arrears block + break cost check + MOD-001 PAYMENT (k-9) + MOD-115 release + PAID_OFF
FR-528 ALERT consume-repayment-missed filters product_type=MORTGAGE on MOD-065's arrears_triggered; dispatches Day 1/7/30 notifications

Data model

credit.mortgage_rate_periods (NEW; mutable)

State machine enforced via PARTIAL UNIQUE on (loan_account_id) WHERE status='active'. Single active row per loan; supersedeAndInsert flips the predecessor to status='superseded' and inserts the new active row in one transaction.

column type notes
id uuid PK
loan_account_id uuid NOT NULL FK loan_accounts(id)
rate_type text CHECK (variable, fixed)
rate_pct numeric(8,5) decimal-as-percentage e.g. 0.06250
start_date date NOT NULL
end_date date NULL NULL for variable; required for fixed
status text CHECK (active, expired, superseded)
elected_at, disclosed_at timestamptz NULL populated on customer election (CON-004 — disclosed_at)
staff_id text NULL back-office election trail
jurisdiction char(2) NZ/AU
trace_id uuid NOT NULL

Constraints: - chk_mortgage_rate_period_dates: end_date IS NULL OR end_date > start_date - chk_mortgage_rate_type_dates: fixed must have end_date; variable must not

credit.mortgage_notifications (NEW; mutable)

UNIQUE NULLS NOT DISTINCT on (loan_account_id, notification_type, period_id) — k-5 enforces "fire once per loan/type/period" for both rate-period notifications (period_id set) and arrears/discharge notifications (period_id NULL).

column type notes
id uuid PK
loan_account_id uuid NOT NULL FK
period_id uuid NULL FK rate_periods NULL for arrears/discharge
notification_type text CHECK 10 enum values
sent_at, acknowledged_at timestamptz NULL sent_at set on dispatch (k-10 stub); acknowledged_at set when MOD-063 ships
trace_id uuid NOT NULL

credit.break_cost_disclosures (NEW; mutable)

UNIQUE on idempotency_key for replay safety. k-5 amendment: expires_at NOT NULL captures quote validity window (default 5 business days, AppConfig override).

column type notes
id, loan_account_id, period_id uuid
idempotency_key text UNIQUE NOT NULL caller-supplied
contract_rate, reinvestment_rate numeric(8,5)
outstanding_balance numeric(18,2)
remaining_days int ≥ 0
break_cost_amount numeric(18,2)
currency char(3)
jurisdiction char(2)
model_version text break-cost-v1.0.0+swap-rate-v1.0.0
disclosed_at timestamptz NOT NULL
expires_at timestamptz NOT NULL k-5 amendment
accepted_at timestamptz NULL set on accept-break-cost
chk_break_cost_expires_after_disclosed CHECK expires_at > disclosed_at

Lambdas

name trigger purpose
elect-rate Function URL (AWS_IAM) FR-525 customer/agent rate election
request-break-cost-quote Function URL (AWS_IAM) FR-526 quote
accept-break-cost Function URL (AWS_IAM) FR-526 GATE accept
discharge-mortgage Function URL (AWS_IAM) FR-527 saga
daily-fixed-rate-sweep EB Scheduler cron(0 19 * * ? *) UTC = 07:00 NZST FR-525 90/60/30 + auto-revert
consume-repayment-missed SQS+EB rule on bank.credit.arrears_triggered FR-528 mortgage filter

Methodology

Break cost (FR-526)

break_cost = max(0, (contract_rate − reinvestment_rate) × outstanding_balance × remaining_fixed_days / 365)
  • contract_rate: from credit.mortgage_rate_periods active row
  • reinvestment_rate: swap rate from MOD-085 stub (k-8 — locked v1 defaults: NZ 1Y..5Y = 0.045..0.043; AU 1Y..5Y = 0.041..0.042)
  • remaining_fixed_days: calendar days from intended_repayment_date to end_date
  • expires_at: disclosed_at + 5 business days (k-5 amendment)

Saga (FR-527 k-9 PAYMENT correction)

1. Receive request (idempotency_key)
2. Calculate break cost if active fixed rate
3. Check loan_accounts.arrears_days — block if > 0
4. Post final repayment via MOD-001 — posting_type='PAYMENT', reference='MORTGAGE_DISCHARGE'
5. Call MOD-115 discharge-property-security with posting_id
6. Stub MOD-073 discharge notice (notification log + SNS + event)
7. UPDATE loan_accounts SET loan_status='PAID_OFF'
8. Emit bank.credit.mortgage_discharged

If steps 5-8 fail post-step-4, SNS-alarm + manual remediation (best-effort saga; MOD-001 + MOD-115 idempotent on the same posting_id correlation).

Arrears (FR-528)

MOD-065 owns the broader DPD state machine and emits bank.credit.arrears_triggered at thresholds 1/7/30/90/180. MOD-116: - Filters product_type=MORTGAGE - Filters days_past_due ∈ {1, 7, 30} - Dispatches mortgage-specific notification (Day 7 hardship-flag dispatch is logged but no actual flag is set on customer profile until MOD-007 ships) - Day 30 notification fires; MOD-065's HARDSHIP_REVIEW state transition handles the formal collections handoff

SSM outputs

/bank/{stage}/credit/mortgage/elect-rate-api-endpoint
/bank/{stage}/credit/mortgage/request-break-cost-quote-api-endpoint
/bank/{stage}/credit/mortgage/accept-break-cost-api-endpoint
/bank/{stage}/credit/mortgage/discharge-api-endpoint
/bank/{stage}/credit/mortgage/{*}-function-arn (×6)
/bank/{stage}/credit/tables/{mortgage-rate-periods,mortgage-notifications,break-cost-disclosures}/name

Idempotency

  • Function URLs: credit.idempotency_keys per request idempotency_key.
  • Daily sweep: mod116:fixed-rate-sweep:{YYYY-MM-DD}.
  • SQS consumer: keyed by inbound event_id.
  • DB-level: PARTIAL UNIQUE (loan_account_id) WHERE status='active' on rate_periods; UNIQUE NULLS NOT DISTINCT on notifications; UNIQUE on idempotency_key for break cost.

Risks + open items (unbuilt dependencies)

  • MOD-005 (interest accrual) — v1 reads outstanding_principal directly. Once MOD-005 ships, repayment-schedule recompute on rate change becomes a follow-on.
  • MOD-006 (rate change propagation) — out of v1 scope (variable-rate updates).
  • MOD-063 (notification orchestration) — v1 audit-only mode; rows in mortgage_notifications + SNS to MOD-076. Real customer messages await MOD-063.
  • MOD-007 (customer profile / hardship flag) — v1 stub via env var for customer_deposit_account_id; Day 7 hardship flag is logged but not set.
  • MOD-050 (disclosure delivery) — v1 stub: caller asserts disclosure via disclosure_acknowledged=true. Real disclosure validation awaits MOD-050.
  • MOD-073 (discharge notice) — v1 stub: notification log + event.
  • MOD-085 (swap rate feed) — v1 locked defaults; v2 Snowflake → Postgres write-back (handoff to bank-risk-platform).
  • MOD-112 (amortisation schedule) — overlap with MOD-065's existing repayment-schedule-builder. Filed clarification handoff.