Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions backend/docs/DISPUTE_RESOLUTION.md
Original file line number Diff line number Diff line change
@@ -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
```
12 changes: 10 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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
Expand Down
Loading