Full-codebase-audit 2026-05-30 outputs: - Audit report: 09 - Audits/Full Codebase Audit - 2026-05-30.md - 81 issue files ISSUE-055..135 (decisions + 1 skipped no-brainer). - Scanner docs from scratch (was zero): architecture, data model, API ref, payment flow, operations runbook + repo README. - Doc-sync updates across API reference, data models, flows, design system. - Secret Rotation Runbook (08 - Operations) for the exposed credentials. - Reusable workflow guide (07 - Development) + .claude/workflows/full-codebase-audit.js. Issues remain status:open intentionally — the code fixes are uncommitted-then-committed working-tree changes per repo and aren't "resolved" until merged/deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
title, tags, related_models, related_apis
| title | tags | related_models | related_apis | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Seller Offer Flow |
|
|
|
Last updated: 2026-05-30 — updated for offer-management page,
withdrawOfferaction, edit-while-pending,getSellerOffersAPI (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.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
Edit / withdrawal while awaiting buyer acceptance
-
While a request is in
received_offersstatus (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
modeto'edit'insideStep2WaitingForPayment, re-mountsStep1SendProposalpre-populated with the existing offer values. On save, callsPATCH /api/marketplace/offers/:id(viaupdateOfferaction, which now correctly usesPATCHinstead of the oldPUT). - Withdraw: opens a
ConfirmDialog, then callswithdrawOffer(offerId)insrc/actions/marketplace.tswhich usesPUT /api/marketplace/offers/:id/statuswith{ status: 'withdrawn' }.
canManageOfferis onlytruewhenrequestDetails?.status === 'received_offers'; once the buyer accepts and the status advances, both buttons are hidden.The DB filter
{ status: 'pending' }insideSellerOfferService.withdrawOffermeans withdrawal is impossible onceacceptedorrejected.⚠️
POST /api/marketplace/offers/:id/withdrawstill does not exist as an HTTP route. Always usePUT /api/marketplace/offers/:id/statuswith{ status: 'withdrawn' }. - Edit: toggles
Offer update — method mismatch resolved
✅ Fixed (commit 240a668): The frontend
updateOfferaction now sendsPATCH /api/marketplace/offers/:id, matching the backend. TheacceptOfferaction was also corrected fromPUTtoPATCH.
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) | 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;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— 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,getSellerOffersactions