Skip to main content

Disputes (Dispute + DisputeNote lifecycle)

Scope

Two models and one module that together carry the counterparty dispute surface:

  1. disputes/Dispute (open → triaged → upheld/rejected/ escalated/withdrawn → closed) + DisputeNote (append-only internal/both-visibility comment stream). Nine controller endpoints in disputes.module.ts, one service class, one permission set (DisputeRead, DisputeReadOwn, DisputeRaise, DisputeTriage, DisputeResolve, DisputeWithdraw, DisputeEscalate, DisputeNoteWrite).
  2. The two parallel dispute surfaces on referrals + commissions (ReferralStatus.disputed, CommissionStatus.disputed) that currently live outside the Dispute model. 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, and DisputeNote.body fields 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) + DisputeSubjectType enum (referral, commission, payment, claim) + DisputeStatus enum (open, triaged, upheld, rejected, withdrawn, escalated, closed).
  • Cross-module callers: none. DisputesService is self-contained — POST /referrals/:id/dispute and POST /commissions/:id/dispute do NOT call DisputesService.raise(); each maintains its own disputed status column on the parent entity.

What works today

  • Transition matrix is enforced: ALLOWED constant 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 subjectId must exist, else the raise refuses with a validation error.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-18)

Release disputes-p1-20260718-0938. Closes gaps 1–4:

  • gap 1 — DisputesService.raise() calls SanctionsService.screenForPayout('user', raisedBy, ctx) before writing. Block → refuse + emit dispute.raise.sanctions_block audit event. Screening id stored on Dispute.metadata.intakeScreeningId.
  • gap 2 — Migration 20260718110000_disputes_phase1 adds retentionScrubbedAt to both disputes + dispute_notes. New data_sharing.disputes_retention_days policy (default 2555). RetentionPurgeJob scrubs reason, resolutionNote, and every linked note body to <erased> past window + emits dispute.retention.pii_scrubbed.
  • gap 3 — ResolveDisputeDto accepts an optional moneyMovement payload. When present + outcome=upheld, resolve wraps the Dispute update + DisputeResolutionLedger insert in a transaction. Unique constraint on (disputeId) gives DB-level idempotency — duplicate calls hit P2002 and raise IDEMPOTENCY_CONFLICT. Emits dispute.resolve.money_movement linking Dispute.id → ledger row id.
  • gap 4 — ReferralsService.dispute() + CommissionsService.dispute() now post-transition write-through into DisputesService.raise({subjectType, subjectId, reason}). Both services accept @Optional() DisputesService; ReferralsModule + CommissionsModule import DisputesModule. 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).

Phase 2 shipped (2026-07-18)

Release disputes-p2-20260718-0948. Closes gaps 5–8:

  • gap 5 — Migration 20260718120000_disputes_phase2 adds triageDueAt, triageBreachedAt, resolutionDueAt, resolutionBreachedAt columns + indexes. DisputesService.raise() populates the due-columns from disputes.triage_sla_hours (default 120) + disputes.resolution_sla_hours (default 720). New DisputeSlaEscalationJob runs hourly, stamps breach columns once (idempotent), emits dispute.sla.triage_breached / resolution_breached / sweep, and fans out to compliance officers via RoleFanoutService.
  • gap 6 — DisputesService injects @Optional() NotificationDispatchService. Every lifecycle transition (triage / upheld / rejected / withdrawn / escalated / closed, plus the money-movement upheld path) dispatches a dispute.<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() walks referral → Policy.cancelledInCoolingOff (and commission → Referral → Policy for commission disputes). On hit, Dispute.metadata.coolingOff=true is stamped. resolve() in the money-movement path refuses any kind other than payment_refund on a cooling-off dispute to protect the CPA §37-39 full-refund guarantee.
  • gap 8 — DisputesService.addNote() now takes user and refuses visibility='internal' unless the caller holds DisputeRead. Refused attempts emit dispute.note.internal_denied with attempted-by + role captured in metadata.

Locked in by src/modules/disputes/disputes-phase2.spec.ts (7 cases). Test suite: 500/500 (72 suites).

Phase 3 shipped (2026-07-18)

Release disputes-p3-20260718-0958. Closes gaps 9–12:

  • gap 9 — DisputesService now exposes idempotent wrappers (raiseIdempotent, triageIdempotent, resolveIdempotent, withdrawIdempotent, escalateIdempotent, closeIdempotent, addNoteIdempotent). Same-body + same-key replays the memoised response via the shared IdempotencyService (per-endpoint scope). The controller passes the Idempotency-Key header through every mutation.
  • gap 10 — @Throttle on every mutation: raise + subject-intake endpoints 10/min, lifecycle mutations 20/min, note write 30/min.
  • gap 11 — Migration 20260718130000_disputes_phase3 adds structured Dispute.escalationTarget (soft enum: ira, pdpo, ombudsman, court, internal_review) + Dispute.escalationReference text columns. EscalateDisputeDto requires both. The dispute.escalated audit 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/:paymentId route to the same raiseIdempotent() path with subjectType=claim / payment. Both are throttled + idempotent per gaps 9 + 10 and gated by DisputeRaise.

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.