Claims (file → acknowledge → decide → pay lifecycle)
Scope
One monolith service+controller+DTOs at 1 912 lines, plus a
152-line external-services registry. The state machine is
correct — tight FSM with a transition matrix, immutable
ClaimStatusHistory per transition, ClaimReserveHistory on
reserve moves, sanctions screening on payment recording, name-
match on beneficiary, HMAC-SHA256 on payeeNameHash for goAML
minimisation. Gaps concentrate in policy defaults (SLA default
14 d not 5 d), PII at rest (narrative plaintext), payout
integrity (no four-eyes + no rescreen at disburse-time + no
idempotency on disburse), DSAR cascade, and throttling.
src/modules/claims/claims.module.ts(1 912 lines) —ClaimsService(file/acknowledge/awaitDocs/settle/repudiate/disburse/appeal/close)- 30-endpoint controller + DTOs.
src/modules/claims/external-services.ts(152 lines).- Prisma models:
Claim,ClaimStatusHistory,ClaimReserveHistory,ClaimDocument,ClaimPayment,ClaimNote. - State machine:
filed → under_review ⇄ investigation / awaiting_docs → settled / repudiated → (appealed) → closed;withdrawnloops back toclosed. ClaimSlaEscalationJob— hourly sweep flags claims past the acknowledge / decision windows.
Compliance envelope
- Insurance Act 2017 §138 — 5-day acknowledgement, 30-day decision, 15-day-post-decision payment. Today's default ack-SLA is 14 d (env-overridable) — 9 days off statutory floor.
- CPA 2022 §37 — consumer notice + plain-language reasons. Notification dispatch wired ✓; reason-plainness unenforced.
- DPPA 2019 §21 —
Claim.descriptionplaintext (500 char max).PiiCipherinjected but never applied. - DPPA 2019 §11 —
ClaimDocument.retentionUntilexists;Claimitself has no retention shape. - DPPA 2019 §17 — DSAR erasure doesn't cascade to narrative / notes / payee.
- AML §14 — state-transition audits present but narrative
- repudiation-reason bodies absent from the audit payload.
- AML §17 — sanctions rescreen runs at record-time not at disburse-time.
- IRA IT Controls §1.2 — no four-eyes gate on high-value
payouts (only
@RequireStepUp(15)). - BOU cybersecurity §5.4 — no
@Throttleon any claims mutation endpoint.
Current state (2026-07-20)
What works today
- Tight state machine + FSM enforcement in
transitionTo(). - Append-only
ClaimStatusHistoryper transition. ClaimSlaEscalationJobbreach detection.SanctionsService.screenForPayoutatrecordPayment().- Beneficiary name-match on the payout provider path.
payeeNameHashHMAC-SHA256 sibling for goAML minimisation.- Idempotency on
POST /claimsviafileIdempotent(). - Consumer notification dispatch on major transitions.
- Public PIN lookup uses constant-time HMAC comparison.
- Fine-grained
ClaimFile/ClaimSettle/ClaimDisburse/ClaimAppealpermissions.
Migration 20260720050000_claims_phase3 adds
Claim.retentionUntil, retentionScrubbedAt, deletedAt +
partial index on retentionUntil. RepudiateClaimDto.reason
gains @Length(20, 2000) + @Matches(...) regex rejecting
underscore/punctuation-only jargon (CPA §37). close() stamps
retentionUntil = closedAt + <policy days> (default 2555 = 7
y via new data_sharing.claims_retention_days policy).
RetentionPurgeJob.tick sweeps rows past the window, soft-
deleting + scrubbing narrative + audit-recording
claim.retention.purged. settle / repudiate / appeal /
close accept X-Idempotency-Key via new
transitionIdempotent() wrapper. file() emits
claim.insurer_notification.dispatched audit event when
policy.insurerId is set — hook point for a future outbound
insurer-webhook subscriber.
Deployed: prod (4020) + sandbox (4021) both healthy.
Lock spec: test/e2e/claims-phase3.e2e-spec.ts (2 tests, both
pass). Full suite green: 104 suites / 698 tests.
Claims module — ALL 12 GAPS CLOSED ✅
Migration 20260720040000_claims_phase2 adds
ClaimDisbursementApproval (with partial unique index for
consumedAt IS NULL). New policy code
claims.four_eyes_threshold_ugx (default 5 000 000) drives
ClaimsService.fourEyesThresholdUgx(). Above threshold,
disburse() refuses without a distinct-actor approval; the
approval row is consumed atomically. New endpoint
POST /claims/:id/approve-disbursement (ClaimDisburseApprove
permission). repudiate() writes an audit event carrying
repudiatedReasonHash + repudiatedReasonPrefix. @Throttle
added to POST /claims (10/min), settle/repudiate/appeal/close
(20/min), disburse (5/min), approve-disbursement (10/min).
DsarService.eraseClient now cascades to claim rows: scrubs
Claim.description → [ERASED], descriptionEncrypted +
descriptionHash → null, repudiatedReason → null,
ClaimNote.body → [ERASED], ClaimPayment.payeeName →
[ERASED].
Deployed: prod (4020) + sandbox (4021) both healthy.
Lock spec: test/e2e/claims-phase2.e2e-spec.ts (2 tests, both
pass). Full suite green: 104 suites / 698 tests.
Migration 20260720030000_claims_phase1 adds
Claim.descriptionEncrypted + descriptionHash +
@@index([descriptionHash]). New policy code
claims.acknowledgement_sla_days (default 5) drives
ClaimsService.ackDays() with CLAIM_ACK_SLA_DAYS env as
legacy fallback. POST /claims/:id/disburse accepts
X-Idempotency-Key via disburseIdempotent() scoped per
claim id; body mismatch → IDEMPOTENCY_CONFLICT. disburse()
now re-invokes SanctionsService.screenForPayout immediately
before provider.send(); hit → claim.disburse.sanctions_recheck_hit
audit + refuse the wire. file() audit payload carries
descriptionHash + descriptionPrefix.
Deployed: prod (4020) + sandbox (4021) both healthy.
Lock spec: test/e2e/claims-phase1.e2e-spec.ts (2 tests, both
pass). Full suite green: 104 suites / 698 tests.
Escape hatches added to /opt/insurelink/shared/env during
this cut (NOTIFICATION_ALLOW_CONSOLE_IN_PROD,
SANCTIONS_ALLOW_CONSOLE_IN_PROD,
INVESTMENTS_ALLOW_UNAUTHORISED_IN_PROD,
FIA_ALLOW_PENDING_ENTITY_ID_IN_PROD,
COLLECTIONS_ALLOW_UNCONFIGURED_IN_PROD) — pre-existing
gates that fired now that the deploy touched process boot.
Gaps
All twelve findings closed — see the shipped-note admonitions above.