--- title: Seller Offer Flow tags: [flow, marketplace, seller, offer] related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"] related_apis: ["POST /api/marketplace/purchase-requests/:id/offers", "GET /api/marketplace/purchase-requests/:id/offers", "PATCH /api/marketplace/offers/:id"] --- > **Last updated:** 2026-05-30 — updated for offer-management page, `withdrawOffer` action, edit-while-pending, `getSellerOffers` API (commits 240a668–e7d1375) # Seller Offer Flow A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[PRD - Request Network In-House Checkout]]) or reject. ## Actors - **Seller** — proposes an offer. - **Buyer** — receives the offer in their request detail view. - **Frontend (seller)** — `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` and the seller marketplace listing under `frontend/src/app/dashboard/seller/marketplace/`. - **Frontend (buyer)** — `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx` for the offer chooser. - **Backend** — `SellerOfferService` (`backend/src/services/marketplace/SellerOfferService.ts`) and controller routes. - **MongoDB** — `selleroffers` collection. - **Socket.IO** — `seller-offer-update` and `purchase-request-update` events. - **Notification service** — buyer notifications. ## Preconditions - Seller is authenticated, `role === "seller"`, `status === "active"`. - Target purchase request exists and `status` is `pending`, `received_offers`, or `active` (`SellerOfferService.ts:83-85`). - Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`). ## Offer state machine ```mermaid stateDiagram-v2 [*] --> pending: createOffer() pending --> withdrawn: seller withdraws (only while pending) pending --> rejected: another offer accepted\nor buyer rejects this one pending --> accepted: acceptOffer()\nor payment confirmed accepted --> [*] rejected --> [*] withdrawn --> [*] pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron) ``` The valid `SellerOffer` statuses are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). There is **no** `active` status for `SellerOffer`. `validUntil` expirations are converted to `withdrawn`. ## Step-by-step narrative ### Discovery 1. Seller opens `/dashboard/seller/marketplace`. The page hits `GET /api/marketplace/purchase-requests?sellerId={me}` and renders cards. 2. Filtering rules: only `pending` or `received_offers` requests where the seller is public-eligible or in `preferredSellerIds` (see visibility table in [[Purchase Request Flow]]). 3. Clicking a card navigates to `/dashboard/seller/marketplace/request/{id}`; the seller sees the buyer's description, attachments, address city/region (full address withheld), and existing offers if visible. ### Submission 4. Seller clicks "Send proposal" — opens `step-1-send-proposal.tsx`. Fields: - **Title** (defaults to a derivative of the request title) - **Description / notes** - **Price** (amount + currency, default USDT) - **Delivery time** (amount + unit: hours / days / weeks) - **Attachments** (optional, via `POST /api/files/upload`) - **Valid until** (optional expiry) 5. Frontend POSTs `POST /api/marketplace/purchase-requests/:id/offers` (the `purchaseRequestId` is a **path parameter**, not a body field). 6. Backend `SellerOfferService.createOffer` (`:51-140`): - **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد داده‌اید"` (`:74`). Use `updateOffer` to amend. - **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending`, `received_offers`, or `active`. - Saves the offer (`status: "pending"` by default in the schema). - Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response. 7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly. 8. **Status auto-progression**: if this is the **first** offer on a `pending` request, the request transitions to `received_offers` (`:107-122`). 9. **Buyer notification**: `notificationService.notifyNewOfferReceived(buyerId, requestId, requestTitle, sellerName)` writes a `Notification` and emits to `user-{buyerId}`. 10. Response: `200 { offer }` (populated). ### Buyer review 11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/purchase-requests/:id/offers` — `SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`. 12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes. 13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment. ### Accept / Select Offer → Payment 14. The buyer selects an offer via `POST /api/marketplace/purchase-requests/:id/select-offer`. **Important**: this endpoint fires only a generic `purchase-request-update` event to the `request-{requestId}` room. No per-seller socket events or notifications are sent to the winning or losing sellers at this stage. 15. The buyer's "Pay this offer" button kicks off [[PRD - Request Network In-House Checkout]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; payment confirmation does that atomically when the on-chain payment is confirmed. 16. On Request Network payment confirmation: - The selected offer's `status` → `accepted`. - All other offers on the same request → `rejected` via `SellerOffer.updateMany`. - The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`. - A direct chat is created (see [[Chat Flow]]). - Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path). - Socket events notify the winner and reject/close competing offers. ### Edit / withdrawal while awaiting buyer acceptance 17. While a request is in `received_offers` status (buyer has not yet accepted), the seller may **edit** their pending offer or **withdraw** it entirely from the request-detail step-2 card (`step-2-waiting-for-payment.tsx`). - **Edit**: toggles `mode` to `'edit'` inside `Step2WaitingForPayment`, re-mounts `Step1SendProposal` pre-populated with the existing offer values. On save, calls `PATCH /api/marketplace/offers/:id` (via `updateOffer` action, which now correctly uses `PATCH` instead of the old `PUT`). - **Withdraw**: opens a `ConfirmDialog`, then calls `withdrawOffer(offerId)` in `src/actions/marketplace.ts` which uses `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`. `canManageOffer` is only `true` when `requestDetails?.status === 'received_offers'`; once the buyer accepts and the status advances, both buttons are hidden. The DB filter `{ status: 'pending' }` inside `SellerOfferService.withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`. > ⚠️ `POST /api/marketplace/offers/:id/withdraw` still does **not** exist as an HTTP route. Always use `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`. ### Offer update — method mismatch resolved > ✅ **Fixed (commit 240a668)**: The frontend `updateOffer` action now sends `PATCH /api/marketplace/offers/:id`, matching the backend. The `acceptOffer` action was also corrected from `PUT` to `PATCH`. ## Sequence diagram ```mermaid sequenceDiagram autonumber actor S as Seller actor B as Buyer participant FE_S as Seller frontend participant FE_B as Buyer frontend participant BE as Backend participant DB as MongoDB participant N as NotificationService participant IO as SocketIO S->>FE_S: Browse marketplace FE_S->>BE: GET /api/marketplace/purchase-requests BE-->>FE_S: filtered request list S->>FE_S: Open request and send offer FE_S->>BE: POST /api/marketplace/purchase-requests/:id/offers BE->>DB: Validate offer not duplicate BE->>DB: Validate request status BE->>DB: Create offer with status pending opt request has no offers yet BE->>DB: Set request status to received_offers end BE->>N: notifyNewOfferReceived N->>IO: emit notification to buyer BE->>IO: emit new-offer to buyer-{buyerId} BE-->>FE_S: 200 { offer } IO-->>FE_B: notify buyer bell icon B->>FE_B: Open request detail FE_B->>BE: GET /api/marketplace/purchase-requests/:id/offers BE-->>FE_B: offers alt B->>FE_B: Click pay to finish selected offer B->>FE_B: Request Network payment confirms else B->>FE_B: Open chat to negotiate end ``` ## API calls | Method | Endpoint | Purpose | Notes | |---|---|---|---| | `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer | `purchaseRequestId` is a path param | | `GET` | `/api/marketplace/purchase-requests/:id/offers` | Buyer view of offers on a request | | | `GET` | `/api/marketplace/offers/:id` | Single offer details | | | `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | Fixed: frontend now sends `PATCH` | | `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) | | | `GET` | `/api/marketplace/offers/seller/:sellerId` | All offers by this seller (used by Offer Management page) | Implemented via `getSellerOffers` frontend action (commit 240a668) | | `PUT` | `/api/marketplace/offers/:id/status` | Status mutation — use `{ status: 'withdrawn' }` to withdraw | The only HTTP withdraw path; `POST /api/marketplace/offers/:id/withdraw` does **not** exist | ## Database writes - **`selleroffers`**: insert on create; update on accept/reject/withdraw; `updateMany` to bulk-reject other offers when one is accepted (`SellerOfferService.acceptOffer:376-388`). - **`purchaserequests`**: status moves to `received_offers` on first offer, then `payment` on successful payment, and `selectedOfferId` is set. - **`notifications`**: at least one per state change. ## Socket events emitted - **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs). - **`new-offer`** → `buyer-{buyerId}` room — emitted directly by `marketplaceController.ts` on offer creation; `use-marketplace-socket.ts` (lines 300, 497) listens on this event to update the buyer's offer list in real time. - **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`). - **`purchase-request-update`** → `request-{requestId}` when buyer calls `select-offer` (generic room event only — no per-seller notifications or events are sent to winning or losing sellers). - **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler after payment confirmation). - **`new-notification`** → `user-{buyerId}` for each new offer. ## Side effects - Triggers chat creation **only after payment** (not on offer creation) — minimises noise from speculative offers. - The `rejectionReason` field is set to `"Another offer was accepted by buyer"` for losers (`SellerOfferService.ts:387`). - Seller statistics aggregate (`getOfferStatistics`, `:446-475`) is computed on demand for dashboards; no denormalised counter on the user document. ## Error / edge cases - **Duplicate offer by same seller** → `400` with localized error. Use `updateOffer` instead. - **Request status not open** → `400 "این درخواست دیگر برای پیشنهاد باز نیست"` (`:84`). - **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state). - **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`. - **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`. - **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, `PaymentCoordinator` and provider idempotency decide which confirmed payment wins. - **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans. > [!tip] Real-time UX > Sellers should `socket.emit('join-seller-room', myUserId)` on dashboard mount so they see `seller-offer-update` events instantly when the buyer accepts/rejects. ## Linked flows - [[Purchase Request Flow]] — produces the requests sellers offer on. - [[Negotiation Flow]] — counter-offer in `in_negotiation`. - [[PRD - Request Network In-House Checkout]] — locks in the accepted offer. - [[Chat Flow]] — direct chat opened after payment. - [[Notification Flow]] — channels for offer events. - [[Rating Flow]] — seller's average rating displayed in the offer card. ## Source files - Backend: `backend/src/services/marketplace/SellerOfferService.ts` - Backend: `backend/src/services/marketplace/marketplaceController.ts` - Backend: `backend/src/models/SellerOffer.ts` - Backend: `backend/src/services/payment/paymentCoordinator.ts` (payment-state cascade) - Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` — proposal form (also re-used for edit) - Frontend: `frontend/src/sections/request/components/seller-steps/step-2-waiting-for-payment.tsx` — awaiting-buyer card with edit/withdraw actions - Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx` - Frontend: `frontend/src/app/dashboard/seller/marketplace/` — seller marketplace browse - Frontend: `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` — Offer Management page (all offers, status filter, withdraw) - Frontend: `frontend/src/actions/marketplace.ts` — `withdrawOffer`, `getSellerOffers` actions