MOD-065 — Credit servicing & collections (technical design)¶
System: SD05 Credit Decisioning & Loan Platform
Repo: bank-credit
Status: In progress (this commit) → Built/Deployed via CI per ADR-053
Spec: bank-wiki/source/entities/modules/MOD-065.yaml
Related ADRs: ADR-025, ADR-031, ADR-038, ADR-042, ADR-043, ADR-048, ADR-051, ADR-052, ADR-053.
1. Purpose¶
MOD-065 fills the post-origination gap between MOD-029 (approval/acceptance) and MOD-031 (impairment provisioning). On accepted applications it creates the loan account row + amortisation schedule, calls MOD-001 to disburse principal, and lights up the servicing lifecycle. Daily it sweeps credit.repayment_schedules for missed payments, flips affected loans to ARREARS, opens collections cases, and publishes bank.credit.arrears_triggered for MOD-030/MOD-031/MOD-059 fan-out. Customers can declare hardship via a Function URL; staff resolve the case and (when approved) restructure the loan — loan_accounts is updated, schedules are regenerated with the revised total cost of credit (FR-195 / CON-004 disclosure), and any GL adjustments are posted via MOD-001. A weekly Scheduler emits the FR-196 portfolio report.
2. Architecture¶
bank.credit.application_accepted (MOD-029 follow-on; AD k-1)
▼
EB rule + SQS (DLQ x5 retry)
▼
┌─────────────────────────┐
│ consume-application- │ ──► createFacility logic
│ accepted Lambda │
└─────────────────────────┘
POST /create-facility-api-endpoint (IAM_AUTH, backfill + integration scaffold)
▼
┌──────────────────────────────────┐
│ create-facility Lambda — AD k-6 │
│ 1. MOD-001 account-create │
│ 2. INSERT credit.loan_accounts │
│ (loan_status=PENDING_DISBURSEMENT)│
│ 3. INSERT credit.repayment_schedules │
│ 4. MOD-001 post-disbursement │
│ (idempotency_key=mod065:disburse:{application_id})│
│ 5. UPDATE loan_status='ACTIVE' │
└──────────────────────────────────┘
bank.credit.repayment_received (MOD-001 — AD k-2 same-bus assumption)
▼
EB rule + SQS (DLQ x5)
▼
┌─────────────────────────┐
│ consume-repayment │ ──► apply payment to schedule;
│ Lambda │ update outstanding_principal;
└─────────────────────────┘ reset arrears on cure
EB Scheduler cron(0 16 * * ? *) UTC → daily-arrears-sweep Lambda
- mark overdue PENDING rows MISSED
- recompute arrears_days from earliest unpaid scheduled date
- apply ArrearsEvaluator → new loan_status (CRE-006 AUTO)
- upsert collections_cases; HARDSHIP_REVIEW at 30 DPD (FR-195)
- INSERT collections_actions (channel='SYSTEM', staff_id=NULL)
- publish arrears_triggered + facility_status_changed events
- SNS publish to alarm-intake on threshold crossing (CRE-002 ALERT)
POST /declare-hardship-api-endpoint (IAM_AUTH; CON-003 AUTO)
- flip case_status to HARDSHIP_REVIEW (FR-195 escalation gate)
- INSERT collections_actions (HARDSHIP_DECLARED)
- publish bank.credit.hardship_declared
POST /resolve-hardship-api-endpoint (IAM_AUTH; AD k-5)
- load case + loan; on UPHELD apply restructure
- mark current PENDING/PARTIAL schedules RESCHEDULED
- INSERT new schedule rows (sequence > last original; AD k-3)
- UPDATE loan_accounts terms (repayment_amount, term_months, rate, next_repayment_date)
- INSERT collections_actions (HARDSHIP_OUTCOME + RESTRUCTURE_APPLIED with revised-terms snapshot)
- publish hardship_resolved + facility_status_changed (when loan_status moves)
EB Scheduler cron(0 19 ? * MON *) UTC → weekly-collections-report Lambda
- portfolio summary by DPD bucket + loan_status (FR-196)
- WRITE_OFF_PENDING shown separately from DEFAULT (AD k-7)
- JSON written to s3://bank-artefacts-{env}/credit/collections-reports/{week}.json
- SNS publish to alarm-intake topic (Monday 07:00 NZST attention window)
3. Data plane¶
V001 creates four tables in FK order. Wiki SD05 model has loan_accounts and collections_cases; repayment_schedules and collections_actions are NEW per CLAUDE.md (data-model-gap handoff filed).
credit.loan_accounts(mutable): the wiki schema + two newloan_statusenum values per AD k-6/k-7:PENDING_DISBURSEMENT(initial state pre-MOD-001-disbursement) andWRITE_OFF_PENDING(proposed write-off awaiting CFO approval). Newlast_alerted_thresholdint column tracks AD k-8 boundary-crossing state.credit.repayment_schedules(NEW, mutable):idPK,loan_account_idFK,sequence_numberint,scheduled_date,scheduled_principal/_interest/_total,paid_amount,paid_at,statusenum (PENDING|PAID|PARTIAL|MISSED|RESCHEDULED). UNIQUE(loan_account_id, sequence_number)accommodates AD k-3 rescheduling — restructure marks oldPENDING/PARTIALrowsRESCHEDULEDand inserts new rows at highersequence_number. Touch trigger.credit.collections_cases(mutable): wiki schema preserved.credit.collections_actions(NEW, append-only, Cat 1 immutable): FR-194 audit log.case_idFK,action_typeenum,channelenum,staff_idnullable (AD k-4 —NULLon system-generated actions withchannel='SYSTEM'),outcome,notes,policy_refsjsonb,action_at,trace_id.trg_collections_actions_immutable(Cat 1) reusescredit.fn_immutable_row(). 7-year retention via Snowflake-side replication (AD k-9).
V001 prerequisites asserted at run time: credit.fn_immutable_row() (MOD-128), credit.idempotency_keys (MOD-128), credit.credit_applications (MOD-029).
Grants: bank_credit_app_user SELECT/INSERT/UPDATE on the three mutable tables; SELECT/INSERT only on collections_actions (immutability enforced at trigger + grant).
4. Acceptance criteria mapping¶
| FR / NFR | Mechanism | Test |
|---|---|---|
| FR-193 | daily-arrears-sweep Scheduler-triggered; ArrearsEvaluator deterministic transitions; FR-195 gate at 30 DPD | unit/arrears-evaluator + unit/declare-resolve-hardship + integration/fr/fr-193 |
| FR-194 | collections_actions Cat 1 immutable + every audit field present | integration/fr/fr-194 + integration/infra/schema-immutability |
| FR-195 | declare-hardship flips case to HARDSHIP_REVIEW; arrears-evaluator's isHardshipReviewBlocking returns true for that status |
unit/arrears-evaluator + integration/fr/fr-195 + policy/pol-con-003-auto |
| FR-196 | weekly-collections-report Lambda + Scheduler; report written to S3; SNS to alarm-intake | integration/fr/fr-196 + integration/infra/schedules-attached |
| NFR-010 | AUTO posture; source scan in policy tests forbids manual-skip tokens | policy/pol-cre-006-auto + pol-con-003-auto |
| NFR-019 | SQS DLQ + DLQ-depth alarm to alarm-intake (per consumer queue) | integration/infra/sqs-dlq |
| NFR-024 | collections_actions Cat 1 immutability trigger | integration/infra/schema-immutability |
5. Policy mode mapping¶
| Policy | Mode | Mechanism | Test |
|---|---|---|---|
| CRE-006 | AUTO | Deterministic ArrearsEvaluator; no override path; every transition publishes facility_status_changed for MOD-030/MOD-031/MOD-059 fan-out. |
policy/pol-cre-006-auto (source scan + determinism) |
| CON-003 | AUTO | Single-call hardship declaration flips case to HARDSHIP_REVIEW + publishes hardship_declared + writes HARDSHIP_DECLARED action; no manual step. |
policy/pol-con-003-auto (handler invariant + source scan) |
| CRE-002 | ALERT | Daily sweep emits one SNS alert per boundary crossing (AD k-8); last_alerted_threshold tracker prevents same-bucket re-alerts. |
policy/pol-cre-002-alert (boundary crossing assertion) |
6. SSM I/O¶
Upstream (read at deploy)¶
| Path | Owner |
|---|---|
/bank/{env}/iam/lambda/bank-credit/arn |
MOD-104 |
/bank/{env}/kms/pii/arn |
MOD-104 |
/bank/{env}/observability/adot-nodejs-arm64-arn |
MOD-076 |
/bank/{env}/observability/alarm-intake-topic-arn |
MOD-076 |
/bank/{env}/eventbridge/bank-credit/arn |
MOD-104 |
/bank/{env}/s3/artefacts/name |
MOD-104 (FR-196 report storage) |
/bank/{env}/neon/direct-host (Flyway) |
MOD-103 |
Secret bank-neon/{env}/bank_credit/app_user |
MOD-103 |
Secret bank-neon/{env}/bank_credit/bank_credit_migrate_user |
MOD-103 |
| (pending) MOD-001 account-create + post-disbursement endpoints | MOD-001 — see MOD-065-mod001-posting-integration.handoff.md |
Downstream (published)¶
/bank/{env}/credit/servicing/{create-facility,application-consumer,repayment-consumer,arrears-sweep,declare-hardship,resolve-hardship,weekly-report}-function-arn + Function URL endpoints for the three IAM-auth Lambdas + the four credit/tables/* FQN paths.
7. EventBridge¶
Consumed (same-bus on bank-credit):
- bank.credit.application_accepted — pending MOD-029 follow-on (k-1).
- bank.credit.repayment_received — emitted by MOD-001 (k-2 same-bus assumption; cross-bus follow-on if needed).
Published:
- bank.credit.arrears_triggered — already in catalogue.
- bank.credit.facility_status_changed — NEW (k-11 catalogue addition).
- bank.credit.hardship_declared — NEW.
- bank.credit.hardship_resolved — NEW.
Field lists locked exactly per AD k-11; encoded in src/types/events.ts.
8. Restructure semantics (AD k-5)¶
Four restructure types, all v1, no principal reduction:
| Type | What changes | Disclosure (FR-195 / CON-004) |
|---|---|---|
PAYMENT_PAUSE |
Term extends by pause_months; payments resume after pause; interest accrues during pause and is backloaded — total interest increases. |
New schedule shows recalculated scheduled_interest/scheduled_total per row + revised total cost. |
REDUCED_AMOUNT |
Lower repayment; term extends to fit; total interest increases. | Revised total cost on the disclosure snapshot. |
TERM_EXTENSION |
Explicit longer term; lower repayment; total interest increases. | Revised total cost on the disclosure snapshot. |
INTEREST_RATE_FREEZE |
Schedule rebuilt at current rate over remaining term; no GL impact. | Revised schedule with no rate change in v1 (rate freeze prevents future increases). |
hardship-evaluator.ts builds the new schedule rows; resolve-hardship writes them via repayment-schedule-store.markRescheduled + insertMany; the revised-terms snapshot is appended as notes on the RESTRUCTURE_APPLIED collections_actions row for the FR-195 disclosure audit.
9. AD k-7 thresholds¶
| Days past due | Action | Status |
|---|---|---|
| 1 | Soft-touch reminder (SOFT_TOUCH alert) | ARREARS |
| 7 | Second reminder | ARREARS |
| 30 | HARDSHIP_REVIEW gate (CCCFA s.55 / NCCP) — FR-195 escalation block | ARREARS + case HARDSHIP_REVIEW |
| 90 | DEFAULT | DEFAULT |
| 180 | Write-off proposal — manual CFO approval | WRITE_OFF_PENDING |
All AppConfig-overridable. The 30-day threshold has an explicit annotation in arrears-evaluator.ts warning against silent increases — CCCFA s.55 alignment requires compliance review before changing.
10. AD k-8 boundary-crossing alert¶
loan_accounts.last_alerted_threshold tracks the highest threshold the loan has been alerted on. In each sweep, an alert fires only when arrears_days crosses a threshold the loan hasn't been alerted on yet. Subsequent days within the same bucket emit no alert. This guarantees one SNS publish per boundary crossing.
11. AD k-12 schedules¶
| Schedule | UTC cron | Wall-clock NZST (winter) | Wall-clock NZDT (summer) |
|---|---|---|---|
| daily-arrears-sweep | cron(0 16 * * ? *) |
04:00 daily | 05:00 daily |
| weekly-collections-report | cron(0 19 ? * MON *) |
07:00 Monday | 08:00 Monday |
Both schedules are state=DISABLED in non-prod; production cutover flips to ENABLED.
Operational note: the weekly report runs at business-hours start so credit-ops sees the FR-196 output when they arrive. The daily sweep runs unattended; the DLQ alarm subscription must reach the on-call rotation, not just business-hours email — captured in the runbook.
12. v1 limitations¶
- MOD-001 endpoint URLs are env-var placeholders pending MOD-001's SSM publication. The create-facility flow fails at integration time until those endpoints are confirmed. See
docs/handoffs/MOD-065-mod001-posting-integration.handoff.md. bank.credit.application_acceptedis consumed but not yet published — pending MOD-029 follow-on per AD k-1. Until then the create-facility Function URL is the only entrypoint (intended for backfill + integration testing).bank.credit.repayment_receivedconsumer assumes same-bus per AD k-2. If integration testing reveals MOD-001 publishes on bank-core, file a cross-bus rule grant follow-on.- No principal reduction in v1 hardship restructures (AD k-5). v2 adds principal forgiveness with separate CFO sign-off + a different journal pattern.
- MOD-059 facility_status_changed consumer is a separate handoff (k-10). MOD-065 publishes the event; MOD-059 wires the consumer in a follow-on.
13. Decision log¶
| AD | Decision | Where |
|---|---|---|
| k-1 | application_accepted via SQS+DLQ; idempotency key=application_id; create-facility Function URL stays directly callable | infra + create-facility + consume-application-accepted |
| k-2 | Same-bus consumption of bank.credit.repayment_received on bank-credit bus; cross-bus contingency documented | infra EB rule |
| k-3 | UNIQUE (loan_account_id, sequence_number) accommodates rescheduling without schema change | V001 |
| k-4 | Cat 1 immutability on collections_actions; SYSTEM-generated actions write staff_id=NULL with channel='SYSTEM' | V001 + collections-action-store |
| k-5 | Four restructure types, no principal reduction; revised total cost recalculated before commit; CON-004 disclosure | hardship-evaluator + resolve-hardship |
| k-6 | Two-call MOD-001 sequence: account-create → INSERT loan PENDING → INSERT schedules → post-disbursement → UPDATE ACTIVE; idempotency keys at both call boundaries; loan_accounts.account_id never NULL | create-facility + mod001-client + V001 |
| k-7 | Five thresholds (1/7/30/90/180); WRITE_OFF_PENDING status; CCCFA s.55 annotation on the 30-day threshold | arrears-evaluator + V001 |
| k-8 | One alert per boundary crossing via last_alerted_threshold tracker | arrears-evaluator + V001 + daily-arrears-sweep |
| k-9 | Cat 1 immutable; Snowflake-side 7-year retention via MOD-042 | V001 |
| k-10 | Publish facility_status_changed; MOD-059 consumer is a separate follow-on handoff | event-publisher + handoff |
| k-11 | Four event payload schemas locked; three are NEW additions to wiki catalogue | src/types/events.ts + handoff |
| k-12 | UTC crons; NZST wall-clock; DISABLED in non-prod; weekend on-call call-out flagged | infra/index.ts |
| k-13 | Full v1 scope shipped together (split would create FR-195 compliance gap) | this PR |
14. CI¶
.github/workflows/mod-065.yml delegates to totara-bank/bank-platform/reusable-lambda.yml@main with has_postgres: true. Smoke test posts an empty body to the declare-hardship endpoint and asserts 422 INVALID_REQUEST.
15. Wiki updates requested at handoff time¶
Three SD05 data-model changes + four event-catalogue additions. See docs/handoffs/MOD-065-data-model-gap.handoff.md.