Files
nick-doc/04 - Flows/Seller Offer Flow.md
Siavash Sameni dceaf82934 audit: 2026-05-30 full-codebase audit — report, issues, docs, runbooks
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>
2026-05-30 18:48:04 +04:00

218 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Seller Offer Flow
tags: [flow, marketplace, seller, offer]
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
related_apis: ["POST /api/marketplace/purchase-requests/:id/offers", "GET /api/marketplace/purchase-requests/:id/offers", "PATCH /api/marketplace/offers/:id"]
---
> **Last updated:** 2026-05-30 — updated for offer-management page, `withdrawOffer` action, edit-while-pending, `getSellerOffers` API (commits 240a668e7d1375)
# 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.
- **Backend** — `SellerOfferService` (`backend/src/services/marketplace/SellerOfferService.ts`) and controller routes.
- **MongoDB** — `selleroffers` collection.
- **Socket.IO** — `seller-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
```mermaid
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
4. 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)
5. Frontend POSTs `POST /api/marketplace/purchase-requests/:id/offers` (the `purchaseRequestId` is a **path parameter**, not a body field).
6. 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.
7. **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.
8. **Status auto-progression**: if this is the **first** offer on a `pending` request, the request transitions to `received_offers` (`:107-122`).
9. **Buyer notification**: `notificationService.notifyNewOfferReceived(buyerId, requestId, requestTitle, sellerName)` writes a `Notification` and emits to `user-{buyerId}`.
10. Response: `200 { offer }` (populated).
### Buyer review
11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/purchase-requests/:id/offers``SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`.
12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes.
13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment.
### Accept / Select Offer → Payment
14. 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.
15. 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.
16. On Request Network payment confirmation:
- The selected offer's `status``accepted`.
- 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.
### Edit / withdrawal while awaiting buyer acceptance
17. While a request is in `received_offers` status (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 `mode` to `'edit'` inside `Step2WaitingForPayment`, re-mounts `Step1SendProposal` pre-populated with the existing offer values. On save, calls `PATCH /api/marketplace/offers/:id` (via `updateOffer` action, which now correctly uses `PATCH` instead of the old `PUT`).
- **Withdraw**: opens a `ConfirmDialog`, then calls `withdrawOffer(offerId)` in `src/actions/marketplace.ts` which uses `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
`canManageOffer` is only `true` when `requestDetails?.status === 'received_offers'`; once the buyer accepts and the status advances, both buttons are hidden.
The DB filter `{ status: 'pending' }` inside `SellerOfferService.withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
> ⚠️ `POST /api/marketplace/offers/:id/withdraw` still does **not** exist as an HTTP route. Always use `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
### Offer update — method mismatch resolved
> ✅ **Fixed (commit 240a668)**: The frontend `updateOffer` action now sends `PATCH /api/marketplace/offers/:id`, matching the backend. The `acceptOffer` action was also corrected from `PUT` to `PATCH`.
## Sequence diagram
```mermaid
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; `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-offer`** → `buyer-{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-update`** → `request-{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-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 `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 seller** → `400` with localized error. Use `updateOffer` instead.
- **Request status not open** → `400 "این درخواست دیگر برای پیشنهاد باز نیست"` (`: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
- [[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`, `getSellerOffers` actions