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 → emitmortgage_discharged. - FR-528 — Arrears escalation Day 1 / 7 / 30 — MOD-116 reacts to MOD-065's
bank.credit.arrears_triggeredevent 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: fromcredit.mortgage_rate_periodsactive rowreinvestment_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_dateexpires_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_keysper 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_principaldirectly. 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.