Files
nick-doc/04 - Flows/Negotiation Flow.md
Siavash Sameni 7a616744f4 docs: complete code-reality alignment for remaining docs + reconcile issue set
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>
2026-05-29 15:15:02 +04:00

12 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Negotiation Flow
flow
marketplace
negotiation
counter-offer
chat
SellerOffer
PurchaseRequest
Chat
PUT /api/marketplace/offers/:id
POST /api/chat
POST /api/chat/:chatId/messages

Negotiation Flow

Last updated: 2026-05-29 — aligned with code (see Doc vs Code Audit Report)

After an offer is submitted (Seller Offer Flow), the buyer and seller can negotiate the price/ETA via counter-offers exchanged through the chat. The request status moves to in_negotiation, and either party can finalise with accept/reject.

Actors

  • Buyer — initiates negotiation by replying to an offer or opening chat from the request detail.
  • Seller — receives counter, can accept or counter back.
  • Frontend — chat component (frontend/src/sections/chat/) overlaid on the request view; offer-edit modal under frontend/src/sections/request/components/buyer-steps/step-3-components/.
  • BackendChatService.sendMessage for chat lines, SellerOfferService.updateOffer for price/ETA edits, PurchaseRequestService.updatePurchaseRequest for the status flip.
  • MongoDBchats, selleroffers, purchaserequests.
  • Socket.IOnew-message, purchase-request-update.

Preconditions

  • A SellerOffer exists on the purchase request (status pending).
  • The purchase request is received_offers or in_negotiation.
  • Both parties are still active users.

[!info] Status vocabulary The negotiation drives the PurchaseRequest into the in_negotiation status. The SellerOffer moves only between pending, accepted, rejected, and withdrawn (backend/src/models/SellerOffer.ts:80). There is no 'active' SellerOffer status — any documentation or UI that references an "active" offer is incorrect.

Step-by-step narrative

  1. Open negotiation chat — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls POST /api/chat to find-or-create a direct chat tied to the purchase request (ChatService.createChat, chat.ts:90-192). The chat's relatedTo = { type: 'PurchaseRequest', id } makes it discoverable from the request view.

[!tip] Pre-payment chats vs. post-payment chats A negotiation chat may exist before payment confirmation creates the post-payment chat. The ChatService.createChat direct find-or-create logic (ChatService.ts:95-108) prevents duplicates -- the same chat object is reused.

  1. Status flip to in_negotiation — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls PurchaseRequestService.updatePurchaseRequest with { status: 'in_negotiation' }. The status-progression guard allows this (received_offers → in_negotiation).

  2. Buyer proposes a counter — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used:

    • Free-form — just a chat message; the seller eyeballs the offer-edit screen and updates the price.
    • Structured counter — the buyer opens an "edit offer" modal that (via the frontend updateOffer action) sends PUT /api/marketplace/offers/{id} with the new desired terms. This is a seller-side edit endpoint; structured buyer counters typically come as a system message in the chat (messageType: 'system') referencing the new price.
  3. Seller updates the offerSellerOfferService.updateOffer (:271-295):

    • SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true }).
    • Emits purchase-request-update with eventType: 'offer-updated' to request-{requestId} (SellerOfferService.ts:284-288) — both parties' open tabs refresh.

[!bug] ⚠️ KNOWN BUG — PUT/PATCH method mismatch on offer edit The frontend updateOffer action (frontend/src/actions/marketplace.ts:286-297) sends PUT /marketplace/offers/:id, but the legacy backend router registers only PATCH /offers/:id (backend/src/services/marketplace/routes.ts:1260). No PUT /offers/:id handler is registered, so structured offer edits from the UI may 404. Fix by aligning on a single method (register PUT on the backend, or switch the frontend to PATCH).

  1. Buyer accepts -- clicks "Accept this offer", which kicks off PRD - Request Network In-House Checkout with the selected sellerOfferId. Payment confirmation flips offer -> accepted and request -> payment.

  2. Buyer rejects — the frontend rejectOffer action calls PUT /api/marketplace/offers/{id}/status with { status: 'rejected' }. SellerOfferService.updateOfferStatus (:306-353) sends notifyOfferRejected to the seller and stamps rejectedAt + rejectionReason.

  3. Seller withdraws — there is no dedicated /withdraw endpoint (see warning below). The only way to withdraw is PUT /api/marketplace/offers/{id}/status with { status: 'withdrawn' } (routes.ts:1914). withdrawOffer (:428-443) only works while status === 'pending'. After rejection/acceptance, withdrawal is impossible.

  4. Chat continues — even after status flips, the chat remains open for clarifications (delivery details, dispute prep). See Chat Flow for message-level semantics.

[!warning] ⚠️ NOT IMPLEMENTED — POST /api/marketplace/offers/:id/withdraw No POST .../offers/:id/withdraw route is registered anywhere in the backend; calling it returns 404. Withdrawal is performed exclusively through the status endpoint: PUT /api/marketplace/offers/:id/status with body { status: 'withdrawn' }.

Sequence diagram

sequenceDiagram
    autonumber
    actor B as Buyer
    actor S as Seller
    participant FE_B as Frontend (buyer)
    participant FE_S as Frontend (seller)
    participant BE as Backend
    participant DB as MongoDB
    participant IO as Socket.IO

    B->>FE_B: Click "Chat with seller" on offer
    FE_B->>BE: POST /api/chat (type:direct, relatedTo:PR)
    BE->>DB: find-or-create Chat
    BE-->>FE_B: { chat }
    B->>FE_B: Send "Can you do $80?"
    FE_B->>BE: POST /api/chat/{id}/messages
    BE->>DB: Chat.addMessage(...)
    BE->>IO: emit chat-{id} 'new-message'
    IO-->>FE_S: 'new-message' (seller sees in real time)
    BE->>BE: optionally trigger request → in_negotiation
    BE->>DB: PurchaseRequest.status = "in_negotiation"
    BE->>IO: emit request-{id} 'purchase-request-update' (status-changed)
    S->>FE_S: Open edit-offer modal, set new price
    FE_S->>BE: PUT /api/marketplace/offers/{id} {price:{amount:80}} ⚠️ backend only registers PATCH
    BE->>DB: SellerOffer update (if PUT handled; else 404)
    BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
    IO-->>FE_B: refresh offer card
    alt Buyer accepts
        B->>FE_B: Click "Pay" -> [[PRD - Request Network In-House Checkout]]
        Note over BE: Webhook PAID flips offer→accepted, request→payment
    else Buyer rejects
        B->>FE_B: Click "Reject"
        FE_B->>BE: PUT /api/marketplace/offers/{id}/status {status:"rejected"}
        BE->>DB: offer.status = "rejected"
        BE->>BE: notifyOfferRejected(seller)
        IO-->>FE_S: 'new-notification'
    else Seller withdraws
        S->>FE_S: Click "Withdraw offer"
        FE_S->>BE: PUT /api/marketplace/offers/{id}/status {status:"withdrawn"}
        BE->>DB: offer.status = "withdrawn"
        BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
    end

API calls

Method Endpoint Purpose
POST /api/chat Find-or-create negotiation chat
POST /api/chat/:chatId/messages Send chat message
POST /api/marketplace/purchase-requests/:id/offers Create offer (scoped) — routes.ts:1163
GET /api/marketplace/purchase-requests/:id/offers List offers for a request (scoped) — routes.ts:1223
PUT /api/marketplace/offers/:id Seller updates price / ETA / notes (counter). ⚠️ KNOWN BUG: frontend sends PUT, backend registers only PATCH /offers/:id (routes.ts:1260) → may 404.
PUT /api/marketplace/offers/:id/status Reject ({ status: 'rejected' }) and withdraw ({ status: 'withdrawn' }) — routes.ts:1914. There is no separate /withdraw endpoint.
PATCH /api/marketplace/purchase-requests/:id Status transition to in_negotiation

Database writes

  • chats: messages appended via chat.addMessage; metadata.lastActivity bumped; unreadCounts incremented for non-sender participants.
  • selleroffers: counter changes update price, deliveryTime, notes, updatedAt; status moves between pending/accepted/rejected/withdrawn.
  • purchaserequests: status flips when first counter arrives.
  • notifications: created per status change (accept/reject), not per chat message (chat has its own real-time channel).

Socket events emitted

  • new-messagechat-{chatId} (the canonical chat event).
  • chat-notificationuser-{participantId} for non-senders (badge increment).
  • purchase-request-update with eventType: 'offer-updated'request-{id} whenever the offer is edited.
  • purchase-request-update with eventType: 'status-changed'request-{id} on the received_offers → in_negotiation flip.

Side effects

  • The chat's unread badge grows in the recipient's chat list (Chat.unreadCounts array). Resets when they open the chat and POST /api/chat/{id}/read.
  • Typing indicators are emitted via typing-start / typing-stop socket events (see backend/src/app.ts:142-158) — purely client-driven.

Error / edge cases

  • Offer edit returns 404 — see the KNOWN BUG above (PUT vs PATCH method mismatch).
  • Sender not a chat participant403 "User is not a participant in this chat" (ChatService.sendMessage:209-211).
  • Counter on an offer the buyer doesn't own a request for → blocked by the controller (the buyer must be the request's owner).
  • Counter on an accepted offerupdateOffer does not enforce status; the schema/controller should reject. Current code allows the price change, which is dangerous post-payment. Recommended hardening: guard with if (offer.status !== 'pending') throw.
  • Withdraw after accept/rejectwithdrawOffer only acts while status === 'pending', so withdrawal is rejected once the offer leaves that state.
  • Status regression attempt (in_negotiation → received_offers) → blocked by isValidStatusProgression (PurchaseRequestService.ts:31-50).
  • Two simultaneous edits — last-write-wins on findByIdAndUpdate; consider optimistic concurrency via __v if conflicts become an issue.
  • Chat created in negotiation but buyer never pays → orphan chat remains; the post-payment chat (in Chat Flow) reuses it because the find-or-create logic matches by participants + relatedTo.

[!warning] No structured "counter-offer object" Today, counter-offer negotiations are mostly free-form chat plus an updateOffer edit. There is no CounterOffer collection with provenance. If audit/regulatory needs emerge, capture each counter as a snapshot ({ oldPrice, newPrice, byUserId, atTime }) on the offer.

Linked flows

Source files

  • Backend: backend/src/services/marketplace/SellerOfferService.ts:271-443
  • Backend: backend/src/services/marketplace/routes.ts:1163-1278,1914 (offer routes)
  • Backend: backend/src/services/marketplace/PurchaseRequestService.ts:408-495
  • Backend: backend/src/services/chat/ChatService.ts:90-260
  • Backend: backend/src/models/SellerOffer.ts:17,80 (status enum)
  • Frontend: frontend/src/actions/marketplace.ts:286-308 (updateOffer, rejectOffer)
  • Frontend: frontend/src/sections/request/components/buyer-steps/step-3-components/
  • Frontend: frontend/src/sections/chat/ (chat UI)