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>
This commit is contained in:
Siavash Sameni
2026-05-29 14:47:49 +04:00
parent 5113b0df23
commit a1f056e6a5
47 changed files with 2160 additions and 196 deletions

View File

@@ -2,9 +2,11 @@
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"]
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-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
# 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.
@@ -23,7 +25,7 @@ A **seller** browses open purchase requests and submits an offer with a price, d
## Preconditions
- Seller is authenticated, `role === "seller"`, `status === "active"`.
- Target purchase request exists and `status` is `pending` or `received_offers` (`SellerOfferService.ts:83-85`).
- 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
@@ -31,7 +33,6 @@ A **seller** browses open purchase requests and submits an offer with a price, d
```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 payment confirmed
@@ -41,7 +42,7 @@ stateDiagram-v2
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`.
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
@@ -60,10 +61,10 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
- **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`.
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` or `received_offers`.
- **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.
@@ -73,14 +74,15 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
### 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`.
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 → Payment
### Accept / Select Offer → Payment
14. 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.
15. On Request Network payment confirmation:
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`.
@@ -90,7 +92,22 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
### 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`.
17. ⚠️ **`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
@@ -110,7 +127,7 @@ sequenceDiagram
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
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
@@ -123,7 +140,7 @@ sequenceDiagram
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}
FE_B->>BE: GET /api/marketplace/purchase-requests/:id/offers
BE-->>FE_B: offers
alt
B->>FE_B: Click pay to finish selected offer
@@ -135,15 +152,15 @@ sequenceDiagram
## 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 |
| 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
@@ -155,7 +172,8 @@ sequenceDiagram
- **`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).
- **`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