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 or received_offers (SellerOfferService.ts:83-85).
Seller does not already have an offer on this request (uniqueness enforced by SellerOfferService.createOffer).
The active enum values are pending | accepted | rejected | withdrawn (SellerOfferService.ts:308). validUntil expirations are converted to withdrawn.
Step-by-step narrative
Discovery
Seller opens /dashboard/seller/marketplace. The page hits GET /api/marketplace/purchase-requests?sellerId={me} and renders cards.
Filtering rules: only pending or received_offers requests where the seller is public-eligible or in preferredSellerIds (see visibility table in Purchase Request Flow).
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.
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)
Frontend POSTs POST /api/marketplace/offers.
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 or received_offers.
Saves the offer (status: "pending" by default in the schema).
Re-loads with .populate('sellerId').populate('purchaseRequestId') for the response.
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.
Status auto-progression: if this is the first offer on a pending request, the request transitions to received_offers (:107-122).
Buyer notification: notificationService.notifyNewOfferReceived(buyerId, requestId, requestTitle, sellerName) writes a Notification and emits to user-{buyerId}.
Response: 200 { offer } (populated).
Buyer review
Buyer's request detail page (/dashboard/buyer/requests/{id}) joins GET /api/marketplace/offers/request/{requestId} — SellerOfferService.getOffersByPurchaseRequest returns all offers sorted by createdAt: -1.
Each offer card shows seller name, avatar, rating (from Rating Flow), price, ETA, notes.
Buyer either negotiates (opens chat → Negotiation Flow) or accepts the offer by triggering payment.
Accept → Payment
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.
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.
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.
Withdrawal
Seller can withdraw their pending offer from /dashboard/seller/marketplace/offers/{offerId} → withdrawOffer (SellerOfferService.ts:428-443). The DB filter { status: 'pending' } means withdrawal is impossible once accepted or rejected.
Sequence diagram
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/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 seller new-offer
BE-->>FE_S: 200 { offer }
IO-->>FE_B: notify buyer bell icon
B->>FE_B: Open request detail
FE_B->>BE: GET /api/marketplace/offers/request/{id}
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
POST
/api/marketplace/offers
Create offer
GET
/api/marketplace/offers/request/:requestId
Buyer view of offers on a request
GET
/api/marketplace/offers/seller/:sellerId
Seller's own offer history
GET
/api/marketplace/offers/:id
Single offer details
PATCH
/api/marketplace/offers/:id
Update price / ETA / notes (seller, while pending)
POST
/api/marketplace/offers/:id/accept
Manual acceptance (when not via webhook)
POST
/api/marketplace/offers/:id/withdraw
Seller withdraws
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).
purchase-request-update with eventType: 'offer-updated' → request-{requestId} on edits (SellerOfferService.ts:284-288).
seller-offer-update with eventType: 'payment-completed' to winning seller, 'offer-rejected' to losers (emitted by the webhook handler).
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.