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>
172 lines
12 KiB
Markdown
172 lines
12 KiB
Markdown
---
|
|
title: Negotiation Flow
|
|
tags: [flow, marketplace, negotiation, counter-offer, chat]
|
|
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"]
|
|
related_apis: ["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](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
|
|
|
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`, `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.
|
|
|
|
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 (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.
|
|
|
|
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.
|
|
|
|
> [!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`).
|
|
|
|
5. **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`.
|
|
|
|
6. **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`.
|
|
|
|
7. **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.
|
|
|
|
8. **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
|
|
|
|
```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: 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-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
|
|
|
|
- **Offer edit returns 404** — see the KNOWN BUG above (PUT vs PATCH method mismatch).
|
|
- **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`.
|
|
- **Withdraw after accept/reject** → `withdrawOffer` 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
|
|
|
|
- [[Seller Offer Flow]] — the prior step.
|
|
- [[PRD - Request Network In-House Checkout]] — 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-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)
|