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

12 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Dispute Flow
flow
dispute
mediator
evidence
chat
state-machine
Dispute
Chat
PurchaseRequest
Payment
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.
  • BackendDisputeService (backend/src/services/dispute/DisputeService.ts), DisputeController (backend/src/controllers/disputeController.ts), routes at backend/src/routes/disputeRoutes.ts.
  • MongoDBdisputes, chats, purchaserequests, payments.
  • Socket.IOnew-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)

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): refund, partial, release, reject (the wording differs slightly in the model — verify with backend/src/models/Dispute.ts).

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.sellerIdselectedOffer.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.

[!warning] Dispute does not auto-pause escrow Today, opening a dispute does not flip Payment.escrowState away from funded. An admin could theoretically still release the escrow before resolving the dispute. Until a disputed flag is added to Payment, admins must check the dispute table before any release/refund action.

Phase 2 — Admin assignment

  1. The admin dispute dashboard lists pending disputes (sorted by priority: -1, createdAt: -1).
  2. Admin clicks "Pick up" → POST /api/disputes/:id/assign with { adminId } (currently the admin's own id).
  3. 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

  1. 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/evidenceDisputeService.addEvidence (:305-337) appends to dispute.evidence[] and writes a timeline entry evidence_added.
  2. 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

  1. Once the admin has enough information, they call POST /api/disputes/:id/resolve with { action, amount?, currency?, notes? }.
  2. 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.
  3. Financial side-effect (manual today): depending on the action, the admin then triggers either the payout (Payout Flow with kind: 'release') or the refund (kind: 'refund', see Escrow Flow). The dispute service does not automatically dispatch the on-chain action.
  4. Both parties are notified (TODOs in code — planned: notifyDisputeResolved).

Sequence diagram

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:12DisputeController.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.
  • notificationsTODO markers in code; planned addition.

Socket events emitted

  • new-messagechat-{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 missing400 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. Add automation that auto-fires the payout/refund when the admin selects release or refund.
  • 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

Source files

  • Backend: backend/src/services/dispute/DisputeService.ts
  • Backend: backend/src/controllers/disputeController.ts
  • Backend: backend/src/routes/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)