Flow docs updated (11 files): - Delivery Confirmation: reversed actor roles (buyer generates, seller verifies), fixed endpoint paths (/delivery-code/generate, /delivery-code/verify) - Passkey (WebAuthn): removed stub/simulated-key claims; real @simplewebauthn/server attestation is implemented; refresh tokens are persisted - Dispute: corrected resolve schema (action enum), removed non-existent statuses, documented security gaps (no role guards on status/resolve/assign), route shadowing, all socket events are TODO stubs - Seller Offer: corrected all endpoint paths, removed 'active' status, documented withdraw dead code, missing seller history page, select-offer notification gap - Notification: corrected mark-all-read method+path, fixed GET /:id broken lookup, added unread-count-update socket event - Authentication: corrected rate limiter (counts all attempts), axios 403 not handled, deleteAccount wrong endpoint bug, changePassword no UI - Password Reset: corrected 6-digit code (not 8), documented no-complexity gap on reset-with-code vs token reset - Payment Flow DePay: /create→/save, removed phantom sub-routes, SIM_ bypass risk, PaymentProvider type gap, getProviderIntentEndpoint routing bug - Payment Flow SHKeeper: removed phantom polling endpoint, fixed release/refund paths - Purchase Request: added pending_payment/active statuses, fixed sellers/attachments endpoints, corrected socket events, PUT→PATCH bug - Escrow: documented dispute resolve does not touch escrow, route shadowing, confirm-delivery auth gap Issues created (35 files in Issues/): - 9 security issues (critical) including: dispute privilege escalation ×4, unauthenticated payment/scanner endpoints ×2, SIM_ production bypass, confirm-delivery ownership gap - 26 additional major/critical bugs covering broken endpoints, missing features, data integrity gaps, and frontend-backend mismatches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 KiB
title, tags, related_models, related_apis
| title | tags | related_models | related_apis | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Seller Offer Flow |
|
|
|
Last updated: 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
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.tsxand the seller marketplace listing underfrontend/src/app/dashboard/seller/marketplace/. - Frontend (buyer) —
frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsxfor the offer chooser. - Backend —
SellerOfferService(backend/src/services/marketplace/SellerOfferService.ts) and controller routes. - MongoDB —
sellerofferscollection. - Socket.IO —
seller-offer-updateandpurchase-request-updateevents. - Notification service — buyer notifications.
Preconditions
- Seller is authenticated,
role === "seller",status === "active". - Target purchase request exists and
statusispending,received_offers, oractive(SellerOfferService.ts:83-85). - Seller does not already have an offer on this request (uniqueness enforced by
SellerOfferService.createOffer).
Offer state machine
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
- Seller opens
/dashboard/seller/marketplace. The page hitsGET /api/marketplace/purchase-requests?sellerId={me}and renders cards. - Filtering rules: only
pendingorreceived_offersrequests where the seller is public-eligible or inpreferredSellerIds(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.
Submission
- 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)
- Frontend POSTs
POST /api/marketplace/purchase-requests/:id/offers(thepurchaseRequestIdis a path parameter, not a body field). - Backend
SellerOfferService.createOffer(:51-140):- Uniqueness:
SellerOffer.findOne({ purchaseRequestId, sellerId })— if present, throws"شما قبلاً برای این درخواست پیشنهاد دادهاید"(:74). UseupdateOfferto amend. - Status guard: loads the
PurchaseRequest; rejects if its status is anything other thanpending,received_offers, oractive. - Saves the offer (
status: "pending"by default in the schema). - Re-loads with
.populate('sellerId').populate('purchaseRequestId')for the response.
- Uniqueness:
- Real-time fan-out (
emitOfferUpdate,:24-46): emitsseller-offer-updatetoseller-{sellerId}so the seller's other tabs reflect the new offer instantly. - Status auto-progression: if this is the first offer on a
pendingrequest, the request transitions toreceived_offers(:107-122). - Buyer notification:
notificationService.notifyNewOfferReceived(buyerId, requestId, requestTitle, sellerName)writes aNotificationand emits touser-{buyerId}. - Response:
200 { offer }(populated).
Buyer review
- Buyer's request detail page (
/dashboard/buyer/requests/{id}) joinsGET /api/marketplace/purchase-requests/:id/offers—SellerOfferService.getOffersByPurchaseRequestreturns all offers sorted bycreatedAt: -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 / Select Offer → Payment
- The buyer selects an offer via
POST /api/marketplace/purchase-requests/:id/select-offer. Important: this endpoint fires only a genericpurchase-request-updateevent to therequest-{requestId}room. No per-seller socket events or notifications are sent to the winning or losing sellers at this stage. - The buyer's "Pay this offer" button kicks off PRD - Request Network In-House Checkout with
purchaseRequestIdandsellerOfferId. The offer is not immediately markedaccepted; 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 →
rejectedviaSellerOffer.updateMany. - The purchase request:
status = "payment",selectedOfferId = sellerOfferId. - A direct chat is created (see Chat Flow).
- Notifications:
notifyOfferAcceptedto the winning seller, generic rejection notifications to the others (SellerOfferService.acceptOfferdoes the same in the manual path). - Socket events notify the winner and reject/close competing offers.
- The selected offer's
Withdrawal
-
⚠️
POST /api/marketplace/offers/:id/withdrawdoes NOT exist as an HTTP route. TheSellerOfferService.withdrawOffer()service method exists but is dead code — it is not wired to any controller endpoint.The only supported HTTP way to withdraw an offer is:
PUT /api/marketplace/offers/:id Body: { status: 'withdrawn' }Note also that the frontend page
/dashboard/seller/marketplace/offers(a "My Offers" listing) does not exist. Withdrawal must be triggered from the individual request detail page.The DB filter
{ status: 'pending' }insidewithdrawOffermeans withdrawal is impossible onceacceptedorrejected.
Offer update — method mismatch
⚠️ Known mismatch: The frontend sends
PUT /marketplace/offers/:idto update an offer, but the backend route is registered asPATCH /api/marketplace/offers/:id(marketplaceControllerRoutes.ts). Depending on whether a proxy or middleware normalises the method, one of these may fail. Verify end-to-end and align to a single method.
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/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 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/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) | ⚠️ Frontend sends PUT; backend registers PATCH — method mismatch |
POST |
/api/marketplace/offers/:id/accept |
Manual acceptance (when not via webhook) | |
GET /api/marketplace/offers/seller/:sellerId |
— | ⚠️ NOT IMPLEMENTED — getOffersBySeller() service method exists but has no HTTP route |
|
POST /api/marketplace/offers/:id/withdraw |
— | ⚠️ NOT IMPLEMENTED — use PATCH /api/marketplace/offers/:id with { status: 'withdrawn' } instead |
Database writes
selleroffers: insert on create; update on accept/reject/withdraw;updateManyto bulk-reject other offers when one is accepted (SellerOfferService.acceptOffer:376-388).purchaserequests: status moves toreceived_offerson first offer, thenpaymenton successful payment, andselectedOfferIdis set.notifications: at least one per state change.
Socket events emitted
seller-offer-updatewitheventType: 'new-offer'→seller-{sellerId}(creator's other tabs).purchase-request-updatewitheventType: 'offer-updated'→request-{requestId}on edits (SellerOfferService.ts:284-288).purchase-request-update→request-{requestId}when buyer callsselect-offer(generic room event only — no per-seller notifications or events are sent to winning or losing sellers).seller-offer-updatewitheventType: '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
rejectionReasonfield 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 →
400with localized error. UseupdateOfferinstead. - Request status not open →
400 "این درخواست دیگر برای پیشنهاد باز نیست"(:84). - Price = 0 or negative → Mongoose validator on
SellerOffer.price.amountrejects (SellerOfferService.ts:55-60logs the validation state). - Seller withdraws an
acceptedoffer → blocked by the{ status: 'pending' }filter; returnsnull. validUntilin the past at creation → schema-level validator should reject; otherwise the nextmarkExpiredOffersAsWithdrawncron run flips it towithdrawn.- Race condition: two payments to two different offers → unlikely (frontend disables payment buttons once one is chosen); even if both arrive,
PaymentCoordinatorand 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 seeseller-offer-updateevents 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 - Frontend:
frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx - Frontend:
frontend/src/app/dashboard/seller/marketplace/