Files
nick-doc/04 - Flows/Dispute Flow.md

205 lines
12 KiB
Markdown

---
title: Dispute Flow
tags: [flow, dispute, mediator, evidence, chat, state-machine]
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
---
# Dispute Flow
When something goes wrong (item not delivered, wrong item, fraud), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — releasing the escrow to the seller, refunding the buyer, splitting the funds, or rejecting the claim.
## Actors
- **Buyer** — typical initiator.
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
- **Admin / Mediator** — assigned to investigate.
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts`, and release-hold helpers in `backend/src/services/dispute/releaseHoldService.ts`.
> [!note] Alignment gap
> The module exists now, but it still uses the legacy status/action enum. [[Funds Ledger and Escrow State Machine Specification]] defines the canonical future dispute states and financial side effects.
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned).
## Preconditions
- The related `PurchaseRequest` exists.
- The initiator is the request's buyer or the related seller.
- Funds are typically held in escrow (`Payment.escrowState = 'funded'`) — disputes on unfunded orders are accepted but have no monetary impact.
## Dispute state machine (`Dispute.status`)
```mermaid
stateDiagram-v2
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
pending --> in_progress: admin assigned\nassignAdmin()
in_progress --> resolved: admin resolves\naction ∈ {refund, partial, release, reject}
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
pending --> closed: same
resolved --> [*]
closed --> [*]
```
Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts` *(intended design)*): `refund`, `partial`, `release`, `reject`.
## Step-by-step narrative
### Phase 1 — Opening
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
2. They select a `category` (delivery, payment, quality, fraud, other), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
4. Backend `DisputeService.createDispute` (`:12-119`):
- Loads the purchase request with `populate('selectedOfferId')`.
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId``selectedOffer.sellerId` → first of `preferredSellerIds`. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`.
- Creates a **`Chat` of type `group`** with the buyer and the resolved seller as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
- Persists `dispute.chatId = chat._id`.
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle.
> [!note] Release hold behavior
> Opening a dispute now has backend release-hold support: `releaseHoldService.raiseDispute()` sets hold fields on the purchase request and related payments, and release/refund gates can consult those fields. The remaining work is to make this the single mandatory policy path for every release/refund/sweep operation and align it with the canonical `DISPUTED` escrow state.
### Phase 2 — Admin assignment
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }` (currently the admin's own id).
8. `DisputeService.assignAdmin` (`:184-223`):
- `dispute.adminId = adminId; dispute.status = 'in_progress'`.
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
- Adds the admin to the dispute `chat.participants[]` (role `admin`).
- Saves.
### Phase 3 — Investigation
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence``DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`.
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`.
### Phase 4 — Resolution
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with `{ action, amount?, currency?, notes? }`.
12. `DisputeService.resolveDispute` (`:262-300`):
- `dispute.status = 'resolved'`
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
- `dispute.closedAt = now`
- Appends `timeline` entry `dispute_resolved`.
- Saves.
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **release** ([[Payout Flow]] / [[Escrow Flow]]) or the **refund**. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`).
## Sequence diagram
```mermaid
sequenceDiagram
autonumber
actor B as Buyer
actor S as Seller
actor A as Admin
participant FE as Frontend
participant BE as Backend
participant DB as MongoDB
participant IO as Socket.IO
B->>FE: "Report problem" on request
B->>FE: Choose category, priority, evidence
FE->>BE: POST /api/disputes
BE->>DB: Dispute.create({status:"pending"})
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
BE->>DB: dispute.chatId = chat._id
BE-->>FE: { dispute }
FE-->>B: chat opens (real-time via existing chat join)
FE-->>S: chat opens (real-time via existing chat join)
A->>FE: Admin dashboard, click "Pick up"
FE->>BE: POST /api/disputes/{id}/assign
BE->>DB: dispute.adminId, status="in_progress", timeline.push
BE->>DB: chat.participants.push(admin)
BE-->>FE: { dispute }
loop investigation
A->>FE: Chat with B & S
B-->>BE: POST /api/disputes/{id}/evidence (image)
BE->>DB: dispute.evidence.push, timeline.push
end
A->>FE: Click "Resolve" choose action
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes }
BE->>DB: dispute.status="resolved", resolution={...}
alt action="refund"
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
else action="release"
A->>BE: trigger payout to seller\n[[Payout Flow]]
else action="partial"
A->>BE: split — refund X to buyer, release Y to seller
end
BE-->>FE: { dispute }
IO-->>B: 'new-notification' dispute resolved (planned)
IO-->>S: 'new-notification' dispute resolved (planned)
```
## API calls
| Method | Endpoint | Source |
|---|---|---|
| `POST` | `/api/disputes` | `disputeRoutes.ts:12``DisputeController.createDispute` |
| `GET` | `/api/disputes` | `disputeRoutes.ts:15` (filters: status, priority, category, adminId, buyer/seller) |
| `GET` | `/api/disputes/statistics` | `disputeRoutes.ts:18` |
| `GET` | `/api/disputes/:id` | `disputeRoutes.ts:21` |
| `POST` | `/api/disputes/:id/assign` | `disputeRoutes.ts:24` |
| `PATCH` | `/api/disputes/:id/status` | `disputeRoutes.ts:27` |
| `POST` | `/api/disputes/:id/resolve` | `disputeRoutes.ts:30` |
| `POST` | `/api/disputes/:id/evidence` | `disputeRoutes.ts:33` |
All require `authenticateToken` (router-level middleware).
## Database writes
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
- **`purchaserequests`** — not directly mutated by the dispute service; the resolution side-effect (release/refund) updates the request via [[Escrow Flow]].
- **`payments`** — touched indirectly when the admin performs the financial resolution.
- **`notifications`** — `TODO` markers in code; planned addition.
## Socket events emitted
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`).
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
## Side effects
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
- **Timeline append-only log** is the audit trail. Surface it in the admin UI for compliance.
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
## Error / edge cases
- **Purchase request missing** → `400 Purchase request not found`.
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer + admin only). Recommended: reject creation in this case to avoid mediator-less situations.
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates.
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin/custody operator completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution.
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
> [!tip] Sort disputes by priority + age
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
## Linked flows
- [[Chat Flow]] — message-level mechanics inside the dispute chat.
- [[Escrow Flow]] — the financial state being contested.
- [[Payout Flow]] — executed on `release` resolutions.
- [[Notification Flow]] — channels for dispute alerts.
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
## Source files
- Backend: `backend/src/services/dispute/DisputeService.ts`
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
- Backend: `backend/src/routes/disputeRoutes.ts`
- Backend: `backend/src/services/dispute/disputeRoutes.ts`
- Backend: `backend/src/models/Dispute.ts`
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx`
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)