--- title: Negotiation Flow tags: [flow, marketplace, negotiation, counter-offer, chat] related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"] related_apis: ["PATCH /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"] --- # Negotiation Flow 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/`. - **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip. - **MongoDB** — `chats`, `selleroffers`, `purchaserequests`. - **Socket.IO** — `new-message`, `seller-offer-update`, `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. ## 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** the SHKeeper webhook auto-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. 2. **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`). 3. **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 PATCHes `/api/marketplace/offers/{id}` with the new desired terms. This is currently a seller-only edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price. 4. **Seller updates the offer** — `SellerOfferService.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. 5. **Buyer accepts** — clicks "Accept this offer", which kicks off [[Payment Flow - SHKeeper]] with the (now-updated) `sellerOfferId`. The webhook flips offer → `accepted` and request → `payment`. 6. **Buyer rejects** — calls `PATCH /api/marketplace/offers/{id}` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`. 7. **Seller withdraws** — `withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible. 8. **Chat continues** — even after status flips, the chat remains open for clarifications (delivery details, dispute prep). See [[Chat Flow]] for message-level semantics. ## Sequence diagram ```mermaid 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: PATCH /api/marketplace/offers/{id} {price:{amount:80}} BE->>DB: SellerOffer update BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated) IO-->>FE_B: refresh offer card alt Buyer accepts B->>FE_B: Click "Pay" → [[Payment Flow - SHKeeper]] Note over BE: Webhook PAID flips offer→accepted, request→payment else Buyer rejects B->>FE_B: Click "Reject" FE_B->>BE: PATCH /api/marketplace/offers/{id} {status:"rejected"} BE->>DB: offer.status = "rejected" BE->>BE: notifyOfferRejected(seller) IO-->>FE_S: 'new-notification' end ``` ## API calls | Method | Endpoint | Purpose | |---|---|---| | `POST` | `/api/chat` | Find-or-create negotiation chat | | `POST` | `/api/chat/:chatId/messages` | Send chat message | | `PATCH` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter) | | `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` | | `POST` | `/api/marketplace/offers/:id/withdraw` | Seller pulls the offer | ## 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`. - **`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-message`** → `chat-{chatId}` (the canonical chat event). - **`chat-notification`** → `user-{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 - **Sender not a chat participant** → `403 "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` offer** → `updateOffer` 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`. - **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 - [[Seller Offer Flow]] — the prior step. - [[Payment Flow - SHKeeper]] — closes the negotiation with an on-chain payment. - [[Chat Flow]] — message-level mechanics, attachments, read receipts. - [[Notification Flow]] — accept/reject notifications. ## Source files - Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-353` - Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495` - Backend: `backend/src/services/chat/ChatService.ts:90-260` - Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/` - Frontend: `frontend/src/sections/chat/` (chat UI)