Files
nick-doc/04 - Flows/Dispute Flow.md
Siavash Sameni a1f056e6a5 docs: align flow docs with code reality + create 35 implementation issue files
Flow docs updated (11 files):
- Delivery Confirmation: reversed actor roles (buyer generates, seller verifies),
  fixed endpoint paths (/delivery-code/generate, /delivery-code/verify)
- Passkey (WebAuthn): removed stub/simulated-key claims; real @simplewebauthn/server
  attestation is implemented; refresh tokens are persisted
- Dispute: corrected resolve schema (action enum), removed non-existent statuses,
  documented security gaps (no role guards on status/resolve/assign), route shadowing,
  all socket events are TODO stubs
- Seller Offer: corrected all endpoint paths, removed 'active' status, documented
  withdraw dead code, missing seller history page, select-offer notification gap
- Notification: corrected mark-all-read method+path, fixed GET /:id broken lookup,
  added unread-count-update socket event
- Authentication: corrected rate limiter (counts all attempts), axios 403 not handled,
  deleteAccount wrong endpoint bug, changePassword no UI
- Password Reset: corrected 6-digit code (not 8), documented no-complexity gap on
  reset-with-code vs token reset
- Payment Flow DePay: /create→/save, removed phantom sub-routes, SIM_ bypass risk,
  PaymentProvider type gap, getProviderIntentEndpoint routing bug
- Payment Flow SHKeeper: removed phantom polling endpoint, fixed release/refund paths
- Purchase Request: added pending_payment/active statuses, fixed sellers/attachments
  endpoints, corrected socket events, PUT→PATCH bug
- Escrow: documented dispute resolve does not touch escrow, route shadowing, confirm-delivery auth gap

Issues created (35 files in Issues/):
- 9 security issues (critical) including: dispute privilege escalation ×4,
  unauthenticated payment/scanner endpoints ×2, SIM_ production bypass,
  confirm-delivery ownership gap
- 26 additional major/critical bugs covering broken endpoints, missing features,
  data integrity gaps, and frontend-backend mismatches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:47:49 +04:00

19 KiB

title, tags, related_models, related_apis, audit
title tags related_models related_apis audit
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
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.

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 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.
  • BackendDisputeService (backend/src/services/dispute/DisputeService.ts), dashboard/controller routes at backend/src/routes/disputeRoutes.ts (mounted first at /api/disputes), and release-hold helpers in backend/src/services/dispute/disputeRoutes.ts (mounted second at /api/disputes).
  • MongoDBdisputes, 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.

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

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:

  1. POST /api/disputes/:id/resolve — the dashboard router (mounted first) exposes POST /:id/resolve with no role guard. A request intended for the release-hold router's POST /:purchaseRequestId/resolve (which does require admin) will be intercepted and handled by the wrong, unguarded handler when a matching dispute _id is supplied.

  2. 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 /raise route, 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

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

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

[!danger] No role guard on this endpoint — any authenticated user can call it (see Security Gaps).

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

  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. No socket event fires for evidence uploads.
  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. No socket event fires.

[!danger] PATCH /api/disputes/:id/status has no role guard — any authenticated user can change dispute status (see Security Gaps).

Phase 4 — Resolution

  1. Once the admin has enough information, they call POST /api/disputes/:id/resolve with:
    {
      "action": "refund | replacement | compensation | warning_seller | ban_seller | no_action",
      "amount": 150,
      "currency": "USD",
      "notes": "Seller failed to deliver item"
    }
    
  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.
    • No socket event fires. (// TODO: Send notifications via Socket.IO)
  3. 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 including ban_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/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.


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-notificationuser-{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 missing400 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 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

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)