Files
nick-doc/04 - Flows/Seller Offer Flow.md
Siavash Sameni a1f056e6a5 docs: align flow docs with code reality + create 35 implementation issue files
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>
2026-05-29 14:47:49 +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 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 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).
  • 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/