Files
nick-doc/04 - Flows/Seller Offer Flow.md
Siavash Sameni 7a616744f4 docs: complete code-reality alignment for remaining docs + reconcile issue set
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>
2026-05-29 15:15:02 +04:00

13 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Seller Offer Flow
flow
marketplace
seller
offer
SellerOffer
PurchaseRequest
Notification
POST /api/marketplace/purchase-requests/:id/offers
GET /api/marketplace/purchase-requests/:id/offers
PATCH /api/marketplace/offers/:id

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.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.
  • BackendSellerOfferService (backend/src/services/marketplace/SellerOfferService.ts) and controller routes.
  • MongoDBselleroffers collection.
  • Socket.IOseller-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

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

  1. 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)
  2. Frontend POSTs POST /api/marketplace/purchase-requests/:id/offers (the purchaseRequestId is a path parameter, not a body field).
  3. 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.
  4. 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.
  5. Status auto-progression: if this is the first offer on a pending request, the request transitions to received_offers (:107-122).
  6. Buyer notification: notificationService.notifyNewOfferReceived(buyerId, requestId, requestTitle, sellerName) writes a Notification and emits to user-{buyerId}.
  7. Response: 200 { offer } (populated).

Buyer review

  1. Buyer's request detail page (/dashboard/buyer/requests/{id}) joins GET /api/marketplace/purchase-requests/:id/offersSellerOfferService.getOffersByPurchaseRequest returns all offers sorted by createdAt: -1.
  2. Each offer card shows seller name, avatar, rating (from Rating Flow), price, ETA, notes.
  3. Buyer either negotiates (opens chat → Negotiation Flow) or accepts the offer by triggering payment.

Accept / Select Offer → Payment

  1. 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.
  2. 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.
  3. On Request Network payment confirmation:
    • The selected offer's statusaccepted.
    • 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.

Withdrawal

  1. ⚠️ POST /api/marketplace/offers/:id/withdraw does NOT exist as an HTTP route. The SellerOfferService.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' } inside withdrawOffer means withdrawal is impossible once accepted or rejected.

Offer update — method mismatch

⚠️ Known mismatch: The frontend sends PUT /marketplace/offers/:id to update an offer, but the backend route is registered as PATCH /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 Seller's own offer history ⚠️ NOT IMPLEMENTED — getOffersBySeller() service method exists but has no HTTP route
POST /api/marketplace/offers/:id/withdraw Seller withdraws ⚠️ NOT IMPLEMENTED — use PATCH /api/marketplace/offers/:id with { status: 'withdrawn' } instead

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-offerbuyer-{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-updaterequest-{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-notificationuser-{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 seller400 with localized error. Use updateOffer instead.
  • Request status not open400 "این درخواست دیگر برای پیشنهاد باز نیست" (: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

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/