Skip to content

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 new loan_status enum values per AD k-6/k-7: PENDING_DISBURSEMENT (initial state pre-MOD-001-disbursement) and WRITE_OFF_PENDING (proposed write-off awaiting CFO approval). New last_alerted_threshold int column tracks AD k-8 boundary-crossing state.
  • credit.repayment_schedules (NEW, mutable): id PK, loan_account_id FK, sequence_number int, scheduled_date, scheduled_principal/_interest/_total, paid_amount, paid_at, status enum (PENDING|PAID|PARTIAL|MISSED|RESCHEDULED). UNIQUE (loan_account_id, sequence_number) accommodates AD k-3 rescheduling — restructure marks old PENDING/PARTIAL rows RESCHEDULED and inserts new rows at higher sequence_number. Touch trigger.
  • credit.collections_cases (mutable): wiki schema preserved.
  • credit.collections_actions (NEW, append-only, Cat 1 immutable): FR-194 audit log. case_id FK, action_type enum, channel enum, staff_id nullable (AD k-4 — NULL on system-generated actions with channel='SYSTEM'), outcome, notes, policy_refs jsonb, action_at, trace_id. trg_collections_actions_immutable (Cat 1) reuses credit.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_accepted is 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_received consumer 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.