--- 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"] audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts, routes/disputeRoutes.ts (dashboard), services/dispute/disputeRoutes.ts (release-hold), app.ts. Previous version had wrong resolution schema, invented status values, missing security issues, and incorrect socket-event description." --- > **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report) # Dispute Flow When something goes wrong (item not delivered, wrong item, seller misbehaviour), 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 — selecting an action such as refund, replacement, compensation, warning, or ban. > [!success] Security fixes applied (2026-05-30) > The three privilege-escalation bugs documented in the original Security Gaps section were fixed in commit `1d881c5` (ISSUE-003, ISSUE-004) and `fce8a19` (resolver role). Role guards are now enforced on assign/status/resolve; route shadowing is eliminated by remounting the release-hold router at `/api/disputes/pr`. See [Security Gaps](#security-gaps) for the historical record and current state. > [!warning] Real-time events not implemented > Every Socket.IO emit in `DisputeService` is currently commented out. No `dispute-updated`, `new-notification`, or any other socket event fires for dispute creation, admin assignment, status changes, evidence uploads, or resolution. The dispute feature is CRUD-only at this stage. ## 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. - **Admin / Mediator** — assigned to investigate (role `admin` or `resolver`). - **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted at `/api/disputes/pr` since commit `1d881c5`). - **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`. - **Socket.IO** — no events fire today; all emits are TODO stubs (see warning above). ## 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`) Valid status values (from `Dispute.ts`): `pending | in_progress | waiting_response | resolved | rejected | closed`. > [!caution] `under_review` does NOT exist. The correct progressed status is `in_progress`. ```mermaid stateDiagram-v2 [*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d pending --> in_progress: admin assigned\nassignAdmin() pending --> waiting_response: status update in_progress --> waiting_response: status update waiting_response --> in_progress: status update in_progress --> resolved: admin resolves\nresolveDispute() in_progress --> rejected: admin rejects in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam) pending --> closed: same resolved --> [*] rejected --> [*] closed --> [*] ``` ## Resolution schema (`Dispute.resolution`) ```ts resolution?: { action: 'refund' | 'replacement' | 'compensation' | 'warning_seller' | 'ban_seller' | 'no_action'; amount?: number; currency?: string; // 'USD' | 'EUR' | 'IRR' | 'USDT' notes?: string; resolvedBy: ObjectId; resolvedAt: Date; } ``` > [!caution] Incorrect in previous docs: `decision: buyer|seller|split` and `refundAmount` do NOT exist in the model. The field is `action` with the six values listed above. ## Dispute categories (`Dispute.category`) Valid values: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other` > [!caution] `fraud` is NOT a valid category. Use `seller_behavior` or `other` for fraud-type reports. --- ## Security Gaps (Historical — All Closed as of 2026-05-30) The following bugs were identified in the 2026-05-29 audit and fixed in commits `1d881c5` and `fce8a19`. The descriptions below are preserved for historical reference and audit trail. ### 1. `PATCH /api/disputes/:id/status` — no role guard ✅ FIXED **Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can change dispute status. ### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard ✅ FIXED **Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can resolve disputes. **Additional fix (ISSUE-004, commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold automatically so the payment release is unblocked after resolution. ### 3. `POST /api/disputes/:id/assign` — no role guard ✅ FIXED **Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can assign mediators. --- ## Route Shadowing (Historical — Resolved as of 2026-05-30) Previously both routers were mounted at `/api/disputes`, causing the dashboard router to intercept release-hold requests. Fixed in commit `1d881c5` (ISSUE-003): ```ts // app.ts — current state app.use("/api/disputes", dashboardDisputeRoutes); // src/routes/disputeRoutes.ts app.use("/api/disputes/pr", disputeRoutes); // src/services/dispute/disputeRoutes.ts — new prefix ``` Release-hold endpoints now use the `/api/disputes/pr/` prefix: - `POST /api/disputes/pr/:purchaseRequestId/raise` - `GET /api/disputes/pr/:purchaseRequestId/status` - `POST /api/disputes/pr/:purchaseRequestId/resolve` --- ## 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` (`product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | 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`. 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[]`. The pre-save hook appends an automatic `dispute_created` timeline entry. - Creates a **`Chat` of type `group`** with the buyer (and seller, if resolved) 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: none fire.** The notification block is a TODO stub in `DisputeService.createDispute` (`:107-116`). > [!note] Release hold behavior > Opening a dispute through the release-hold router (`POST /api/disputes/:purchaseRequestId/raise`) sets hold fields on the purchase request and related payments via `releaseHoldService.raiseDispute()`. Release/refund gates can consult those fields. This is a separate code path from `DisputeService.createDispute` above. ### 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 }`. > [!danger] No role guard on this endpoint — any authenticated user can call it (see [Security Gaps](#security-gaps)). 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. - **No socket event fires.** (`// TODO: Notify buyer and seller via Socket.IO`) ### 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`. **No socket event fires for evidence uploads.** 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`. **No socket event fires.** > [!note] `PATCH /api/disputes/:id/status` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed. ### Phase 4 — Resolution 11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with: ```json { "action": "refund | replacement | compensation | warning_seller | ban_seller | no_action", "amount": 150, "currency": "USD", "notes": "Seller failed to deliver item" } ``` 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. - **Calls `releaseHoldResolve(purchaseRequestId)`** — this clears the escrow hold automatically so the payment release is unblocked (ISSUE-004 fix, commit `1d881c5`). - **No socket event fires.** (`// TODO: Send notifications via Socket.IO`) 13. **Financial side-effect:** as of commit `1d881c5` the escrow hold is cleared automatically on resolution. The admin still needs to separately trigger the ledger-gated release ([[Payout Flow]] / [[Escrow Flow]]) or refund for actual fund movement. > [!note] `POST /api/disputes/:id/resolve` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed. --- ## 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 } Note over IO: ⚠️ No socket events fire (TODO stubs) A->>FE: Admin dashboard, click "Pick up" FE->>BE: POST /api/disputes/{id}/assign Note right of BE: ⚠️ No role guard BE->>DB: dispute.adminId, status="in_progress", timeline.push BE->>DB: chat.participants.push(admin) BE-->>FE: { dispute } Note over IO: ⚠️ No socket events fire (TODO stubs) loop investigation A->>FE: Chat with B & S B-->>BE: POST /api/disputes/{id}/evidence (image) BE->>DB: dispute.evidence.push, timeline.push Note over IO: ⚠️ No socket events fire (TODO stubs) end A->>FE: Click "Resolve" choose action FE->>BE: POST /api/disputes/{id}/resolve { action, amount?, notes? } Note right of BE: ⚠️ No role guard (dashboard router) BE->>DB: dispute.status="resolved", resolution={action, amount, currency, notes, ...} alt action="refund" A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]] else action="replacement" A->>BE: arrange replacement item (manual) else action="compensation" A->>BE: partial payment to buyer (manual) else action="warning_seller" / "ban_seller" A->>BE: admin account action (manual) else action="no_action" A->>BE: dismiss dispute end BE-->>FE: { dispute } Note over IO: ⚠️ No socket events fire (TODO stubs) ``` --- ## API calls ### Dashboard router (`backend/src/routes/disputeRoutes.ts`) — mounted first at `/api/disputes` | Method | Endpoint | Auth | Role Guard | Notes | |---|---|---|---|---| | `POST` | `/api/disputes` | `authenticateToken` | None | Create dispute | | `GET` | `/api/disputes` | `authenticateToken` | None | List with filters | | `GET` | `/api/disputes/statistics` | `authenticateToken` | None | Aggregate stats | | `GET` | `/api/disputes/:id` | `authenticateToken` | None | Get by ID | | `POST` | `/api/disputes/:id/assign` | `authenticateToken` | **MISSING** ⚠️ | Self-assign possible | | `PATCH` | `/api/disputes/:id/status` | `authenticateToken` | **MISSING** ⚠️ | Any user can change status | | `POST` | `/api/disputes/:id/resolve` | `authenticateToken` | **MISSING** ⚠️ | Any user can resolve | | `POST` | `/api/disputes/:id/evidence` | `authenticateToken` | None | Add evidence | ### Release-hold router (`backend/src/services/dispute/disputeRoutes.ts`) — mounted second at `/api/disputes` | Method | Endpoint | Auth | Role Guard | Notes | |---|---|---|---|---| | `POST` | `/api/disputes/:purchaseRequestId/raise` | `authenticateToken` | Buyer or admin (inline check) | Sets hold fields on PurchaseRequest | | `POST` | `/api/disputes/:purchaseRequestId/resolve` | `authenticateToken` | `authorizeRoles('admin')` ✓ | Clears hold fields | | `GET` | `/api/disputes/:purchaseRequestId/status` | `authenticateToken` | Participant or admin (inline check) | Returns hold/block status | > [!warning] Route shadowing: `POST /api/disputes/:id/resolve` in the dashboard router (no guard, mounted first) will intercept requests before they reach the release-hold router's `POST /:purchaseRequestId/resolve` (has guard). See [Route Shadowing](#route-shadowing). --- ## 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`** — hold fields (`disputeRaised`, `disputeRaisedAt`, `disputeResolved`, `disputeResolvedAt`, `disputeHoldReason`, `holdUntil`) mutated by the release-hold service. Not touched by `DisputeService` directly. - **`payments`** — touched indirectly when the admin performs the financial resolution. - **`notifications`** — TODO; no writes happen today. ## Socket events emitted > [!warning] None of the following events actually fire. Every emit block in `DisputeService` is commented out as a TODO stub. Planned events (not yet implemented): - **`new-notification`** → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, and resolution. - **`dispute-updated`** → planned but not implemented. The only real-time activity in the dispute flow today is through the standard **Chat** socket (`new-message` on `chat-{disputeChatId}`) when participants send chat messages — this flows through `ChatService.sendMessage`, which is separate from the dispute service and does emit. ## 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. The pre-save hook auto-appends `dispute_created` on insert. Surface this 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 only, no seller). 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 a unique index on `(purchaseRequestId, status)` filtered to `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 completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution action. - **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`. - **Route collision** → both routers share `/api/disputes`. See [Route Shadowing](#route-shadowing) for details and recommendation. > [!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 `refund` / `compensation` resolutions. - [[Notification Flow]] — channels for dispute alerts (not yet wired). - [[Delivery Confirmation Flow]] — disputes often arise from failed delivery. ## Source files - `backend/src/services/dispute/DisputeService.ts` — core service logic - `backend/src/services/dispute/disputeRoutes.ts` — release-hold router (admin-guarded resolve) - `backend/src/services/dispute/releaseHoldService.ts` — hold field helpers - `backend/src/routes/disputeRoutes.ts` — dashboard/controller router (missing role guards) - `backend/src/models/Dispute.ts` — canonical schema and enums - `backend/src/app.ts` lines 521 and 585 — mount order (shadowing risk) - `frontend/src/sections/request/components/report-problem-to-admin.tsx` - `frontend/src/sections/admin/` — admin dispute dashboard (subject to organisation)