Files
nick-doc/04 - Flows/Seller Offer Flow.md

11 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/offers
GET /api/marketplace/offers/request/:requestId
PATCH /api/marketplace/offers/:id

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 Payment Flow - SHKeeper) 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 or received_offers (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 --> active: (optional — manual seller activation)
    pending --> withdrawn: seller withdraws (only while pending)
    pending --> rejected: another offer accepted\nor buyer rejects this one
    pending --> accepted: acceptOffer()\nor SHKeeper PAID webhook
    accepted --> [*]
    rejected --> [*]
    withdrawn --> [*]
    pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron)

The active enum values are pending | accepted | rejected | withdrawn (SellerOfferService.ts:308). 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/offers.
  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 or received_offers.
    • 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/offers/request/{requestId}SellerOfferService.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 → Payment

  1. The buyer's "Pay this offer" button kicks off Payment Flow - SHKeeper with purchaseRequestId and sellerOfferId. The offer is not immediately marked accepted; the SHKeeper webhook does that atomically when the on-chain payment is confirmed.
  2. On PAID/OVERPAID webhook (see backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714):
    • 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: seller-offer-update payment-completed to the winner, seller-offer-update offer-rejected to losers (shkeeperWebhook.ts:679-705).

Withdrawal

  1. 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: SHKeeper webhook handles payment result
    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-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, the SHKeeper webhook coordinator (PaymentCoordinator) is idempotent and the first PAID 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/shkeeper/shkeeperWebhook.ts:573-714 (acceptance via webhook)
  • 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/