Remaining docs updated to match code (the docs that the first pass had not covered):
- Flows: Chat, Referral, Rating, Registration, Google OAuth, Negotiation, Payout,
Trezor Safekeeping — corrected endpoints, socket events, status enums, auth gaps
- API Reference: User API, Trezor API — admin route prefix/verb/status corrections,
added undocumented endpoints (ton-proof challenge, profile email verify,
GET /trezor/account, POST /trezor/verify-operation)
- Data Models: Chat, Notification, Payment, PointTransaction, User — corrected
enums (PaymentProvider, escrowState, PointTransaction.type, User.status),
90-day notification TTL, soft-delete semantics, wallet fields
Trezor "zero frontend" finding (audit C31/C32) corrected as STALE:
- Verified current code HAS a full frontend Trezor implementation (admin/trezor
page, TrezorSettingsView, trezorConnector via @trezor/connect-web,
TrezorSignDialog, actions/trezor.ts building the {message,signature} object)
- Fixed Trezor Safekeeping Flow doc (removed false "no frontend" warnings)
- Reclassified ISSUE-012 as invalid/superseded with explanation
Issue set reconciled to a single canonical numbering (ISSUE-001..054):
- Adopted the comprehensive 51-issue set (long-slug, fully indexed)
- Removed 35 superseded short-slug duplicates from the first pass
- Removed a duplicate ISSUE-046 file
- Added 3 issues the 51-set lacked: ISSUE-052 (completed-not-counted-in-stats),
ISSUE-053 (axios 401-only interceptor), ISSUE-054 (rate limiter counts all attempts)
- Regenerated Issues Index: 53 open (14 critical, 39 major) + 1 invalid
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
title, tags, related_models, related_apis, audit
| title | tags | related_models | related_apis | audit | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Dispute Flow |
|
|
|
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.
[!danger] SECURITY — Three open privilege-escalation bugs exist as of this audit. See Security Gaps below.
[!warning] Real-time events not implemented Every Socket.IO emit in
DisputeServiceis currently commented out. Nodispute-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.
- Backend —
DisputeService(backend/src/services/dispute/DisputeService.ts), dashboard/controller routes atbackend/src/routes/disputeRoutes.ts(mounted first at/api/disputes), and release-hold helpers inbackend/src/services/dispute/disputeRoutes.ts(mounted second at/api/disputes). - MongoDB —
disputes,chats,purchaserequests,payments. - Socket.IO — no events fire today; all emits are TODO stubs (see warning above).
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)
Valid status values (from Dispute.ts): pending | in_progress | waiting_response | resolved | rejected | closed.
[!caution]
under_reviewdoes NOT exist. The correct progressed status isin_progress.
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)
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|splitandrefundAmountdo NOT exist in the model. The field isactionwith the six values listed above.
Dispute categories (Dispute.category)
Valid values: product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other
[!caution]
fraudis NOT a valid category. Useseller_behaviororotherfor fraud-type reports.
Security Gaps
1. PATCH /api/disputes/:id/status — no role guard
File: backend/src/routes/disputeRoutes.ts line 26
router.patch('/:id/status', DisputeController.updateStatus);
Despite comments in the router saying "admin only", there is no authorizeRoles middleware. Any authenticated buyer or seller can call this endpoint and change a dispute's status to resolved or closed, bypassing the admin resolution flow entirely. This is an open privilege-escalation bug.
2. POST /api/disputes/:id/resolve (dashboard router) — no role guard
File: backend/src/routes/disputeRoutes.ts line 29
router.post('/:id/resolve', DisputeController.resolveDispute);
No role guard. Any authenticated user can post a resolution — including action: 'ban_seller'. Note that the release-hold router's POST /:purchaseRequestId/resolve (backend/src/services/dispute/disputeRoutes.ts line 77) does correctly apply authorizeRoles('admin'). The dashboard router's resolve endpoint does not.
3. POST /api/disputes/:id/assign — no role guard
File: backend/src/routes/disputeRoutes.ts line 23
router.post('/:id/assign', DisputeController.assignAdmin);
Any authenticated user can call this with their own user ID in { adminId } and self-assign as mediator for any dispute.
Route Shadowing
Both routers are mounted at /api/disputes in app.ts:
// app.ts line 521 — mounted FIRST
app.use("/api/disputes", dashboardDisputeRoutes); // src/routes/disputeRoutes.ts
// app.ts line 585 — mounted SECOND
app.use("/api/disputes", disputeRoutes); // src/services/dispute/disputeRoutes.ts
Express evaluates routes in registration order. This creates two concrete hazards:
-
POST /api/disputes/:id/resolve— the dashboard router (mounted first) exposesPOST /:id/resolvewith no role guard. A request intended for the release-hold router'sPOST /:purchaseRequestId/resolve(which does require admin) will be intercepted and handled by the wrong, unguarded handler when a matching dispute_idis supplied. -
POST /api/disputes/:purchaseRequestId/raise— this route exists only in the second (release-hold) router. It will be reached correctly only if the dashboard router does not first match the path. Since the dashboard router has no/raiseroute, requests pass through. However, as more routes are added to either router, collisions will grow silently.
Recommendation: Separate the two routers onto distinct path prefixes (e.g. /api/disputes for the dashboard controller, /api/disputes/hold for the release-hold service).
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(product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | 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. 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[]. The pre-save hook appends an automaticdispute_createdtimeline entry. - Creates a
Chatof typegroupwith the buyer (and seller, if resolved) 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: 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 viareleaseHoldService.raiseDispute(). Release/refund gates can consult those fields. This is a separate code path fromDisputeService.createDisputeabove.
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 }.
[!danger] No role guard on this endpoint — any authenticated user can call it (see Security Gaps).
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.
- No socket event fires. (
// TODO: Notify buyer and seller via Socket.IO)
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. No socket event fires for evidence uploads. - The admin may also
PATCH /api/disputes/:id/statuswith intermediate states or notes; this updatesdispute.statusand writes atimelineentrystatus_changed. No socket event fires.
[!danger]
PATCH /api/disputes/:id/statushas no role guard — any authenticated user can change dispute status (see Security Gaps).
Phase 4 — Resolution
- Once the admin has enough information, they call
POST /api/disputes/:id/resolvewith:{ "action": "refund | replacement | compensation | warning_seller | ban_seller | no_action", "amount": 150, "currency": "USD", "notes": "Seller failed to deliver item" } DisputeService.resolveDispute(:262-300):dispute.status = 'resolved'dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }dispute.closedAt = now- Appends
timelineentrydispute_resolved. - Saves.
- No socket event fires. (
// TODO: Send notifications via Socket.IO)
- Financial side-effect (manual today): depending on the action, the admin then triggers either the release (Payout Flow / Escrow Flow) or the refund as a separate step. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
[!danger]
POST /api/disputes/:id/resolve(dashboard router) has no role guard — any authenticated user can post any resolution action includingban_seller(see Security Gaps).
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 }
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/resolvein the dashboard router (no guard, mounted first) will intercept requests before they reach the release-hold router'sPOST /:purchaseRequestId/resolve(has guard). See Route Shadowing.
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— hold fields (disputeRaised,disputeRaisedAt,disputeResolved,disputeResolvedAt,disputeHoldReason,holdUntil) mutated by the release-hold service. Not touched byDisputeServicedirectly.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
DisputeServiceis commented out as a TODO stub.
Planned events (not yet implemented):
new-notification→user-{buyerId}anduser-{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_createdon 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 topending|in_progressto 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 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 for details and recommendation.
[!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
refund/compensationresolutions. - 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 logicbackend/src/services/dispute/disputeRoutes.ts— release-hold router (admin-guarded resolve)backend/src/services/dispute/releaseHoldService.ts— hold field helpersbackend/src/routes/disputeRoutes.ts— dashboard/controller router (missing role guards)backend/src/models/Dispute.ts— canonical schema and enumsbackend/src/app.tslines 521 and 585 — mount order (shadowing risk)frontend/src/sections/request/components/report-problem-to-admin.tsxfrontend/src/sections/admin/— admin dispute dashboard (subject to organisation)