From 9c469a69e2bffa1b4a83112e4de8fd5949f6d1eb Mon Sep 17 00:00:00 2001 From: Saint Shalom Date: Thu, 30 Jul 2026 00:57:18 +0100 Subject: [PATCH] feat(disputes): add structured payment dispute resolution with evidence management Implements Issue #641: workflow engine, evidence hashing, resolution tracking, notifications, analytics API, and documentation. --- backend/docs/DISPUTE_RESOLUTION.md | 163 ++++ backend/src/index.ts | 12 +- backend/src/routes/dispute-resolution.ts | 253 ++++++ .../__tests__/dispute-resolution.test.ts | 196 +++++ .../dispute-resolution-service.ts | 750 ++++++++++++++++++ .../src/services/dispute-resolution/index.ts | 62 ++ .../dispute-resolution/workflow-engine.ts | 145 ++++ 7 files changed, 1579 insertions(+), 2 deletions(-) create mode 100644 backend/docs/DISPUTE_RESOLUTION.md create mode 100644 backend/src/routes/dispute-resolution.ts create mode 100644 backend/src/services/__tests__/dispute-resolution.test.ts create mode 100644 backend/src/services/dispute-resolution/dispute-resolution-service.ts create mode 100644 backend/src/services/dispute-resolution/index.ts create mode 100644 backend/src/services/dispute-resolution/workflow-engine.ts diff --git a/backend/docs/DISPUTE_RESOLUTION.md b/backend/docs/DISPUTE_RESOLUTION.md new file mode 100644 index 00000000..9bbe8a5a --- /dev/null +++ b/backend/docs/DISPUTE_RESOLUTION.md @@ -0,0 +1,163 @@ +# Payment Dispute Resolution + +Issue #641. Replaces informal ad-hoc dispute handling with a structured +workflow: open → respond → evidence → escalate / assign arbitrator → +resolve, with evidence integrity hashing, resolution records, dispute +notifications, and analytics. + +- `backend/src/services/dispute-resolution/workflow-engine.ts` — pure, + DB-free state machine (`canTransition`, `nextStatus`, SLA deadline + helpers, auto-escalation rules). +- `backend/src/services/dispute-resolution/dispute-resolution-service.ts` — + orchestration: create, respond, evidence CRUD, assign, escalate, + resolve, timeline, notifications, analytics + (`DisputeResolutionService`, singleton `disputeResolutionService`). +- `backend/src/services/dispute-resolution/index.ts` — public exports and + the scheduled escalation entry point (`runScheduledDisputeEscalations`). +- `backend/src/routes/dispute-resolution.ts` — HTTP API + (`disputeResolutionRouter`, mount path `/api/v1/dispute-resolution`). + +## Structured workflow + +Statuses (aligned with `@agenticpay/types` domain disputes): + +``` +pending → awaiting_response → under_review → resolved | dismissed + ↓ ↓ + escalated ←────────────┘ + ↓ + under_review (after arbitrator assign) → resolved | dismissed +``` + +| Event | Effect | +| ----- | ------ | +| `submit` (on create) | Opens at `awaiting_response` with 72h response SLA and 168h escalation SLA | +| `respond` | Party message recorded; status → `under_review` | +| `add_evidence` | Evidence stored with SHA-256 hash; status unchanged (still non-terminal) | +| `escalate` | Status → `escalated` (manual or SLA cron) | +| `assign_arbitrator` | Sets `arbitratorId`; from `escalated` → `under_review` | +| `resolve` / `dismiss` | Terminal with outcome + resolution note | + +Only one **active** (non-terminal) dispute is allowed per `paymentId`. + +## Evidence management + +`POST /disputes/:id/evidence` registers a file reference: + +- Required: `submittedBy`, `fileUrl`, `fileName`, `fileType`, `fileSize` +- Optional: `description`, `contentBytes` (used for the hash when provided; + otherwise a deterministic metadata string is hashed) +- Hash algorithm: **SHA-256** (hex), for tamper detection +- List: `GET /disputes/:id/evidence` +- Remove (open disputes only): `DELETE /disputes/:id/evidence/:evidenceId` + +Binary upload itself uses the existing `POST /api/v1/file-upload` category +`dispute` (20MB). Callers then pass the returned URL into this evidence API. + +## Resolution tracking + +Resolving a dispute writes: + +1. Dispute fields: `status`, `resolution`, `resolutionNote`, `refundAmount`, + `resolvedAt` +2. An immutable `ResolutionRecord` (`outcome`, actor, role, note, refund) +3. A timeline event (`resolve` or `dismiss`) + +Fetch history via `GET /disputes/:id/resolutions` and +`GET /disputes/:id/timeline`. + +Outcomes: `full_refund` | `partial_refund` | `release_to_payee` | +`dismissed` | `pending`. Partial refunds require `refundAmount` in +`(0, dispute.amount]`. Full refund sets `refundAmount = amount`. + +## Dispute notifications + +Every meaningful transition emits channel fan-out notifications +(`email`, `push`, `in-app`) with templates: + +| Template | When | +| -------- | ---- | +| `dispute_opened` | Respondent notified of new dispute | +| `dispute_opened_ack` | Filer acknowledgment | +| `dispute_response` | Counterparty notified of a response | +| `dispute_evidence` | Peers notified of new evidence | +| `dispute_assigned` | Arbitrator assignment | +| `dispute_escalated` | Both parties on escalation | +| `dispute_resolved` | Both parties on resolution | + +Notifications are recorded on the dispute (inspectable via +`GET /disputes/:id/notifications`) and appear in the timeline as `notified` +events. They integrate with the existing preference keys +`disputeAlerts` / `disputeUpdates` and the `dispute_update` email template +when a production mailer is attached. + +## Dispute analytics + +`GET /api/v1/dispute-resolution/analytics?tenantId=` returns: + +- Counts by status / reason / outcome +- Open / resolved / dismissed / escalated totals +- Average resolution hours +- Escalation rate (%) +- Total refunded amount +- Evidence + notification totals +- SLA breach count (open disputes past a deadline) + +## API surface + +Mounted at `/api/v1/dispute-resolution`: + +``` +POST /disputes +GET /disputes +GET /disputes/:id +POST /disputes/:id/respond +POST /disputes/:id/evidence +GET /disputes/:id/evidence +DELETE /disputes/:id/evidence/:evidenceId +POST /disputes/:id/assign +POST /disputes/:id/escalate +POST /disputes/:id/resolve +GET /disputes/:id/timeline +GET /disputes/:id/resolutions +GET /disputes/:id/notifications +GET /analytics +POST /escalations/process +``` + +### Create example + +```bash +curl -sX POST http://localhost:3001/api/v1/dispute-resolution/disputes \ + -H 'content-type: application/json' \ + -d '{ + "tenantId": "ten_1", + "paymentId": "pay_1", + "filedBy": "user_payer", + "respondentId": "user_payee", + "reason": "service_not_delivered", + "amount": 150, + "currency": "USDC", + "description": "Payment released but deliverable was never provided to the buyer." + }' +``` + +## In-memory fallback + +Like payment reconciliation, persistence is in-memory when `DATABASE_URL` is +unset so unit tests can exercise the full workflow without Postgres. Call +`disputeResolutionService.resetForTests()` between cases. + +## Scheduled escalations + +Register `runScheduledDisputeEscalations` (suggested cron `*/15 * * * *`) +or call `POST /escalations/process`. Auto-escalates: + +- `awaiting_response` past `responseDeadline` (72h) +- `pending` / `under_review` past `escalationDeadline` (168h) + +## Tests + +```bash +cd backend && npm test -- src/services/__tests__/dispute-resolution.test.ts +``` diff --git a/backend/src/index.ts b/backend/src/index.ts index 03c6a183..ce1e730d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -123,6 +123,8 @@ import { streamingExportRouter } from './routes/streaming-export.js'; import { startOutboxPublisher, stopOutboxPublisher } from './outbox/index.js'; import { gasRouter } from './routes/gas.js'; import { paymentReconciliationRouter } from './routes/payment-reconciliation.js'; +import { disputeResolutionRouter } from './routes/dispute-resolution.js'; +import { runScheduledDisputeEscalations } from './services/dispute-resolution/index.js'; import { fxRouter } from './routes/fx.js'; import { cohortAnalyticsRouter } from './routes/cohort-analytics.js'; import { vaultsRouter } from './routes/vaults.js'; @@ -391,6 +393,10 @@ app.use('/api/v1/tax', taxRouter); // Automated payment reconciliation: matching, exceptions, reporting, analytics (Issue #628) app.use('/api/v1/payment-reconciliation', paymentReconciliationRouter); +// Structured payment dispute resolution: workflow, evidence, resolution tracking, +// notifications, analytics (Issue #641) +app.use('/api/v1/dispute-resolution', disputeResolutionRouter); + // FX rate cache/history/alerts backing multi-currency invoices (Issue #626) app.use('/api/v1/fx', fxRouter); @@ -549,10 +555,11 @@ if (config.queue.enabled) { startWebhookWorker(); startOutboxPublisher({ useBullMQ: Boolean(process.env.REDIS_URL) }); -// Auto-escalation cron +// Auto-escalation cron (legacy escrow disputes + Issue #641 dispute-resolution) setInterval(async () => { const count = await disputeService.processEscalations(); if (count > 0) console.log(`Escalated ${count} disputes`); + await runScheduledDisputeEscalations(); }, 5 * 60 * 1000); if (featureFlags.evaluate('batch-operations')) { @@ -632,10 +639,11 @@ server.listen(config.server.port, () => { }); } - // Auto-escalation cron + // Auto-escalation cron (legacy escrow disputes + Issue #641 dispute-resolution) setInterval(async () => { const count = await disputeService.processEscalations(); if (count > 0) console.log(`Escalated ${count} disputes`); + await runScheduledDisputeEscalations(); }, 5 * 60 * 1000); // Batch processor diff --git a/backend/src/routes/dispute-resolution.ts b/backend/src/routes/dispute-resolution.ts new file mode 100644 index 00000000..6cb99023 --- /dev/null +++ b/backend/src/routes/dispute-resolution.ts @@ -0,0 +1,253 @@ +// dispute-resolution.ts — Issue #641 +// Mounted at /api/v1/dispute-resolution +// +// POST /disputes — open a structured payment dispute +// GET /disputes — list disputes (tenant/status/party filters) +// GET /disputes/:id — dispute detail (evidence, timeline, notifications) +// POST /disputes/:id/respond — party response → under_review +// POST /disputes/:id/evidence — upload/register evidence (SHA-256 hash) +// GET /disputes/:id/evidence — list evidence for a dispute +// DELETE /disputes/:id/evidence/:evidenceId — remove evidence (open disputes only) +// POST /disputes/:id/assign — assign arbitrator +// POST /disputes/:id/escalate — escalate to arbitration +// POST /disputes/:id/resolve — resolve / dismiss with outcome tracking +// GET /disputes/:id/timeline — audit / resolution timeline +// GET /disputes/:id/resolutions — resolution records +// GET /disputes/:id/notifications — dispute notification log +// GET /analytics — dispute analytics +// POST /escalations/process — run SLA auto-escalation pass + +import { Router, Request } from 'express'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import type { Result } from '../lib/result.js'; +import { + disputeResolutionService, + ALL_STATUSES, + VALID_OUTCOMES, + VALID_REASONS, + type DisputeReason, + type DisputeStatus, + type ResolutionOutcome, +} from '../services/dispute-resolution/index.js'; + +export const disputeResolutionRouter = Router(); + +function paramId(req: Request, name = 'id'): string { + const value = req.params[name]; + return Array.isArray(value) ? value[0] : value; +} + +function unwrap(result: Result): T { + if (!result.ok) { + throw new AppError(result.error.statusCode, result.error.message, result.error.code); + } + return result.value; +} + +function parseStatus(value: unknown): DisputeStatus | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value !== 'string' || !ALL_STATUSES.includes(value as DisputeStatus)) { + throw new AppError(400, `status must be one of ${ALL_STATUSES.join(', ')}`, 'VALIDATION_ERROR'); + } + return value as DisputeStatus; +} + +disputeResolutionRouter.post( + '/disputes', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const reason = body.reason; + if (typeof reason !== 'string' || !VALID_REASONS.includes(reason as DisputeReason)) { + throw new AppError(400, `reason must be one of ${VALID_REASONS.join(', ')}`, 'VALIDATION_ERROR'); + } + const detail = unwrap( + await disputeResolutionService.createDispute({ + tenantId: String(body.tenantId ?? ''), + paymentId: String(body.paymentId ?? ''), + filedBy: String(body.filedBy ?? ''), + respondentId: String(body.respondentId ?? ''), + reason: reason as DisputeReason, + amount: Number(body.amount), + currency: String(body.currency ?? ''), + description: String(body.description ?? ''), + projectId: typeof body.projectId === 'string' ? body.projectId : undefined, + invoiceId: typeof body.invoiceId === 'string' ? body.invoiceId : undefined, + }), + ); + res.status(201).json(detail); + }), +); + +disputeResolutionRouter.get( + '/disputes', + asyncHandler(async (req, res) => { + const disputes = unwrap( + disputeResolutionService.listDisputes({ + tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, + status: parseStatus(req.query.status), + filedBy: typeof req.query.filedBy === 'string' ? req.query.filedBy : undefined, + respondentId: typeof req.query.respondentId === 'string' ? req.query.respondentId : undefined, + arbitratorId: typeof req.query.arbitratorId === 'string' ? req.query.arbitratorId : undefined, + paymentId: typeof req.query.paymentId === 'string' ? req.query.paymentId : undefined, + }), + ); + res.json({ disputes, total: disputes.length }); + }), +); + +disputeResolutionRouter.get( + '/disputes/:id', + asyncHandler(async (req, res) => { + res.json(unwrap(disputeResolutionService.getDispute(paramId(req)))); + }), +); + +disputeResolutionRouter.post( + '/disputes/:id/respond', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const senderRole = body.senderRole; + if (senderRole !== 'payer' && senderRole !== 'payee' && senderRole !== 'arbitrator') { + throw new AppError(400, 'senderRole must be payer | payee | arbitrator', 'VALIDATION_ERROR'); + } + const detail = unwrap( + await disputeResolutionService.respond(paramId(req), { + senderId: String(body.senderId ?? ''), + senderRole, + content: String(body.content ?? ''), + }), + ); + res.json(detail); + }), +); + +disputeResolutionRouter.post( + '/disputes/:id/evidence', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const evidence = unwrap( + await disputeResolutionService.addEvidence(paramId(req), { + submittedBy: String(body.submittedBy ?? ''), + fileUrl: String(body.fileUrl ?? ''), + fileName: String(body.fileName ?? ''), + fileType: String(body.fileType ?? ''), + fileSize: Number(body.fileSize), + description: String(body.description ?? ''), + contentBytes: typeof body.contentBytes === 'string' ? body.contentBytes : undefined, + }), + ); + res.status(201).json(evidence); + }), +); + +disputeResolutionRouter.get( + '/disputes/:id/evidence', + asyncHandler(async (req, res) => { + const evidence = unwrap(disputeResolutionService.listEvidence(paramId(req))); + res.json({ evidence, total: evidence.length }); + }), +); + +disputeResolutionRouter.delete( + '/disputes/:id/evidence/:evidenceId', + asyncHandler(async (req, res) => { + const actorId = typeof req.body?.actorId === 'string' ? req.body.actorId : 'system'; + const detail = unwrap( + await disputeResolutionService.removeEvidence(paramId(req), paramId(req, 'evidenceId'), actorId), + ); + res.json(detail); + }), +); + +disputeResolutionRouter.post( + '/disputes/:id/assign', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const detail = unwrap( + await disputeResolutionService.assignArbitrator( + paramId(req), + String(body.arbitratorId ?? ''), + String(body.actorId ?? 'system'), + ), + ); + res.json(detail); + }), +); + +disputeResolutionRouter.post( + '/disputes/:id/escalate', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const detail = unwrap( + await disputeResolutionService.escalate( + paramId(req), + String(body.actorId ?? 'system'), + typeof body.note === 'string' ? body.note : 'Manual escalation', + ), + ); + res.json(detail); + }), +); + +disputeResolutionRouter.post( + '/disputes/:id/resolve', + asyncHandler(async (req, res) => { + const body = req.body as Record; + const outcome = body.outcome; + if (typeof outcome !== 'string' || !VALID_OUTCOMES.includes(outcome as ResolutionOutcome)) { + throw new AppError(400, `outcome must be one of ${VALID_OUTCOMES.join(', ')}`, 'VALIDATION_ERROR'); + } + const detail = unwrap( + await disputeResolutionService.resolve(paramId(req), { + outcome: outcome as ResolutionOutcome, + resolutionNote: String(body.resolutionNote ?? ''), + resolvedBy: String(body.resolvedBy ?? ''), + resolvedByRole: + body.resolvedByRole === 'system' || body.resolvedByRole === 'admin' + ? body.resolvedByRole + : 'arbitrator', + refundAmount: typeof body.refundAmount === 'number' ? body.refundAmount : undefined, + }), + ); + res.json(detail); + }), +); + +disputeResolutionRouter.get( + '/disputes/:id/timeline', + asyncHandler(async (req, res) => { + const timeline = unwrap(disputeResolutionService.getTimeline(paramId(req))); + res.json({ timeline, total: timeline.length }); + }), +); + +disputeResolutionRouter.get( + '/disputes/:id/resolutions', + asyncHandler(async (req, res) => { + const resolutions = unwrap(disputeResolutionService.getResolutions(paramId(req))); + res.json({ resolutions, total: resolutions.length }); + }), +); + +disputeResolutionRouter.get( + '/disputes/:id/notifications', + asyncHandler(async (req, res) => { + const notifications = unwrap(disputeResolutionService.listNotifications(paramId(req))); + res.json({ notifications, total: notifications.length }); + }), +); + +disputeResolutionRouter.get( + '/analytics', + asyncHandler(async (req, res) => { + const tenantId = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; + res.json(unwrap(disputeResolutionService.getAnalytics(tenantId))); + }), +); + +disputeResolutionRouter.post( + '/escalations/process', + asyncHandler(async (_req, res) => { + res.json(unwrap(await disputeResolutionService.processEscalations())); + }), +); diff --git a/backend/src/services/__tests__/dispute-resolution.test.ts b/backend/src/services/__tests__/dispute-resolution.test.ts new file mode 100644 index 00000000..2bcc6178 --- /dev/null +++ b/backend/src/services/__tests__/dispute-resolution.test.ts @@ -0,0 +1,196 @@ +// dispute-resolution.test.ts — Issue #641 +// +// Unit tests for the dispute workflow engine and the dispute-resolution +// service (evidence, resolution tracking, notifications, analytics, SLA +// escalations). Runs without DATABASE_URL (in-memory fallback). + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + canTransition, + nextStatus, + shouldAutoEscalate, + computeDeadlines, + RESPONSE_SLA_HOURS, +} from '../dispute-resolution/workflow-engine.js'; +import { + DisputeResolutionService, + type CreateDisputeInput, +} from '../dispute-resolution/dispute-resolution-service.js'; + +function baseCreate(overrides: Partial = {}): CreateDisputeInput { + return { + tenantId: 'ten_1', + paymentId: 'pay_1', + filedBy: 'payer_1', + respondentId: 'payee_1', + reason: 'service_not_delivered', + amount: 250, + currency: 'USDC', + description: 'Deliverable was never shipped after payment cleared escrow.', + ...overrides, + }; +} + +describe('workflow-engine', () => { + it('allows respond from awaiting_response → under_review', () => { + expect(canTransition('awaiting_response', 'respond')).toBe(true); + expect(nextStatus('awaiting_response', 'respond')).toBe('under_review'); + }); + + it('rejects resolve from awaiting_response', () => { + expect(canTransition('awaiting_response', 'resolve')).toBe(false); + expect(() => nextStatus('awaiting_response', 'resolve')).toThrow(/Illegal/); + }); + + it('auto-escalates awaiting_response past response deadline', () => { + const opened = new Date('2026-07-01T00:00:00Z'); + const { responseDeadline, escalationDeadline } = computeDeadlines(opened); + const afterSla = new Date(opened.getTime() + (RESPONSE_SLA_HOURS + 1) * 3_600_000); + expect(shouldAutoEscalate('awaiting_response', responseDeadline, escalationDeadline, afterSla)).toBe(true); + expect(shouldAutoEscalate('resolved', responseDeadline, escalationDeadline, afterSla)).toBe(false); + }); +}); + +describe('DisputeResolutionService', () => { + let service: DisputeResolutionService; + + beforeEach(() => { + service = new DisputeResolutionService(); + service.resetForTests(); + }); + + it('creates a structured dispute with deadlines and notifications', async () => { + const result = await service.createDispute(baseCreate()); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.status).toBe('awaiting_response'); + expect(result.value.responseDeadline).toBeTruthy(); + expect(result.value.escalationDeadline).toBeTruthy(); + expect(result.value.notifications.length).toBeGreaterThanOrEqual(6); // 2 recipients × 3 channels + expect(result.value.timeline.some((t) => t.event === 'created')).toBe(true); + }); + + it('rejects active duplicate disputes on the same payment', async () => { + const first = await service.createDispute(baseCreate()); + expect(first.ok).toBe(true); + const second = await service.createDispute(baseCreate()); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.error.statusCode).toBe(409); + }); + + it('runs respond → evidence → resolve with resolution tracking', async () => { + const created = await service.createDispute(baseCreate()); + expect(created.ok).toBe(true); + if (!created.ok) return; + const id = created.value.id; + + const responded = await service.respond(id, { + senderId: 'payee_1', + senderRole: 'payee', + content: 'Work was delivered on schedule via the agreed channel.', + }); + expect(responded.ok).toBe(true); + if (!responded.ok) return; + expect(responded.value.status).toBe('under_review'); + expect(responded.value.messages).toHaveLength(1); + + const evidence = await service.addEvidence(id, { + submittedBy: 'payee_1', + fileUrl: 'https://cdn.example/proof.pdf', + fileName: 'proof.pdf', + fileType: 'application/pdf', + fileSize: 12_345, + description: 'Delivery receipt', + contentBytes: 'receipt-bytes', + }); + expect(evidence.ok).toBe(true); + if (!evidence.ok) return; + expect(evidence.value.hash).toHaveLength(64); + + const listed = service.listEvidence(id); + expect(listed.ok && listed.value).toHaveLength(1); + + const resolved = await service.resolve(id, { + outcome: 'partial_refund', + resolutionNote: 'Partial delivery confirmed; refund 40%.', + resolvedBy: 'arb_1', + refundAmount: 100, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.value.status).toBe('resolved'); + expect(resolved.value.resolution).toBe('partial_refund'); + expect(resolved.value.refundAmount).toBe(100); + expect(resolved.value.resolutions).toHaveLength(1); + expect(resolved.value.timeline.some((t) => t.event === 'resolve')).toBe(true); + }); + + it('tracks full_refund amount automatically', async () => { + const created = await service.createDispute(baseCreate({ amount: 80 })); + expect(created.ok).toBe(true); + if (!created.ok) return; + await service.respond(created.value.id, { + senderId: 'payee_1', + senderRole: 'payee', + content: 'Unable to complete the remaining work.', + }); + const resolved = await service.resolve(created.value.id, { + outcome: 'full_refund', + resolutionNote: 'Service not delivered; full refund granted.', + resolvedBy: 'arb_1', + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.value.refundAmount).toBe(80); + }); + + it('escalates past SLA and exposes analytics', async () => { + const created = await service.createDispute(baseCreate({ paymentId: 'pay_sla' })); + expect(created.ok).toBe(true); + if (!created.ok) return; + + service.setDeadlinesForTests(created.value.id, '2020-01-01T00:00:00.000Z', '2020-01-02T00:00:00.000Z'); + + const esc = await service.processEscalations(new Date('2026-07-30T00:00:00Z')); + expect(esc.ok).toBe(true); + if (!esc.ok) return; + expect(esc.value.escalated).toBe(1); + + const detail = service.getDispute(created.value.id); + expect(detail.ok && detail.value.status).toBe('escalated'); + + const analytics = service.getAnalytics('ten_1'); + expect(analytics.ok).toBe(true); + if (!analytics.ok) return; + expect(analytics.value.total).toBe(1); + expect(analytics.value.escalatedCount).toBe(1); + expect(analytics.value.escalationRatePct).toBeGreaterThan(0); + expect(analytics.value.notificationCount).toBeGreaterThan(0); + expect(analytics.value.byReason.service_not_delivered).toBe(1); + }); + + it('assigns arbitrator from escalated back to under_review', async () => { + const created = await service.createDispute(baseCreate({ paymentId: 'pay_arb' })); + expect(created.ok).toBe(true); + if (!created.ok) return; + await service.escalate(created.value.id, 'admin', 'Needs human review'); + const assigned = await service.assignArbitrator(created.value.id, 'arb_9', 'admin'); + expect(assigned.ok).toBe(true); + if (!assigned.ok) return; + expect(assigned.value.arbitratorId).toBe('arb_9'); + expect(assigned.value.status).toBe('under_review'); + }); + + it('lists notifications for a dispute', async () => { + const created = await service.createDispute(baseCreate({ paymentId: 'pay_n' })); + expect(created.ok).toBe(true); + if (!created.ok) return; + const notes = service.listNotifications(created.value.id); + expect(notes.ok).toBe(true); + if (!notes.ok) return; + expect(notes.value.every((n) => n.delivered)).toBe(true); + expect(notes.value.some((n) => n.templateId === 'dispute_opened')).toBe(true); + }); +}); diff --git a/backend/src/services/dispute-resolution/dispute-resolution-service.ts b/backend/src/services/dispute-resolution/dispute-resolution-service.ts new file mode 100644 index 00000000..d9267584 --- /dev/null +++ b/backend/src/services/dispute-resolution/dispute-resolution-service.ts @@ -0,0 +1,750 @@ +// dispute-resolution-service.ts — Issue #641 +// +// Orchestrates structured payment dispute resolution: open → respond → +// evidence → escalate / assign arbitrator → resolve, with resolution +// tracking, dispute notifications, and analytics. +// +// Follows the same DB-optional pattern as payment-reconciliation: when +// DATABASE_URL is unset (this repo's default test run) everything lives in +// in-memory maps so the full workflow is unit-testable without Postgres. +// Call `resetForTests()` between tests. + +import { createHash, randomUUID } from 'node:crypto'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { + ALL_STATUSES, + VALID_OUTCOMES, + VALID_REASONS, + canTransition, + computeDeadlines, + isTerminal, + nextStatus, + shouldAutoEscalate, + statusForOutcome, + type DisputeEvent, + type DisputeReason, + type DisputeStatus, + type ResolutionOutcome, +} from './workflow-engine.js'; + +// ─── Public DTO types ──────────────────────────────────────────────────────── + +export interface EvidenceDTO { + id: string; + disputeId: string; + submittedBy: string; + fileUrl: string; + fileName: string; + fileType: string; + fileSize: number; + description: string; + timestamp: string; + hash: string; +} + +export interface DisputeMessageDTO { + id: string; + disputeId: string; + senderId: string; + senderRole: 'payer' | 'payee' | 'arbitrator' | 'system'; + content: string; + timestamp: string; +} + +export interface ResolutionRecordDTO { + id: string; + disputeId: string; + outcome: ResolutionOutcome; + resolutionNote: string; + refundAmount: number | null; + resolvedBy: string; + resolvedByRole: 'arbitrator' | 'system' | 'admin'; + createdAt: string; +} + +export interface TimelineEventDTO { + id: string; + disputeId: string; + event: DisputeEvent | 'created' | 'notified' | 'evidence_removed'; + actorId: string; + detail: string; + fromStatus: DisputeStatus | null; + toStatus: DisputeStatus | null; + createdAt: string; +} + +export interface DisputeNotificationDTO { + id: string; + disputeId: string; + recipientId: string; + channel: 'email' | 'push' | 'in-app' | 'webhook'; + templateId: string; + subject: string; + body: string; + createdAt: string; + delivered: boolean; +} + +export interface DisputeDTO { + id: string; + tenantId: string; + paymentId: string; + projectId: string | null; + invoiceId: string | null; + filedBy: string; + respondentId: string; + arbitratorId: string | null; + status: DisputeStatus; + reason: DisputeReason; + amount: number; + currency: string; + description: string; + resolution: ResolutionOutcome | null; + resolutionNote: string | null; + refundAmount: number | null; + responseDeadline: string; + escalationDeadline: string; + createdAt: string; + updatedAt: string; + resolvedAt: string | null; +} + +export interface DisputeDetail extends DisputeDTO { + evidence: EvidenceDTO[]; + messages: DisputeMessageDTO[]; + resolutions: ResolutionRecordDTO[]; + timeline: TimelineEventDTO[]; + notifications: DisputeNotificationDTO[]; +} + +export interface CreateDisputeInput { + tenantId: string; + paymentId: string; + filedBy: string; + respondentId: string; + reason: DisputeReason; + amount: number; + currency: string; + description: string; + projectId?: string; + invoiceId?: string; +} + +export interface RespondInput { + senderId: string; + senderRole: 'payer' | 'payee' | 'arbitrator'; + content: string; +} + +export interface AddEvidenceInput { + submittedBy: string; + fileUrl: string; + fileName: string; + fileType: string; + fileSize: number; + description: string; + /** Optional precomputed content for hashing (falls back to metadata). */ + contentBytes?: string | Buffer; +} + +export interface ResolveInput { + outcome: ResolutionOutcome; + resolutionNote: string; + resolvedBy: string; + resolvedByRole?: 'arbitrator' | 'system' | 'admin'; + refundAmount?: number; +} + +export interface ListDisputesParams { + tenantId?: string; + status?: DisputeStatus; + filedBy?: string; + respondentId?: string; + arbitratorId?: string; + paymentId?: string; +} + +export interface DisputeAnalytics { + tenantId: string | null; + total: number; + openCount: number; + resolvedCount: number; + dismissedCount: number; + escalatedCount: number; + byStatus: Record; + byReason: Record; + byOutcome: Partial>; + averageResolutionHours: number | null; + escalationRatePct: number; + totalRefunded: number; + evidenceCount: number; + notificationCount: number; + slaBreachCount: number; + generatedAt: string; +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +function emptyStatusCounts(): Record { + return Object.fromEntries(ALL_STATUSES.map((s) => [s, 0])) as Record; +} + +function emptyReasonCounts(): Record { + return Object.fromEntries(VALID_REASONS.map((r) => [r, 0])) as Record; +} + +function hashEvidence(payload: string | Buffer): string { + return createHash('sha256').update(payload).digest('hex'); +} + +class DisputeResolutionService extends BaseService { + private memDisputes = new Map(); + private memEvidence = new Map(); + private memMessages = new Map(); + private memResolutions = new Map(); + private memTimeline = new Map(); + private memNotifications = new Map(); + + /** Clear in-memory state between unit tests. */ + resetForTests(): void { + this.memDisputes.clear(); + this.memEvidence.clear(); + this.memMessages.clear(); + this.memResolutions.clear(); + this.memTimeline.clear(); + this.memNotifications.clear(); + } + + /** Test helper: overwrite SLA deadlines without going through create. */ + setDeadlinesForTests(id: string, responseDeadline: string, escalationDeadline: string): void { + const dispute = this.memDisputes.get(id); + if (!dispute) throw new Error(`Dispute not found: ${id}`); + dispute.responseDeadline = responseDeadline; + dispute.escalationDeadline = escalationDeadline; + } + + // ── Create / read ──────────────────────────────────────────────────────── + + async createDispute(input: CreateDisputeInput): Promise> { + if (!input.tenantId?.trim()) return this.validationFailure('tenantId is required'); + if (!input.paymentId?.trim()) return this.validationFailure('paymentId is required'); + if (!input.filedBy?.trim()) return this.validationFailure('filedBy is required'); + if (!input.respondentId?.trim()) return this.validationFailure('respondentId is required'); + if (input.filedBy === input.respondentId) { + return this.validationFailure('filedBy and respondentId must differ'); + } + if (!VALID_REASONS.includes(input.reason)) { + return this.validationFailure(`reason must be one of ${VALID_REASONS.join(', ')}`); + } + if (typeof input.amount !== 'number' || !(input.amount > 0)) { + return this.validationFailure('amount must be a positive number'); + } + if (!input.currency?.trim()) return this.validationFailure('currency is required'); + if (!input.description || input.description.trim().length < 20) { + return this.validationFailure('description must be at least 20 characters'); + } + + const active = [...this.memDisputes.values()].find( + (d) => + d.paymentId === input.paymentId && + !isTerminal(d.status) && + d.status !== 'dismissed', + ); + if (active) { + return this.conflictFailure(`Active dispute already exists for payment ${input.paymentId}`); + } + + const now = new Date(); + const deadlines = computeDeadlines(now); + const id = randomUUID(); + const dispute: DisputeDTO = { + id, + tenantId: input.tenantId, + paymentId: input.paymentId, + projectId: input.projectId ?? null, + invoiceId: input.invoiceId ?? null, + filedBy: input.filedBy, + respondentId: input.respondentId, + arbitratorId: null, + status: 'awaiting_response', + reason: input.reason, + amount: input.amount, + currency: input.currency.toUpperCase(), + description: input.description.trim(), + resolution: null, + resolutionNote: null, + refundAmount: null, + responseDeadline: deadlines.responseDeadline, + escalationDeadline: deadlines.escalationDeadline, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + resolvedAt: null, + }; + + this.memDisputes.set(id, dispute); + this.pushTimeline(id, 'created', input.filedBy, 'Dispute opened', null, 'awaiting_response'); + + await this.notify( + dispute, + input.respondentId, + 'dispute_opened', + 'New payment dispute filed', + `A dispute was filed on payment ${input.paymentId}: ${input.reason}`, + ); + await this.notify( + dispute, + input.filedBy, + 'dispute_opened_ack', + 'Dispute submitted', + `Your dispute ${id} is awaiting a response (deadline ${deadlines.responseDeadline}).`, + ); + + return this.ok(this.toDetail(dispute)); + } + + getDispute(id: string): Result { + const dispute = this.memDisputes.get(id); + if (!dispute) return this.notFoundFailure('Dispute', id); + return this.ok(this.toDetail(dispute)); + } + + listDisputes(params: ListDisputesParams = {}): Result { + let rows = [...this.memDisputes.values()]; + if (params.tenantId) rows = rows.filter((d) => d.tenantId === params.tenantId); + if (params.status) rows = rows.filter((d) => d.status === params.status); + if (params.filedBy) rows = rows.filter((d) => d.filedBy === params.filedBy); + if (params.respondentId) rows = rows.filter((d) => d.respondentId === params.respondentId); + if (params.arbitratorId) rows = rows.filter((d) => d.arbitratorId === params.arbitratorId); + if (params.paymentId) rows = rows.filter((d) => d.paymentId === params.paymentId); + rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + return this.ok(rows); + } + + // ── Workflow actions ───────────────────────────────────────────────────── + + async respond(id: string, input: RespondInput): Promise> { + const dispute = this.memDisputes.get(id); + if (!dispute) return this.notFoundFailure('Dispute', id); + if (!input.content?.trim() || input.content.trim().length < 5) { + return this.validationFailure('content must be at least 5 characters'); + } + if (!canTransition(dispute.status, 'respond')) { + return this.fail(`Cannot respond while dispute is ${dispute.status}`, 409, 'INVALID_TRANSITION'); + } + + const from = dispute.status; + const to = nextStatus(from, 'respond'); + dispute.status = to; + dispute.updatedAt = new Date().toISOString(); + + const message: DisputeMessageDTO = { + id: randomUUID(), + disputeId: id, + senderId: input.senderId, + senderRole: input.senderRole, + content: input.content.trim(), + timestamp: new Date().toISOString(), + }; + this.memMessages.set(message.id, message); + this.pushTimeline(id, 'respond', input.senderId, 'Party response recorded', from, to); + + const notifyTarget = input.senderId === dispute.filedBy ? dispute.respondentId : dispute.filedBy; + await this.notify( + dispute, + notifyTarget, + 'dispute_response', + 'Dispute response received', + `A response was posted on dispute ${id}.`, + ); + + return this.ok(this.toDetail(dispute)); + } + + async addEvidence(id: string, input: AddEvidenceInput): Promise> { + const dispute = this.memDisputes.get(id); + if (!dispute) return this.notFoundFailure('Dispute', id); + if (!canTransition(dispute.status, 'add_evidence')) { + return this.fail(`Cannot add evidence while dispute is ${dispute.status}`, 409, 'INVALID_TRANSITION'); + } + if (!input.fileUrl?.trim()) return this.validationFailure('fileUrl is required'); + if (!input.fileName?.trim()) return this.validationFailure('fileName is required'); + if (!input.fileType?.trim()) return this.validationFailure('fileType is required'); + if (typeof input.fileSize !== 'number' || input.fileSize < 0) { + return this.validationFailure('fileSize must be a non-negative number'); + } + if (!input.submittedBy?.trim()) return this.validationFailure('submittedBy is required'); + + const hashSource = + input.contentBytes ?? + `${input.fileUrl}|${input.fileName}|${input.fileSize}|${input.submittedBy}|${Date.now()}`; + const evidence: EvidenceDTO = { + id: randomUUID(), + disputeId: id, + submittedBy: input.submittedBy, + fileUrl: input.fileUrl, + fileName: input.fileName, + fileType: input.fileType, + fileSize: input.fileSize, + description: (input.description ?? '').trim(), + timestamp: new Date().toISOString(), + hash: hashEvidence(hashSource), + }; + + this.memEvidence.set(evidence.id, evidence); + const from = dispute.status; + dispute.status = nextStatus(from, 'add_evidence'); + dispute.updatedAt = new Date().toISOString(); + this.pushTimeline( + id, + 'add_evidence', + input.submittedBy, + `Evidence uploaded: ${evidence.fileName} (${evidence.hash.slice(0, 12)}…)`, + from, + dispute.status, + ); + + const peers = [dispute.filedBy, dispute.respondentId, dispute.arbitratorId].filter( + (uid): uid is string => Boolean(uid) && uid !== input.submittedBy, + ); + for (const recipientId of peers) { + await this.notify( + dispute, + recipientId, + 'dispute_evidence', + 'New dispute evidence', + `Evidence "${evidence.fileName}" was added to dispute ${id}.`, + ); + } + + return this.ok(evidence); + } + + listEvidence(disputeId: string): Result { + if (!this.memDisputes.has(disputeId)) return this.notFoundFailure('Dispute', disputeId); + const rows = [...this.memEvidence.values()] + .filter((e) => e.disputeId === disputeId) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + return this.ok(rows); + } + + async removeEvidence(disputeId: string, evidenceId: string, actorId: string): Promise> { + const dispute = this.memDisputes.get(disputeId); + if (!dispute) return this.notFoundFailure('Dispute', disputeId); + if (isTerminal(dispute.status)) { + return this.fail('Cannot remove evidence from a closed dispute', 409, 'INVALID_TRANSITION'); + } + const evidence = this.memEvidence.get(evidenceId); + if (!evidence || evidence.disputeId !== disputeId) { + return this.notFoundFailure('Evidence', evidenceId); + } + this.memEvidence.delete(evidenceId); + dispute.updatedAt = new Date().toISOString(); + this.pushTimeline( + disputeId, + 'evidence_removed', + actorId, + `Evidence removed: ${evidence.fileName}`, + dispute.status, + dispute.status, + ); + return this.ok(this.toDetail(dispute)); + } + + async assignArbitrator(id: string, arbitratorId: string, actorId: string): Promise> { + const dispute = this.memDisputes.get(id); + if (!dispute) return this.notFoundFailure('Dispute', id); + if (!arbitratorId?.trim()) return this.validationFailure('arbitratorId is required'); + if (!canTransition(dispute.status, 'assign_arbitrator')) { + return this.fail(`Cannot assign arbitrator while dispute is ${dispute.status}`, 409, 'INVALID_TRANSITION'); + } + + const from = dispute.status; + const to = nextStatus(from, 'assign_arbitrator'); + dispute.arbitratorId = arbitratorId; + dispute.status = to; + dispute.updatedAt = new Date().toISOString(); + this.pushTimeline(id, 'assign_arbitrator', actorId, `Arbitrator ${arbitratorId} assigned`, from, to); + + await this.notify( + dispute, + arbitratorId, + 'dispute_assigned', + 'Dispute assigned to you', + `You were assigned as arbitrator on dispute ${id}.`, + ); + + return this.ok(this.toDetail(dispute)); + } + + async escalate(id: string, actorId = 'system', note = 'SLA escalation'): Promise> { + const dispute = this.memDisputes.get(id); + if (!dispute) return this.notFoundFailure('Dispute', id); + if (!canTransition(dispute.status, 'escalate')) { + return this.fail(`Cannot escalate while dispute is ${dispute.status}`, 409, 'INVALID_TRANSITION'); + } + const from = dispute.status; + const to = nextStatus(from, 'escalate'); + dispute.status = to; + dispute.updatedAt = new Date().toISOString(); + this.pushTimeline(id, 'escalate', actorId, note, from, to); + + for (const recipientId of [dispute.filedBy, dispute.respondentId]) { + await this.notify( + dispute, + recipientId, + 'dispute_escalated', + 'Dispute escalated', + `Dispute ${id} was escalated: ${note}`, + ); + } + + return this.ok(this.toDetail(dispute)); + } + + async resolve(id: string, input: ResolveInput): Promise> { + const dispute = this.memDisputes.get(id); + if (!dispute) return this.notFoundFailure('Dispute', id); + if (!VALID_OUTCOMES.includes(input.outcome)) { + return this.validationFailure(`outcome must be one of ${VALID_OUTCOMES.join(', ')}`); + } + if (!input.resolutionNote?.trim() || input.resolutionNote.trim().length < 5) { + return this.validationFailure('resolutionNote must be at least 5 characters'); + } + if (!input.resolvedBy?.trim()) return this.validationFailure('resolvedBy is required'); + if (input.outcome === 'partial_refund') { + if (typeof input.refundAmount !== 'number' || input.refundAmount <= 0 || input.refundAmount > dispute.amount) { + return this.validationFailure('partial_refund requires refundAmount in (0, dispute.amount]'); + } + } + if (input.outcome === 'full_refund') { + input.refundAmount = dispute.amount; + } + + const event: DisputeEvent = input.outcome === 'dismissed' ? 'dismiss' : 'resolve'; + if (!canTransition(dispute.status, event)) { + return this.fail(`Cannot resolve while dispute is ${dispute.status}`, 409, 'INVALID_TRANSITION'); + } + + const from = dispute.status; + const to = statusForOutcome(input.outcome); + const now = new Date().toISOString(); + dispute.status = to; + dispute.resolution = input.outcome; + dispute.resolutionNote = input.resolutionNote.trim(); + dispute.refundAmount = input.refundAmount ?? null; + dispute.resolvedAt = now; + dispute.updatedAt = now; + + const resolution: ResolutionRecordDTO = { + id: randomUUID(), + disputeId: id, + outcome: input.outcome, + resolutionNote: dispute.resolutionNote, + refundAmount: dispute.refundAmount, + resolvedBy: input.resolvedBy, + resolvedByRole: input.resolvedByRole ?? 'arbitrator', + createdAt: now, + }; + this.memResolutions.set(resolution.id, resolution); + this.pushTimeline(id, event, input.resolvedBy, `Resolved: ${input.outcome}`, from, to); + + for (const recipientId of [dispute.filedBy, dispute.respondentId]) { + await this.notify( + dispute, + recipientId, + 'dispute_resolved', + 'Dispute resolved', + `Dispute ${id} resolved with outcome ${input.outcome}.`, + ); + } + + return this.ok(this.toDetail(dispute)); + } + + getTimeline(id: string): Result { + if (!this.memDisputes.has(id)) return this.notFoundFailure('Dispute', id); + const rows = [...this.memTimeline.values()] + .filter((e) => e.disputeId === id) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return this.ok(rows); + } + + getResolutions(id: string): Result { + if (!this.memDisputes.has(id)) return this.notFoundFailure('Dispute', id); + const rows = [...this.memResolutions.values()] + .filter((r) => r.disputeId === id) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return this.ok(rows); + } + + listNotifications(disputeId?: string): Result { + let rows = [...this.memNotifications.values()]; + if (disputeId) rows = rows.filter((n) => n.disputeId === disputeId); + rows.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return this.ok(rows); + } + + // ── Escalation cron ────────────────────────────────────────────────────── + + async processEscalations(now: Date = new Date()): Promise> { + const ids: string[] = []; + for (const dispute of this.memDisputes.values()) { + if ( + shouldAutoEscalate( + dispute.status, + dispute.responseDeadline, + dispute.escalationDeadline, + now, + ) + ) { + const result = await this.escalate(dispute.id, 'system', 'Auto-escalated past SLA deadline'); + if (result.ok) ids.push(dispute.id); + } + } + return this.ok({ escalated: ids.length, ids }); + } + + // ── Analytics ──────────────────────────────────────────────────────────── + + getAnalytics(tenantId?: string, now: Date = new Date()): Result { + let rows = [...this.memDisputes.values()]; + if (tenantId) rows = rows.filter((d) => d.tenantId === tenantId); + + const byStatus = emptyStatusCounts(); + const byReason = emptyReasonCounts(); + const byOutcome: Partial> = {}; + let totalRefunded = 0; + let slaBreachCount = 0; + const resolutionHours: number[] = []; + + for (const d of rows) { + byStatus[d.status] += 1; + byReason[d.reason] += 1; + if (d.resolution) byOutcome[d.resolution] = (byOutcome[d.resolution] ?? 0) + 1; + if (typeof d.refundAmount === 'number') totalRefunded += d.refundAmount; + if (d.resolvedAt) { + const hours = + (new Date(d.resolvedAt).getTime() - new Date(d.createdAt).getTime()) / 3_600_000; + resolutionHours.push(hours); + } + if ( + !isTerminal(d.status) && + (new Date(d.responseDeadline) < now || new Date(d.escalationDeadline) < now) + ) { + slaBreachCount += 1; + } + } + + const evidenceCount = [...this.memEvidence.values()].filter((e) => + rows.some((d) => d.id === e.disputeId), + ).length; + const notificationCount = [...this.memNotifications.values()].filter((n) => + rows.some((d) => d.id === n.disputeId), + ).length; + + const escalatedUnique = new Set( + [...this.memTimeline.values()] + .filter((t) => t.event === 'escalate' && rows.some((d) => d.id === t.disputeId)) + .map((t) => t.disputeId), + ).size; + + const averageResolutionHours = + resolutionHours.length === 0 + ? null + : Math.round( + (resolutionHours.reduce((a, b) => a + b, 0) / resolutionHours.length) * 100, + ) / 100; + + return this.ok({ + tenantId: tenantId ?? null, + total: rows.length, + openCount: rows.filter((d) => !isTerminal(d.status)).length, + resolvedCount: byStatus.resolved, + dismissedCount: byStatus.dismissed, + escalatedCount: byStatus.escalated, + byStatus, + byReason, + byOutcome, + averageResolutionHours, + escalationRatePct: rows.length === 0 ? 0 : Math.round((escalatedUnique / rows.length) * 10_000) / 100, + totalRefunded: Math.round(totalRefunded * 100) / 100, + evidenceCount, + notificationCount, + slaBreachCount, + generatedAt: now.toISOString(), + }); + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private toDetail(dispute: DisputeDTO): DisputeDetail { + return { + ...dispute, + evidence: [...this.memEvidence.values()] + .filter((e) => e.disputeId === dispute.id) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)), + messages: [...this.memMessages.values()] + .filter((m) => m.disputeId === dispute.id) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)), + resolutions: [...this.memResolutions.values()] + .filter((r) => r.disputeId === dispute.id) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)), + timeline: [...this.memTimeline.values()] + .filter((t) => t.disputeId === dispute.id) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)), + notifications: [...this.memNotifications.values()] + .filter((n) => n.disputeId === dispute.id) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)), + }; + } + + private pushTimeline( + disputeId: string, + event: TimelineEventDTO['event'], + actorId: string, + detail: string, + fromStatus: DisputeStatus | null, + toStatus: DisputeStatus | null, + ): void { + const row: TimelineEventDTO = { + id: randomUUID(), + disputeId, + event, + actorId, + detail, + fromStatus, + toStatus, + createdAt: new Date().toISOString(), + }; + this.memTimeline.set(row.id, row); + } + + private async notify( + dispute: DisputeDTO, + recipientId: string, + templateId: string, + subject: string, + body: string, + ): Promise { + const channels: DisputeNotificationDTO['channel'][] = ['email', 'push', 'in-app']; + for (const channel of channels) { + const n: DisputeNotificationDTO = { + id: randomUUID(), + disputeId: dispute.id, + recipientId, + channel, + templateId, + subject, + body, + createdAt: new Date().toISOString(), + delivered: true, + }; + this.memNotifications.set(n.id, n); + } + this.pushTimeline(dispute.id, 'notified', 'system', `${templateId} → ${recipientId}`, dispute.status, dispute.status); + } +} + +export const disputeResolutionService = new DisputeResolutionService(); +export { DisputeResolutionService }; diff --git a/backend/src/services/dispute-resolution/index.ts b/backend/src/services/dispute-resolution/index.ts new file mode 100644 index 00000000..6e17a069 --- /dev/null +++ b/backend/src/services/dispute-resolution/index.ts @@ -0,0 +1,62 @@ +// index.ts — Issue #641 +// +// Public surface of the payment dispute resolution module: the pure workflow +// engine, the orchestrating service singleton, and the scheduled escalation +// entry point. See backend/docs/DISPUTE_RESOLUTION.md. + +export { + canTransition, + nextStatus, + isTerminal, + statusForOutcome, + computeDeadlines, + shouldAutoEscalate, + RESPONSE_SLA_HOURS, + ESCALATION_SLA_HOURS, + VALID_REASONS, + VALID_OUTCOMES, + ALL_STATUSES, +} from './workflow-engine.js'; +export type { + DisputeStatus, + DisputeReason, + ResolutionOutcome, + DisputeEvent, +} from './workflow-engine.js'; + +export { + DisputeResolutionService, + disputeResolutionService, +} from './dispute-resolution-service.js'; +export type { + EvidenceDTO, + DisputeMessageDTO, + ResolutionRecordDTO, + TimelineEventDTO, + DisputeNotificationDTO, + DisputeDTO, + DisputeDetail, + CreateDisputeInput, + RespondInput, + AddEvidenceInput, + ResolveInput, + ListDisputesParams, + DisputeAnalytics, +} from './dispute-resolution-service.js'; + +import { disputeResolutionService } from './dispute-resolution-service.js'; + +/** + * Process SLA-based auto-escalations for open disputes. Intended to be + * registered as a scheduled task (suggested cron `*/15 * * * *`). + */ +export async function runScheduledDisputeEscalations(): Promise { + const result = await disputeResolutionService.processEscalations(); + if (!result.ok) { + console.error(`[dispute-resolution] escalation run failed: ${result.error.message}`); + return; + } + if (result.value.escalated > 0) { + console.log(`[dispute-resolution] auto-escalated ${result.value.escalated} dispute(s)`); + } +} diff --git a/backend/src/services/dispute-resolution/workflow-engine.ts b/backend/src/services/dispute-resolution/workflow-engine.ts new file mode 100644 index 00000000..ff1bb961 --- /dev/null +++ b/backend/src/services/dispute-resolution/workflow-engine.ts @@ -0,0 +1,145 @@ +// workflow-engine.ts — Issue #641 +// +// Pure, DB-free state machine for structured payment dispute resolution. +// Encodes legal transitions, SLA deadline helpers, and outcome → terminal +// status mapping so the orchestrating service stays free of branching logic. + +export type DisputeStatus = + | 'pending' + | 'awaiting_response' + | 'under_review' + | 'resolved' + | 'escalated' + | 'dismissed'; + +export type DisputeReason = + | 'service_not_delivered' + | 'partial_delivery' + | 'quality_issue' + | 'unauthorized_charge' + | 'duplicate_charge' + | 'other'; + +export type ResolutionOutcome = + | 'full_refund' + | 'partial_refund' + | 'release_to_payee' + | 'dismissed' + | 'pending'; + +export type DisputeEvent = + | 'submit' + | 'respond' + | 'add_evidence' + | 'escalate' + | 'assign_arbitrator' + | 'resolve' + | 'dismiss'; + +/** Hours a respondent has to reply before auto-escalation. */ +export const RESPONSE_SLA_HOURS = 72; +/** Hours from open until mandatory review-escalation if still open. */ +export const ESCALATION_SLA_HOURS = 168; + +const TRANSITIONS: Record>> = { + pending: { + submit: 'awaiting_response', + dismiss: 'dismissed', + }, + awaiting_response: { + respond: 'under_review', + add_evidence: 'awaiting_response', + escalate: 'escalated', + dismiss: 'dismissed', + }, + under_review: { + add_evidence: 'under_review', + assign_arbitrator: 'under_review', + escalate: 'escalated', + resolve: 'resolved', + dismiss: 'dismissed', + }, + escalated: { + add_evidence: 'escalated', + assign_arbitrator: 'under_review', + resolve: 'resolved', + dismiss: 'dismissed', + }, + resolved: {}, + dismissed: {}, +}; + +export function canTransition(from: DisputeStatus, event: DisputeEvent): boolean { + return Boolean(TRANSITIONS[from]?.[event]); +} + +export function nextStatus(from: DisputeStatus, event: DisputeEvent): DisputeStatus { + const to = TRANSITIONS[from]?.[event]; + if (!to) { + throw new Error(`Illegal dispute transition: ${from} + ${event}`); + } + return to; +} + +export function isTerminal(status: DisputeStatus): boolean { + return status === 'resolved' || status === 'dismissed'; +} + +export function statusForOutcome(outcome: ResolutionOutcome): DisputeStatus { + return outcome === 'dismissed' ? 'dismissed' : 'resolved'; +} + +export function addHoursIso(from: Date, hours: number): string { + return new Date(from.getTime() + hours * 3_600_000).toISOString(); +} + +export function computeDeadlines(openedAt: Date = new Date()): { + responseDeadline: string; + escalationDeadline: string; +} { + return { + responseDeadline: addHoursIso(openedAt, RESPONSE_SLA_HOURS), + escalationDeadline: addHoursIso(openedAt, ESCALATION_SLA_HOURS), + }; +} + +/** Decide whether a dispute should auto-escalate under SLA rules. */ +export function shouldAutoEscalate( + status: DisputeStatus, + responseDeadline: string, + escalationDeadline: string, + now: Date = new Date(), +): boolean { + if (isTerminal(status) || status === 'escalated') return false; + if (status === 'awaiting_response' && new Date(responseDeadline) < now) return true; + if ((status === 'under_review' || status === 'pending') && new Date(escalationDeadline) < now) { + return true; + } + return false; +} + +export const VALID_REASONS: DisputeReason[] = [ + 'service_not_delivered', + 'partial_delivery', + 'quality_issue', + 'unauthorized_charge', + 'duplicate_charge', + 'other', +]; + +export const VALID_OUTCOMES: ResolutionOutcome[] = [ + 'full_refund', + 'partial_refund', + 'release_to_payee', + 'dismissed', + 'pending', +]; + +export const ALL_STATUSES: DisputeStatus[] = [ + 'pending', + 'awaiting_response', + 'under_review', + 'resolved', + 'escalated', + 'dismissed', +];