Files
nick-doc/11 - Testing/Offer Selection & Rejection — Bug Analysis 2026-06-06.md

6.9 KiB

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:

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.selectOfferSellerOfferService.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.mjs21/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:

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.