# Offer Selection & Rejection — Bug Analysis & Fix (2026-06-06) ## Symptom (reported) In the Telegram Mini App, a buyer created a request, received offers from multiple sellers, accepted one and paid for it. **Both** the winning and losing seller then saw their request stuck at step 4 — «۴. انتظار ارسال کالا» (awaiting shipment) — as if both had won and both needed to ship goods. ## Investigation — backend vs UI We traced the actual request (`کیر خر`, id `54c9de14-…`) directly in Postgres: | Offer | Seller | DB status | |-------|--------|-----------| | `e90a099f` | `8346800b` | **accepted** ✅ | | `81b1e7af` | `4ba2a6fe` | **rejected** ✅ | - `purchase_requests.selected_offer_id` = `e90a099f` (the winner) ✅ - Request status: `delivery` ✅ **Notifications** (also correct): | Recipient | Title | |-----------|-------| | winning seller `8512c583` | `✅ پیشنهاد شما پذیرفته شد!` | | losing seller `a13d3f04` | `❌ پیشنهاد شما رد شد` | **Conclusion: the backend was correct.** Offers were properly accepted/rejected and both sellers received the correct notification. The bug was **purely in the Mini App UI**, which derived the seller's step from the *request* status (`delivery`) without checking the seller's *own* offer status. ## Root cause (UI) `telegram-request-detail-view.tsx` computed the seller flow purely from `request.status`: ```ts const sellerOnDeliveryStep = role === 'seller' && request?.status === 'delivery'; const currentStep = determineCurrentStepFromStatus(request.status, role); ``` A rejected seller, whose offer status is `rejected`, still saw the full seller stepper (including step 4 «انتظار ارسال کالا») because the request as a whole is in `delivery`. ### ID-namespace gotcha The fix needs to know whether *this* seller won. Marketplace offers store `sellerId` as the **Postgres UUID** (`users.id`), but the auth user's `_id`/`id` is the **legacy Mongo ObjectId** (`legacy_object_id`). The auth payload exposes `pgId` for the UUID — so the ownership check must compare `offer.sellerId` against `user.pgId`, **not** `user._id`. (Verified via `/api/auth/login` response shape.) ## Fix (UI) — frontend v2.9.13 (built on mojtaba's v2.9.12) The parallel agent (mojtaba) shipped **v2.9.12** first: it added the canonical `StepContext` API in `request-config.tsx` (`determineSellerStep` returns `SELLER_REJECTED_STEP = 0` when `hasSelectedOffer && !isSelectedSeller && hasOffer` and status is post-selection), fixed the **web** seller view, and fixed the telegram stepper's RTL connector lines. **But it did not wire the telegram detail view into that API** — that view still called `determineCurrentStepFromStatus(status, role)` without a ctx and kept an ungated `sellerOnDeliveryStep`, so the mini-app stayed broken. **v2.9.13** (this fix) wires the telegram detail view into mojtaba's StepContext: - New `userId` prop carries the user's **pgId** (from `telegram-mini-app-view.tsx` as `selfPgId = user.pgId ?? selfId`). - For sellers in a post-selection status, fetch the offers list. The API only returns non-rejected offers, so a loser's offer is absent — we synthesise `sellerOfferStatus: 'rejected'` when a winning offer exists that isn't this seller's (so `determineSellerStep`'s `hasOffer` guard is satisfied and it returns step 0). - Build `sellerStepCtx = { sellerOfferStatus, isSelectedSeller, hasSelectedOffer }` and pass it to `determineCurrentStepFromStatus(status, role, ctx)` — same logic as web. - `sellerIsRejected = currentStep === 0`; gate `sellerOnDeliveryStep` on `!sellerIsRejected` and render a dedicated «پیشنهاد شما انتخاب نشد» screen. - New locale keys `offer_not_selected_title` / `offer_not_selected_body` (en + fa). ### Key gotcha — pgId vs legacy _id Offers store `sellerId` as the **Postgres UUID** (`users.id`); the auth user's `_id`/`id` is the **legacy Mongo ObjectId** (`legacy_object_id`). The auth payload exposes `pgId` for the UUID. Ownership checks must compare `offer.sellerId` against `user.pgId`, not `user._id`. Notification `userId`, however, uses the legacy id. ## Hardening (backend) — v2.9.11 Although the *select-then-pay* flow (the Mini App path, via `marketplaceController.selectOffer` → `SellerOfferService.acceptOffer`) already persisted loser notifications, several **direct payment paths** rejected sibling offers at the repo/SQL level **without** sending notifications: - `paymentRoutes.ts` `/payments/verify` — called `repo.acceptOffer` (no notify) - `paymentController.ts` payment propagation — called `repo.acceptOffer` (no notify) - `paymentCoordinator.ts` escrow-funded path — raw in-tx reject (no notify) ### Changes 1. **`SellerOfferService.acceptOffer` is now idempotent.** It snapshots the pending/active siblings *before* the accept and notifies exactly those freshly rejected. A repeat call rejects 0 rows → notifies nobody. The winner notification only fires when the offer actually transitions to `accepted` (guarded on prior status). This makes it safe to call from every payment path without double-notify. 2. **`paymentRoutes` & `paymentController`** now call `SellerOfferService.acceptOffer` (with a repo fallback) so winner + losers are notified. 3. **`paymentCoordinator`** keeps its atomic in-transaction reject (v2.9.10) for the money path, but now captures the freshly-rejected seller ids via `.returning()` and sends the winner/loser notifications **after commit** (best-effort). ## Regression test `backend/scripts/smoke/offer-selection-rejection.mjs` — **21/21 PASS** against dev. Flow: 1 buyer + 3 sellers → request → 3 offers → buyer selects offer[0]. Asserts: - buyer sees exactly 1 offer (2 rejected + hidden) - the visible offer is the winner with status `accepted` - each losing seller's offer is hidden (rejected) - **each losing seller received the `❌ پیشنهاد شما رد شد` notification** - **the winning seller received the `✅ پیشنهاد شما پذیرفته شد!` notification** - the request records the winning `selectedOfferId` Run: ```bash ADMIN_EMAIL=… ADMIN_PASSWORD=… API_BASE_URL=https://dev.amn.gg \ node backend/scripts/smoke/offer-selection-rejection.mjs ``` ## Files touched **Frontend (v2.9.13 — pushed)** - `src/sections/telegram/view/telegram-request-detail-view.tsx` - `src/sections/telegram/view/telegram-mini-app-view.tsx` - `src/sections/telegram/locales/{en,fa,types}.ts` **Backend (v2.9.11 — pushed; bundled with the v2.9.12 Mongo retirement)** - `src/services/marketplace/SellerOfferService.ts` - `src/services/payment/paymentRoutes.ts` - `src/services/payment/paymentController.ts` - `src/services/payment/paymentCoordinator.ts` - `scripts/smoke/offer-selection-rejection.mjs` Push was initially held while the parallel Mongo-retirement refactor (which broke the shared working tree's typecheck) was in flight. Once it compiled clean, the nuke + v2.9.11 were committed (`30a88eb` v2.9.12, `15bbae3` v2.9.11) and pushed.