Disputes (Dispute + DisputeNote lifecycle)
Scope
Two models and one module that together carry the counterparty dispute surface:
disputes/—Dispute(open → triaged → upheld/rejected/ escalated/withdrawn → closed) +DisputeNote(append-only internal/both-visibility comment stream). Nine controller endpoints indisputes.module.ts, one service class, one permission set (DisputeRead,DisputeReadOwn,DisputeRaise,DisputeTriage,DisputeResolve,DisputeWithdraw,DisputeEscalate,DisputeNoteWrite).- The two parallel dispute surfaces on referrals + commissions
(
ReferralStatus.disputed,CommissionStatus.disputed) that currently live outside theDisputemodel. In-scope for gap analysis because a "dispute" as understood by a regulator spans both — the split is an accident of implementation.
Bordereau also carries a POST /bordereau/:id/dispute endpoint but
its scope is the bordereau row (§128 return), not consumer-facing;
it stays out of scope for this review.
Compliance envelope
- IRA Insurance Act 2017 §129 — insurers must operate a dispute-resolution mechanism with defined SLAs; unresolved disputes escalate to the IRA. Silent skew on triage / resolution timelines is a finding on any inspection.
- CPA 2022 §37 — the consumer (client / agent as policyholder-agent) must be notified when a dispute is received, triaged, and resolved — silence is a §37 breach on its own.
- CPA 2022 §37-39 — cooling-off window (14 days from policy effective date). A dispute raised because a policy was cancelled during the window is legally distinct from a mid-term dispute: full refund is mandatory, no cancellation penalty.
- AML Act 2013 §13 — disputes that resolve into money movement (commission clawback, premium refund, claim adjustment) are value transfers → the raising party must be subject to CDD screening. A blocked party filing a payout-triggering dispute is an AML gap.
- DPPA 2019 §11 + §21 — free-text
reason,resolutionNote, andDisputeNote.bodyfields carry PII (client names, phone numbers, national IDs quoted in the complaint text). They are never scrubbed today.
Current state (2026-07-18)
Module footprint
src/modules/disputes/disputes.module.ts— 395 lines. DTOs + service + controller + module in one file.- Prisma models:
Dispute(lines 1551-1578, 17 columns + 3 indexes) +DisputeNote(lines 1580-1592, 5 columns + 1 index) +DisputeSubjectTypeenum (referral,commission,payment,claim) +DisputeStatusenum (open,triaged,upheld,rejected,withdrawn,escalated,closed). - Cross-module callers: none.
DisputesServiceis self-contained —POST /referrals/:id/disputeandPOST /commissions/:id/disputedo NOT callDisputesService.raise(); each maintains its owndisputedstatus column on the parent entity.
What works today
- Transition matrix is enforced:
ALLOWEDconstant gates every status flip, so out-of-order transitions (open → resolved,closed → triaged) are refused with a testable error message. - Owner-scope read is correctly wired via
DisputeReadOwn— non-privileged callers see only disputes they raised, plus internal notes are filtered from the response payload. - Every mutation writes an audit event:
dispute.raise,dispute.triaged,dispute.upheld/dispute.rejected,dispute.escalated,dispute.withdrawn,dispute.closed,dispute.note.write. Chain-of-custody exists for lifecycle reconstruction. - Subject existence is validated at raise time: the referral /
commission / payment / claim referenced by
subjectIdmust exist, else the raise refuses with a validation error.
Gaps
All 12 gaps closed. See shipped-notes below.
Release disputes-p1-20260718-0938. Closes gaps 1–4:
- gap 1 —
DisputesService.raise()callsSanctionsService.screenForPayout('user', raisedBy, ctx)before writing. Block → refuse + emitdispute.raise.sanctions_blockaudit event. Screening id stored onDispute.metadata.intakeScreeningId. - gap 2 — Migration
20260718110000_disputes_phase1addsretentionScrubbedAtto bothdisputes+dispute_notes. Newdata_sharing.disputes_retention_dayspolicy (default 2555).RetentionPurgeJobscrubsreason,resolutionNote, and every linked notebodyto<erased>past window + emitsdispute.retention.pii_scrubbed. - gap 3 —
ResolveDisputeDtoaccepts an optionalmoneyMovementpayload. When present + outcome=upheld, resolve wraps the Dispute update +DisputeResolutionLedgerinsert in a transaction. Unique constraint on(disputeId)gives DB-level idempotency — duplicate calls hitP2002and raiseIDEMPOTENCY_CONFLICT. Emitsdispute.resolve.money_movementlinkingDispute.id→ ledger row id. - gap 4 —
ReferralsService.dispute()+CommissionsService.dispute()now post-transition write-through intoDisputesService.raise({subjectType, subjectId, reason}). Both services accept@Optional() DisputesService;ReferralsModule+CommissionsModuleimportDisputesModule. Failures on the central write are logged silently — the parent-side state change stays authoritative.
Locked in by src/modules/disputes/disputes-phase1.spec.ts
(5 cases). Test suite: 493/493 (71 suites).
Release disputes-p2-20260718-0948. Closes gaps 5–8:
- gap 5 — Migration
20260718120000_disputes_phase2addstriageDueAt,triageBreachedAt,resolutionDueAt,resolutionBreachedAtcolumns + indexes.DisputesService.raise()populates the due-columns fromdisputes.triage_sla_hours(default 120) +disputes.resolution_sla_hours(default 720). NewDisputeSlaEscalationJobruns hourly, stamps breach columns once (idempotent), emitsdispute.sla.triage_breached/resolution_breached/sweep, and fans out to compliance officers viaRoleFanoutService. - gap 6 —
DisputesServiceinjects@Optional() NotificationDispatchService. Every lifecycle transition (triage/upheld/rejected/withdrawn/escalated/closed, plus the money-movement upheld path) dispatches adispute.<status>template to the raiser. Best- effort — template lookup failures are swallowed rather than rolled back so a missing template doesn't block ops. - gap 7 — At raise time,
detectCoolingOff()walksreferral → Policy.cancelledInCoolingOff(andcommission → Referral → Policyfor commission disputes). On hit,Dispute.metadata.coolingOff=trueis stamped.resolve()in the money-movement path refuses anykindother thanpayment_refundon a cooling-off dispute to protect the CPA §37-39 full-refund guarantee. - gap 8 —
DisputesService.addNote()now takesuserand refusesvisibility='internal'unless the caller holdsDisputeRead. Refused attempts emitdispute.note.internal_deniedwith attempted-by + role captured in metadata.
Locked in by src/modules/disputes/disputes-phase2.spec.ts
(7 cases). Test suite: 500/500 (72 suites).
Release disputes-p3-20260718-0958. Closes gaps 9–12:
- gap 9 —
DisputesServicenow exposes idempotent wrappers (raiseIdempotent,triageIdempotent,resolveIdempotent,withdrawIdempotent,escalateIdempotent,closeIdempotent,addNoteIdempotent). Same-body + same-key replays the memoised response via the sharedIdempotencyService(per-endpoint scope). The controller passes theIdempotency-Keyheader through every mutation. - gap 10 —
@Throttleon every mutation: raise + subject-intake endpoints 10/min, lifecycle mutations 20/min, note write 30/min. - gap 11 — Migration
20260718130000_disputes_phase3adds structuredDispute.escalationTarget(soft enum:ira,pdpo,ombudsman,court,internal_review) +Dispute.escalationReferencetext columns.EscalateDisputeDtorequires both. Thedispute.escalatedaudit event carries the structured target + reference instead of a free-text metadata blob. - gap 12 — New dedicated intake endpoints
POST /disputes/claims/:claimId+POST /disputes/payments/:paymentIdroute to the sameraiseIdempotent()path withsubjectType=claim/payment. Both are throttled + idempotent per gaps 9 + 10 and gated byDisputeRaise.
Locked in by src/modules/disputes/disputes-phase3.spec.ts
(4 cases). Test suite: 504/504 (73 suites).
All 12 gaps closed. Disputes module is audit-clean against IRA §129, CPA §37-39, AML §13 + §14, and DPPA §11 + §21.
Phased implementation plan
Complete — every phase shipped.