Initial commit: nick docs
This commit is contained in:
197
04 - Flows/Seller Offer Flow.md
Normal file
197
04 - Flows/Seller Offer Flow.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: Seller Offer Flow
|
||||
tags: [flow, marketplace, seller, offer]
|
||||
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
|
||||
related_apis: ["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.
|
||||
- **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` 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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
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/offers`.
|
||||
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` or `received_offers`.
|
||||
- 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/offers/request/{requestId}` — `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 → Payment
|
||||
|
||||
14. 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.
|
||||
15. On `PAID`/`OVERPAID` webhook (see `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714`):
|
||||
- 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: `seller-offer-update` `payment-completed` to the winner, `seller-offer-update` `offer-rejected` to losers (`shkeeperWebhook.ts:679-705`).
|
||||
|
||||
### Withdrawal
|
||||
|
||||
16. 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
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor S as Seller
|
||||
actor B as Buyer
|
||||
participant FE_S as Frontend (seller)
|
||||
participant FE_B as Frontend (buyer)
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant N as NotificationService
|
||||
participant IO as Socket.IO
|
||||
|
||||
S->>FE_S: Browse /dashboard/seller/marketplace
|
||||
FE_S->>BE: GET /api/marketplace/purchase-requests?sellerId
|
||||
BE-->>FE_S: filtered list
|
||||
S->>FE_S: Open request, click "Send proposal"
|
||||
S->>FE_S: Fill price, ETA, notes; submit
|
||||
FE_S->>BE: POST /api/marketplace/offers
|
||||
BE->>DB: ensure no existing offer; check status
|
||||
BE->>DB: SellerOffer.create({status:"pending"})
|
||||
opt first offer on the request
|
||||
BE->>DB: PurchaseRequest.status = "received_offers"
|
||||
end
|
||||
BE->>N: notifyNewOfferReceived(buyer, requestId, sellerName)
|
||||
N->>IO: emit user-{buyer} new-notification
|
||||
BE->>IO: emit seller-{sellerId} 'new-offer'
|
||||
BE-->>FE_S: 200 { offer }
|
||||
IO-->>FE_B: new-notification (buyer's bell icon)
|
||||
B->>FE_B: Open request detail
|
||||
FE_B->>BE: GET /api/marketplace/offers/request/{id}
|
||||
BE-->>FE_B: offers[]
|
||||
alt Buyer accepts via payment
|
||||
B->>FE_B: Click "Pay" → starts [[Payment Flow - SHKeeper]]
|
||||
Note over BE,DB: SHKeeper webhook PAID arrives later;<br/>winning offer → accepted, others → rejected
|
||||
else Buyer negotiates
|
||||
B->>FE_B: Open chat → [[Negotiation Flow]]
|
||||
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-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, 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
|
||||
|
||||
- [[Purchase Request Flow]] — produces the requests sellers offer on.
|
||||
- [[Negotiation Flow]] — counter-offer in `in_negotiation`.
|
||||
- [[Payment Flow - SHKeeper]] — 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/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/`
|
||||
Reference in New Issue
Block a user