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>
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 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) | ⚠️ 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).new-offer→buyer-{buyerId}room — emitted directly bymarketplaceController.tson 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-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/