12 KiB
title, tags, related_models, related_apis
| title | tags | related_models | related_apis | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Dispute Flow |
|
|
|
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 atbackend/src/routes/disputeRoutes.ts, and release-hold helpers inbackend/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
PurchaseRequestexists. - 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 (intended design)): refund, partial, release, reject.
Step-by-step narrative
Phase 1 — Opening
- Buyer or seller opens the request detail and clicks "Report problem" (
frontend/src/sections/request/components/report-problem-to-admin.tsx). - They select a
category(delivery, payment, quality, fraud, other), apriority(low | medium | high | urgent), write adescription, and optionally uploadevidence(images, screenshots, video, document) viaPOST /api/files/upload. - Frontend POSTs
POST /api/disputeswith{ purchaseRequestId, reason, description, priority, category, evidence: [...] }. - Backend
DisputeService.createDispute(:12-119):- Loads the purchase request with
populate('selectedOfferId'). - Resolves the counter-party
sellerIdby priority: explicitdata.sellerId→selectedOffer.sellerId→ first ofpreferredSellerIds. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list. - Creates the
Disputewithstatus: 'pending',responseDeadline = now + 48h,deadline = now + 7 days, and an emptytimeline[]. - Creates a
Chatof typegroupwith the buyer and the resolved seller as participants. The opening message is a system-typed line"اختلاف جدید ایجاد شد: {reason}". The chat'srelatedTo = { type: 'PurchaseRequest', id }. - Persists
dispute.chatId = chat._id.
- Loads the purchase request with
- Notifications (currently a
TODOin the service —:107-116) should firenew-notificationto the seller. Today the chat creation alone provides real-time presence via thenew-messagesocket emit insideChat.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 canonicalDISPUTEDescrow state.
Phase 2 — Admin assignment
- The admin dispute dashboard lists pending disputes (sorted by
priority: -1, createdAt: -1). - Admin clicks "Pick up" →
POST /api/disputes/:id/assignwith{ adminId }(currently the admin's own id). DisputeService.assignAdmin(:184-223):dispute.adminId = adminId; dispute.status = 'in_progress'.- Appends
timelineentry{ action: 'admin_assigned', performedBy: adminId, ... }. - Adds the admin to the dispute
chat.participants[](roleadmin). - Saves.
Phase 3 — Investigation
- 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 todispute.evidence[]and writes atimelineentryevidence_added. - The admin may also
PATCH /api/disputes/:id/statuswith intermediate states or notes; this updatesdispute.statusand writes atimelineentrystatus_changed.
Phase 4 — Resolution
- Once the admin has enough information, they call
POST /api/disputes/:id/resolvewith{ action, amount?, currency?, notes? }. DisputeService.resolveDispute(:262-300):dispute.status = 'resolved'dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }dispute.closedAt = now- Appends
timelineentrydispute_resolved. - Saves.
- 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.
- 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: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; updatesadminId,status,timeline[],evidence[],resolution,closedAtover the lifecycle.chats— newgroupchat on open; admin appended toparticipants[]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—TODOmarkers in code; planned addition.
Socket events emitted
new-message→chat-{disputeChatId}for each chat line (via the standardChatService.sendMessageand the system message created inDisputeService.createDispute).new-notification(planned) →user-{buyerId}anduser-{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/uploadand 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 ingetDisputesensuresurgentones 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
releaseresolutions. - 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)