@mohasinac/appkit 4.11.1 → 4.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/dist/_internal/server/features/checkout/actions.d.ts +4 -0
  2. package/dist/_internal/server/features/checkout/actions.js +138 -27
  3. package/dist/_internal/server/features/checkout/data.d.ts +10 -0
  4. package/dist/_internal/server/features/checkout/locked-lines.d.ts +30 -0
  5. package/dist/_internal/server/features/checkout/locked-lines.js +116 -0
  6. package/dist/_internal/server/features/orders/adapters.js +7 -0
  7. package/dist/_internal/server/features/products/data.d.ts +12 -3
  8. package/dist/_internal/server/features/products/data.js +55 -4
  9. package/dist/_internal/server/features/products/index.d.ts +2 -1
  10. package/dist/_internal/server/features/products/index.js +2 -1
  11. package/dist/_internal/server/features/products/list-public.d.ts +130 -0
  12. package/dist/_internal/server/features/products/list-public.js +375 -0
  13. package/dist/_internal/server/features/reviews/data.d.ts +1 -1
  14. package/dist/_internal/server/features/reviews/data.js +3 -2
  15. package/dist/_internal/server/features/tester/visibility.js +3 -3
  16. package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
  17. package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
  18. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +1 -1
  19. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +4 -1
  20. package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +2 -0
  21. package/dist/_internal/server/jobs/handlers/_helpers.js +4 -1
  22. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
  23. package/dist/_internal/server/jobs/handlers/messages.js +5 -0
  24. package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
  25. package/dist/_internal/shared/checkout/lanes.js +106 -0
  26. package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
  27. package/dist/_internal/shared/checkout/order-math.js +30 -6
  28. package/dist/_internal/shared/features/promotions/schema.d.ts +0 -16
  29. package/dist/_internal/shared/features/promotions/schema.js +0 -2
  30. package/dist/_internal/shared/features/reviews/config.d.ts +8 -1
  31. package/dist/_internal/shared/features/reviews/config.js +8 -1
  32. package/dist/_internal/shared/listing-types/_registry.d.ts +88 -1
  33. package/dist/_internal/shared/listing-types/_registry.js +63 -1
  34. package/dist/_internal/shared/listing-types/art/config.d.ts +59 -0
  35. package/dist/_internal/shared/listing-types/art/config.js +12 -0
  36. package/dist/_internal/shared/listing-types/auction/config.d.ts +53 -0
  37. package/dist/_internal/shared/listing-types/auction/config.js +17 -0
  38. package/dist/_internal/shared/listing-types/classified/config.d.ts +59 -0
  39. package/dist/_internal/shared/listing-types/classified/config.js +14 -0
  40. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +59 -0
  41. package/dist/_internal/shared/listing-types/digital-code/config.js +12 -0
  42. package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
  43. package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
  44. package/dist/_internal/shared/listing-types/live/config.d.ts +59 -0
  45. package/dist/_internal/shared/listing-types/live/config.js +14 -0
  46. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +110 -0
  47. package/dist/_internal/shared/listing-types/pre-order/config.js +14 -0
  48. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +44 -0
  49. package/dist/_internal/shared/listing-types/prize-draw/config.js +10 -0
  50. package/dist/_internal/shared/listing-types/standard/config.d.ts +59 -0
  51. package/dist/_internal/shared/listing-types/standard/config.js +9 -0
  52. package/dist/_internal/shared/listing-types/stickers/config.d.ts +59 -0
  53. package/dist/_internal/shared/listing-types/stickers/config.js +12 -0
  54. package/dist/client.d.ts +7 -2
  55. package/dist/client.js +8 -2
  56. package/dist/constants/field-names.d.ts +12 -0
  57. package/dist/constants/field-names.js +12 -0
  58. package/dist/constants/table-keys.d.ts +2 -0
  59. package/dist/constants/table-keys.js +2 -0
  60. package/dist/features/account/components/NotificationBell.js +1 -0
  61. package/dist/features/account/components/UserOffersPanel.js +8 -1
  62. package/dist/features/account/components/UserOrdersView.js +27 -5
  63. package/dist/features/admin/actions/notification-actions.js +1 -1
  64. package/dist/features/admin/components/AdminAddressesView.js +3 -1
  65. package/dist/features/admin/components/AdminCouponEditorView.js +1 -4
  66. package/dist/features/admin/components/AdminPaymentMethodsView.js +3 -1
  67. package/dist/features/admin/components/AdminProductsView.js +34 -18
  68. package/dist/features/admin/constants/filter-tabs.d.ts +26 -9
  69. package/dist/features/admin/constants/filter-tabs.js +22 -9
  70. package/dist/features/admin/schemas/firestore.d.ts +2 -1
  71. package/dist/features/admin/schemas/firestore.js +1 -0
  72. package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
  73. package/dist/features/auctions/actions/bid-actions.js +29 -15
  74. package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
  75. package/dist/features/auctions/components/AuctionBottomActions.js +1 -0
  76. package/dist/features/auctions/components/AuctionDetailPageView.js +13 -11
  77. package/dist/features/auctions/components/AuctionsListView.js +12 -53
  78. package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
  79. package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
  80. package/dist/features/auctions/repository/bid.repository.js +19 -0
  81. package/dist/features/auctions/schemas/firestore.d.ts +11 -1
  82. package/dist/features/auctions/schemas/firestore.js +1 -0
  83. package/dist/features/cart/actions/cart-actions.d.ts +11 -0
  84. package/dist/features/cart/actions/cart-actions.js +24 -0
  85. package/dist/features/cart/repository/cart.repository.d.ts +24 -1
  86. package/dist/features/cart/repository/cart.repository.js +71 -6
  87. package/dist/features/cart/schemas/firestore.d.ts +23 -4
  88. package/dist/features/categories/components/BrandDetailTabs.js +31 -11
  89. package/dist/features/categories/components/CategoryDetailTabs.js +27 -7
  90. package/dist/features/categories/components/CategoryProductsListing.d.ts +9 -1
  91. package/dist/features/categories/components/CategoryProductsListing.js +5 -2
  92. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -1
  93. package/dist/features/contact/email.js +56 -1
  94. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +17 -16
  95. package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
  96. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  97. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  98. package/dist/features/layout/AppLayoutShell.js +9 -2
  99. package/dist/features/layout/BottomActions.js +26 -3
  100. package/dist/features/layout/BottomActionsContext.d.ts +14 -0
  101. package/dist/features/layout/BottomActionsContext.js +13 -1
  102. package/dist/features/layout/hooks/useBottomActions.d.ts +6 -1
  103. package/dist/features/layout/hooks/useBottomActions.js +4 -1
  104. package/dist/features/layout/index.d.ts +1 -1
  105. package/dist/features/live/components/LiveItemDetailPageView.js +7 -6
  106. package/dist/features/orders/repository/orders.repository.d.ts +4 -15
  107. package/dist/features/orders/repository/orders.repository.js +4 -27
  108. package/dist/features/orders/types/index.d.ts +16 -0
  109. package/dist/features/orders/utils/order-splitter.js +14 -2
  110. package/dist/features/pre-orders/components/PreOrderBottomActions.d.ts +1 -1
  111. package/dist/features/pre-orders/components/PreOrderBottomActions.js +3 -19
  112. package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
  113. package/dist/features/products/components/ArtStickersListView.js +10 -49
  114. package/dist/features/products/components/AuctionsIndexListing.js +1 -2
  115. package/dist/features/products/components/ListingBottomActions.d.ts +28 -0
  116. package/dist/features/products/components/ListingBottomActions.js +25 -0
  117. package/dist/features/products/components/PrizeDrawBottomActions.d.ts +1 -1
  118. package/dist/features/products/components/PrizeDrawBottomActions.js +3 -21
  119. package/dist/features/products/components/ProductDetailActions.js +4 -0
  120. package/dist/features/products/components/ProductDetailPageView.js +7 -7
  121. package/dist/features/products/components/ProductsIndexPageView.js +9 -62
  122. package/dist/features/products/constants/listing-tabs.d.ts +43 -198
  123. package/dist/features/products/constants/listing-tabs.js +71 -58
  124. package/dist/features/products/constants/sieve.d.ts +104 -1
  125. package/dist/features/products/constants/sieve.js +27 -7
  126. package/dist/features/products/repository/products.repository.d.ts +45 -1
  127. package/dist/features/products/repository/products.repository.js +67 -8
  128. package/dist/features/products/utils/listing-badge-variant.d.ts +20 -1
  129. package/dist/features/products/utils/listing-badge-variant.js +26 -1
  130. package/dist/features/products/utils/listing-type.d.ts +24 -0
  131. package/dist/features/products/utils/listing-type.js +48 -0
  132. package/dist/features/promotions/actions/coupon-actions.d.ts +7 -0
  133. package/dist/features/promotions/actions/coupon-actions.js +12 -1
  134. package/dist/features/promotions/actions/coupon-stacking.d.ts +24 -0
  135. package/dist/features/promotions/actions/coupon-stacking.js +21 -0
  136. package/dist/features/promotions/actions/index.d.ts +1 -0
  137. package/dist/features/promotions/actions/index.js +1 -0
  138. package/dist/features/promotions/actions/seller-coupon-actions.d.ts +0 -1
  139. package/dist/features/promotions/actions/seller-coupon-actions.js +0 -1
  140. package/dist/features/promotions/components/CouponHelpDetails.d.ts +20 -0
  141. package/dist/features/promotions/components/CouponHelpDetails.js +17 -0
  142. package/dist/features/promotions/components/CouponsIndexListing.js +2 -1
  143. package/dist/features/promotions/components/index.d.ts +2 -0
  144. package/dist/features/promotions/components/index.js +1 -0
  145. package/dist/features/promotions/constants/coupon-help.d.ts +27 -0
  146. package/dist/features/promotions/constants/coupon-help.js +42 -0
  147. package/dist/features/promotions/repository/coupons.repository.d.ts +10 -1
  148. package/dist/features/promotions/repository/coupons.repository.js +22 -8
  149. package/dist/features/promotions/schemas/firestore.d.ts +0 -1
  150. package/dist/features/promotions/schemas/firestore.js +1 -1
  151. package/dist/features/promotions/schemas/index.d.ts +0 -20
  152. package/dist/features/promotions/schemas/index.js +0 -1
  153. package/dist/features/reviews/actions/review-actions.js +5 -1
  154. package/dist/features/reviews/components/ReviewDetailPageView.js +2 -1
  155. package/dist/features/reviews/components/ReviewFilters.d.ts +7 -1
  156. package/dist/features/reviews/components/ReviewFilters.js +4 -3
  157. package/dist/features/reviews/components/ReviewsIndexListing.d.ts +6 -0
  158. package/dist/features/reviews/components/ReviewsIndexListing.js +9 -109
  159. package/dist/features/reviews/components/ReviewsListingPanel.d.ts +41 -0
  160. package/dist/features/reviews/components/ReviewsListingPanel.js +226 -0
  161. package/dist/features/reviews/components/index.d.ts +2 -0
  162. package/dist/features/reviews/components/index.js +1 -0
  163. package/dist/features/reviews/hooks/useReviews.d.ts +8 -0
  164. package/dist/features/reviews/hooks/useReviews.js +14 -6
  165. package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
  166. package/dist/features/reviews/repository/reviews.repository.js +77 -41
  167. package/dist/features/reviews/types/index.d.ts +8 -1
  168. package/dist/features/seller/actions/offer-actions.d.ts +6 -0
  169. package/dist/features/seller/actions/offer-actions.js +8 -9
  170. package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
  171. package/dist/features/seller/components/SellerOffersView.js +59 -8
  172. package/dist/features/seller/components/SellerProductsCards.js +2 -2
  173. package/dist/features/seller/components/SellerProductsView.js +21 -18
  174. package/dist/features/seller/repository/offer.repository.d.ts +18 -2
  175. package/dist/features/seller/repository/offer.repository.js +38 -2
  176. package/dist/features/seller/schemas/firestore.d.ts +7 -2
  177. package/dist/features/seller/schemas/firestore.js +3 -0
  178. package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
  179. package/dist/features/seller/schemas/offer-forms.js +28 -0
  180. package/dist/features/stores/actions/store-query-actions.js +1 -1
  181. package/dist/features/stores/components/StoreAuctionsListing.js +5 -11
  182. package/dist/features/stores/components/StorePreOrdersListing.js +6 -11
  183. package/dist/features/stores/components/StoreReviewsListing.d.ts +4 -0
  184. package/dist/features/stores/components/StoreReviewsListing.js +7 -108
  185. package/dist/features/stores/schemas/index.d.ts +2 -2
  186. package/dist/features/tester/seed-data/coupons-tester-seed-data.js +3 -3
  187. package/dist/features/tester/seed-data/index.d.ts +1 -0
  188. package/dist/features/tester/seed-data/index.js +1 -0
  189. package/dist/features/tester/seed-data/offers-tester-seed-data.d.ts +2 -0
  190. package/dist/features/tester/seed-data/offers-tester-seed-data.js +61 -0
  191. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +337 -1
  192. package/dist/index.d.ts +11 -4
  193. package/dist/index.js +15 -3
  194. package/dist/react/hooks/useListingTypeFlags.d.ts +12 -19
  195. package/dist/react/hooks/useListingTypeFlags.js +4 -9
  196. package/dist/schemas/registry.d.ts +0 -12
  197. package/dist/seed/claimed-coupons-seed-data.js +3 -3
  198. package/dist/seed/coupons-seed-data.js +29 -10
  199. package/dist/seed/faq-seed-data.js +2 -2
  200. package/dist/seed/manifest.js +2 -2
  201. package/dist/server-entry.d.ts +1 -0
  202. package/dist/server-entry.js +1 -0
  203. package/dist/server.d.ts +1 -0
  204. package/dist/server.js +3 -0
  205. package/dist/styles.css +44 -39
  206. package/dist/tailwind-utilities.css +1 -1
  207. package/dist/ui/components/FilterChipGroup.d.ts +21 -3
  208. package/dist/ui/components/FilterChipGroup.js +33 -4
  209. package/dist/ui/components/HorizontalScroller.d.ts +1 -2
  210. package/dist/ui/components/HorizontalScroller.js +9 -5
  211. package/dist/ui/components/HorizontalScroller.style.css +43 -38
  212. package/package.json +1 -1
@@ -1,36 +1,105 @@
1
- import { bidRepository, orderRepository, productRepository, } from "../../../../repositories";
1
+ import { bidRepository, cartRepository, productRepository, storeRepository, } from "../../../../repositories";
2
2
  import { sendNotification } from "../../../../features/admin/actions/notification-actions";
3
3
  import { AUCTION_MESSAGES } from "../handlers/messages";
4
+ import { ROUTES } from "../../../../next/routing/route-map";
5
+ import { CART_LANE } from "../../../shared/checkout/lanes";
6
+ /**
7
+ * How long the winner has to pay before the locked cart line lapses. Mirrors
8
+ * the accepted-offer window so both locked lanes behave the same.
9
+ */
10
+ const AUCTION_CHECKOUT_WINDOW_MS = 48 * 60 * 60 * 1000;
11
+ /** Deep link the winner straight into the auction checkout lane. */
12
+ const AUCTION_CHECKOUT_URL = `${String(ROUTES.USER.CHECKOUT)}?lane=${CART_LANE.AUCTION}`;
4
13
  async function settleAuction(ctx, product) {
5
14
  const activeBids = await bidRepository.getActiveByProduct(product.id);
6
- const batch = ctx.db.batch();
7
15
  if (activeBids.length === 0) {
16
+ const batch = ctx.db.batch();
8
17
  productRepository.updateStatusInBatch(batch, product.id, "archived");
9
18
  await batch.commit();
10
19
  ctx.logger.info(AUCTION_MESSAGES.NO_BIDS_LOG(product.id));
11
20
  return;
12
21
  }
13
22
  const [winnerEntry, ...loserEntries] = activeBids;
23
+ // Reserve price. It exists on the product, is editable by the seller, is
24
+ // shown in both dashboards, and the buyer guide promises bids below it don't
25
+ // win — but settlement awarded activeBids[0] unconditionally until
26
+ // 2026-08-21. A below-reserve auction ends with NO winner.
27
+ const reserve = product.reservePrice;
28
+ if (typeof reserve === "number" && reserve > 0 && winnerEntry.data.bidAmount < reserve) {
29
+ const batch = ctx.db.batch();
30
+ activeBids.forEach(({ ref }) => bidRepository.markLost(batch, ref));
31
+ productRepository.updateStatusInBatch(batch, product.id, "archived");
32
+ await batch.commit();
33
+ const reserveStore = product.storeId
34
+ ? await storeRepository.findById(product.storeId).catch(() => null)
35
+ : null;
36
+ if (reserveStore?.ownerId) {
37
+ await sendNotification({
38
+ userId: reserveStore.ownerId,
39
+ type: "auction_ended",
40
+ priority: "normal",
41
+ title: AUCTION_MESSAGES.RESERVE_NOT_MET_TITLE,
42
+ message: AUCTION_MESSAGES.RESERVE_NOT_MET_MESSAGE(product.title, winnerEntry.data.currency, winnerEntry.data.bidAmount, reserve),
43
+ relatedId: product.id,
44
+ relatedType: "product",
45
+ });
46
+ }
47
+ await Promise.allSettled(activeBids.slice(0, 50).map(({ data: bid }) => sendNotification({
48
+ userId: bid.userId,
49
+ type: "bid_lost",
50
+ priority: "normal",
51
+ title: AUCTION_MESSAGES.LOST_TITLE,
52
+ message: AUCTION_MESSAGES.RESERVE_NOT_MET_BIDDER_MESSAGE(product.title),
53
+ relatedId: product.id,
54
+ relatedType: "product",
55
+ })));
56
+ ctx.logger.info(`Auction ${product.id} ended below reserve — no winner`, {
57
+ highestBid: winnerEntry.data.bidAmount,
58
+ reservePrice: reserve,
59
+ });
60
+ return;
61
+ }
62
+ const batch = ctx.db.batch();
14
63
  bidRepository.markWon(batch, winnerEntry.ref);
15
64
  loserEntries.forEach(({ ref }) => bidRepository.markLost(batch, ref));
16
- const orderRef = orderRepository.createFromAuction(batch, {
17
- productId: product.id,
18
- productTitle: product.title,
19
- userId: winnerEntry.data.userId,
20
- userName: winnerEntry.data.userName,
21
- userEmail: winnerEntry.data.userEmail,
22
- storeId: product.storeId,
23
- amount: winnerEntry.data.bidAmount,
24
- currency: winnerEntry.data.currency,
25
- auctionProductId: product.id,
26
- });
27
- // Two-axis model: mark as sold without changing publish status
65
+ // Two-axis model: mark as sold without changing publish status.
28
66
  batch.update(ctx.db.collection("products").doc(product.id), {
29
67
  isSold: true,
30
68
  availableQuantity: 0,
31
69
  });
32
70
  await batch.commit();
33
- // Fan-out notifications after batch commits.
71
+ // The win becomes a LOCKED CART LINE, not an order.
72
+ //
73
+ // Settlement used to call `orderRepository.createFromAuction`, which wrote a
74
+ // document that was not an OrderDocument at all — flat productId/userId/
75
+ // unitPrice, no items[], no buyerId, no paymentMethod, no shippingAddress,
76
+ // and a Firestore auto-ID instead of the `order-…` semantic id. No orders UI
77
+ // could render it and the manual-payment panel returned null for it, so an
78
+ // auction winner had literally no way to pay, anywhere in the product.
79
+ //
80
+ // Routing the win through the cart instead means the winner goes through the
81
+ // ONE checkout that already knows how to collect an address, charge a payment
82
+ // method, run the high-value OTP, split per store and produce a real order.
83
+ // Auctions stay `canAddToCart: false` for user-initiated adds — this writes
84
+ // through the repository directly, the same deliberate bypass `checkoutOffer`
85
+ // uses for accepted offers.
86
+ await cartRepository.addItem(winnerEntry.data.userId, {
87
+ productId: product.id,
88
+ productTitle: product.title,
89
+ productImage: product.mainImage ?? "",
90
+ price: winnerEntry.data.bidAmount,
91
+ currency: winnerEntry.data.currency,
92
+ quantity: 1,
93
+ storeId: product.storeId,
94
+ storeName: product.storeName ?? "",
95
+ listingType: "auction",
96
+ isAuctionWin: true,
97
+ auctionId: product.id,
98
+ bidId: winnerEntry.data.id,
99
+ lockedPrice: winnerEntry.data.bidAmount,
100
+ checkoutDeadline: new Date(ctx.now.getTime() + AUCTION_CHECKOUT_WINDOW_MS),
101
+ locked: true,
102
+ });
34
103
  await sendNotification({
35
104
  userId: winnerEntry.data.userId,
36
105
  type: "bid_won",
@@ -39,6 +108,10 @@ async function settleAuction(ctx, product) {
39
108
  message: AUCTION_MESSAGES.WON_MESSAGE(product.title, winnerEntry.data.currency, winnerEntry.data.bidAmount),
40
109
  relatedId: product.id,
41
110
  relatedType: "product",
111
+ // Previously the winner's notification linked back to the product page and
112
+ // carried no CTA — the one place it should never send them.
113
+ actionUrl: AUCTION_CHECKOUT_URL,
114
+ actionLabel: AUCTION_MESSAGES.WON_ACTION_LABEL,
42
115
  });
43
116
  await Promise.allSettled(loserEntries.slice(0, 50).map(({ data: bid }) => sendNotification({
44
117
  userId: bid.userId,
@@ -53,7 +126,7 @@ async function settleAuction(ctx, product) {
53
126
  winner: winnerEntry.data.userId,
54
127
  winningBid: winnerEntry.data.bidAmount,
55
128
  losersCount: loserEntries.length,
56
- orderId: orderRef.id,
129
+ bidId: winnerEntry.data.id,
57
130
  });
58
131
  }
59
132
  export async function runAuctionSettlement(ctx) {
@@ -1,5 +1,5 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
- import { offerRepository } from "../../../../repositories";
2
+ import { bidRepository, cartRepository, offerRepository } from "../../../../repositories";
3
3
  import { sendNotification } from "../../../../features/admin/actions/notification-actions";
4
4
  export async function runOfferExpiry(ctx) {
5
5
  ctx.logger.info("Starting offer expiry sweep");
@@ -11,11 +11,15 @@ export async function runOfferExpiry(ctx) {
11
11
  ctx.logger.error("Failed to query expired offers", err);
12
12
  throw err;
13
13
  }
14
+ // NOTE: no early return here. This job sweeps three distinct things and the
15
+ // other two (accepted-past-deadline offers, unpaid auction wins) must still
16
+ // run when there happen to be no expired pending/countered offers.
14
17
  if (expiredOffers.length === 0) {
15
- ctx.logger.info("No expired offers found");
16
- return;
18
+ ctx.logger.info("No expired pending/countered offers found");
19
+ }
20
+ else {
21
+ ctx.logger.info(`Found ${expiredOffers.length} expired offer(s) to process`);
17
22
  }
18
- ctx.logger.info(`Found ${expiredOffers.length} expired offer(s) to process`);
19
23
  const expiredIds = [];
20
24
  for (const offer of expiredOffers) {
21
25
  try {
@@ -46,8 +50,116 @@ export async function runOfferExpiry(ctx) {
46
50
  throw err;
47
51
  }
48
52
  }
53
+ await expireAcceptedPastCheckoutDeadline(ctx);
54
+ await lapseUnpaidAuctionWins(ctx);
49
55
  ctx.logger.info("Offer expiry complete", {
50
56
  processed: expiredIds.length,
51
57
  skipped: expiredOffers.length - expiredIds.length,
52
58
  });
53
59
  }
60
+ /**
61
+ * The second half of the sweep, missing until 2026-08-21.
62
+ *
63
+ * `findExpiredActive` only looks at pending/countered offers past `expiresAt`.
64
+ * An offer the seller ACCEPTED but the buyer never paid for stayed "accepted"
65
+ * forever: its locked price remained claimable long after the 48h checkout
66
+ * window the buyer was shown, and the seller had no way to take the listing
67
+ * back. This lapses those offers and clears the matching locked cart lines, so
68
+ * the lane gate stops blocking a buyer on an offer that is no longer live.
69
+ */
70
+ async function expireAcceptedPastCheckoutDeadline(ctx) {
71
+ let stale;
72
+ try {
73
+ stale = await offerRepository.findExpiredAccepted(ctx.now);
74
+ }
75
+ catch (err) {
76
+ void normalizeError(err);
77
+ ctx.logger.error("Failed to query accepted-but-unpaid offers", err);
78
+ return;
79
+ }
80
+ if (stale.length === 0)
81
+ return;
82
+ ctx.logger.info(`Found ${stale.length} accepted offer(s) past their checkout deadline`);
83
+ const ids = [];
84
+ for (const offer of stale) {
85
+ try {
86
+ // Drop the locked cart line first — leaving it behind would keep the
87
+ // buyer's offer lane non-empty and block their standard checkout on an
88
+ // offer they can no longer act on.
89
+ await cartRepository.removeItemsByOfferId(offer.buyerUid, offer.id);
90
+ ids.push(offer.id);
91
+ await sendNotification({
92
+ userId: offer.buyerUid,
93
+ type: "offer_expired",
94
+ priority: "normal",
95
+ title: "Accepted offer expired",
96
+ message: `The checkout window for your accepted offer on "${offer.productTitle}" has closed. You can make a new offer if it's still listed.`,
97
+ relatedId: offer.id,
98
+ relatedType: "offer",
99
+ });
100
+ }
101
+ catch (err) {
102
+ void normalizeError(err);
103
+ ctx.logger.warn(`Failed to lapse accepted offer ${offer.id}`, {
104
+ error: err instanceof Error ? err.message : String(err),
105
+ });
106
+ }
107
+ }
108
+ if (ids.length > 0) {
109
+ try {
110
+ await offerRepository.expireMany(ids);
111
+ }
112
+ catch (err) {
113
+ void normalizeError(err);
114
+ ctx.logger.error("Failed to batch-expire accepted offers", err);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * A won auction the buyer never paid for.
120
+ *
121
+ * Folded into this job rather than given its own scheduled function: both are
122
+ * "a locked cart line whose claim has lapsed", they want the same cadence, and
123
+ * Cloud Scheduler bills per registered job (see CLAUDE.md's Firebase budget
124
+ * table — 27 jobs is already an accepted, documented cost).
125
+ *
126
+ * The listing is NOT silently relisted. A forfeited win is a seller decision
127
+ * (relist, offer to the runner-up, or ban the non-payer), so this clears the
128
+ * line, marks the bid forfeited, and notifies both sides.
129
+ */
130
+ async function lapseUnpaidAuctionWins(ctx) {
131
+ let stale;
132
+ try {
133
+ stale = await cartRepository.findExpiredLockedLines(ctx.now);
134
+ }
135
+ catch (err) {
136
+ void normalizeError(err);
137
+ ctx.logger.error("Failed to scan for expired locked cart lines", err);
138
+ return;
139
+ }
140
+ const auctionLines = stale.filter(({ item }) => item.bidId && item.isAuctionWin);
141
+ if (auctionLines.length === 0)
142
+ return;
143
+ ctx.logger.info(`Found ${auctionLines.length} unpaid auction win(s) past deadline`);
144
+ for (const { userId, item } of auctionLines) {
145
+ try {
146
+ await cartRepository.removeItemsByBidId(userId, item.bidId);
147
+ await bidRepository.markForfeited(item.bidId);
148
+ await sendNotification({
149
+ userId,
150
+ type: "auction_ended",
151
+ priority: "high",
152
+ title: "Auction win forfeited",
153
+ message: `You didn't complete payment for "${item.productTitle}" in time, so the win has been forfeited. Repeated non-payment can restrict your account.`,
154
+ relatedId: item.auctionId ?? item.productId,
155
+ relatedType: "product",
156
+ });
157
+ }
158
+ catch (err) {
159
+ void normalizeError(err);
160
+ ctx.logger.warn(`Failed to lapse auction win ${item.bidId}`, {
161
+ error: err instanceof Error ? err.message : String(err),
162
+ });
163
+ }
164
+ }
165
+ }
@@ -1,5 +1,5 @@
1
1
  import type { JobContext } from "../runtime/types";
2
- export declare const SANDBOX_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events"];
2
+ export declare const SANDBOX_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events", "offers"];
3
3
  export interface TesterSandboxCleanupOptions {
4
4
  /** true = wipe every isTestData:true doc regardless of testDataExpiresAt (manual force-purge). */
5
5
  force?: boolean;
@@ -3,7 +3,10 @@ import { BID_FIELDS } from "../../../../constants/field-names";
3
3
  // Exported so testerSandboxRefresh.ts (the every-4h revert+prune job) reuses
4
4
  // the exact same collection scope instead of redefining it — one source of
5
5
  // truth for "what counts as sandbox scope."
6
- export const SANDBOX_COLLECTIONS = ["categories", "stores", "products", "blogPosts", "events"];
6
+ // Keep in sync with testerSandboxRefresh.ts's SEED_BY_COLLECTION the daily
7
+ // TTL cleanup and the 4-hourly revert+prune must agree on what "sandbox" means
8
+ // (CLAUDE.md § Tester QA standards #3).
9
+ export const SANDBOX_COLLECTIONS = ["categories", "stores", "products", "blogPosts", "events", "offers"];
7
10
  const BID_CHUNK_SIZE = 30; // Firestore `in` query cap
8
11
  export async function runTesterSandboxCleanup(ctx, opts = {}) {
9
12
  const cutoff = opts.force ? null : new Date();
@@ -8,6 +8,7 @@ import { storesTesterSeedData } from "../../../../features/tester/seed-data/stor
8
8
  import { productsTesterSeedData } from "../../../../features/tester/seed-data/products-tester-seed-data";
9
9
  import { blogTesterSeedData } from "../../../../features/tester/seed-data/blog-tester-seed-data";
10
10
  import { eventsTesterSeedData } from "../../../../features/tester/seed-data/events-tester-seed-data";
11
+ import { offersTesterSeedData } from "../../../../features/tester/seed-data/offers-tester-seed-data";
11
12
  import { bidsTesterSeedData } from "../../../../features/tester/seed-data/bids-tester-seed-data";
12
13
  const SEED_BY_COLLECTION = {
13
14
  categories: categoriesTesterSeedData,
@@ -15,6 +16,7 @@ const SEED_BY_COLLECTION = {
15
16
  products: productsTesterSeedData,
16
17
  blogPosts: blogTesterSeedData,
17
18
  events: eventsTesterSeedData,
19
+ offers: offersTesterSeedData,
18
20
  };
19
21
  const BID_CHUNK_SIZE = 30; // Firestore `in` query cap
20
22
  /**
@@ -2,6 +2,7 @@
2
2
  * Shared helpers for job handlers.
3
3
  */
4
4
  import { getAdminRealtimeDb } from "../../../../providers/db-firebase";
5
+ import { PRODUCT_FIELDS } from "../../../../constants/field-names";
5
6
  import { BATCH_LIMIT } from "./messages";
6
7
  /** Batch-delete document refs, respecting Firestore's 500-write batch limit. */
7
8
  export async function batchDelete(ctx, refs) {
@@ -23,7 +24,9 @@ export { getAdminRealtimeDb };
23
24
  * only the ones past testDataExpiresAt (the scheduled 7-day sweep).
24
25
  */
25
26
  export async function getTestDataRefs(db, collection, cutoff) {
26
- let q = db.collection(collection).where("isTestData", "==", true);
27
+ let q = db
28
+ .collection(collection)
29
+ .where(PRODUCT_FIELDS.IS_TEST_DATA, "==", true);
27
30
  if (cutoff)
28
31
  q = q.where("testDataExpiresAt", "<=", cutoff);
29
32
  const snap = await q.limit(200).get();
@@ -10,6 +10,11 @@ export declare const AUCTION_MESSAGES: {
10
10
  LOST_TITLE: string;
11
11
  LOST_MESSAGE: (productTitle: string) => string;
12
12
  NO_BIDS_LOG: (productId: string) => string;
13
+ /** CTA on the winner's notification — links into the auction checkout lane. */
14
+ WON_ACTION_LABEL: string;
15
+ RESERVE_NOT_MET_TITLE: string;
16
+ RESERVE_NOT_MET_MESSAGE: (productTitle: string, currency: string, highestBid: number, reservePrice: number) => string;
17
+ RESERVE_NOT_MET_BIDDER_MESSAGE: (productTitle: string) => string;
13
18
  };
14
19
  export declare const BID_MESSAGES: {
15
20
  OUTBID_TITLE: string;
@@ -10,6 +10,11 @@ export const AUCTION_MESSAGES = {
10
10
  LOST_TITLE: "Auction ended",
11
11
  LOST_MESSAGE: (productTitle) => `The auction for "${productTitle}" has ended. You were outbid.`,
12
12
  NO_BIDS_LOG: (productId) => `Auction ${productId} ended with no bids`,
13
+ /** CTA on the winner's notification — links into the auction checkout lane. */
14
+ WON_ACTION_LABEL: "Pay now",
15
+ RESERVE_NOT_MET_TITLE: "Auction ended below reserve",
16
+ RESERVE_NOT_MET_MESSAGE: (productTitle, currency, highestBid, reservePrice) => `"${productTitle}" ended at ${currency} ${highestBid}, below your reserve of ${currency} ${reservePrice}. No winner was declared and the listing was archived.`,
17
+ RESERVE_NOT_MET_BIDDER_MESSAGE: (productTitle) => `The auction for "${productTitle}" ended without meeting the seller's reserve price, so it did not sell.`,
13
18
  };
14
19
  export const BID_MESSAGES = {
15
20
  OUTBID_TITLE: "You've been outbid",
@@ -0,0 +1,41 @@
1
+ export declare const CART_LANE: {
2
+ readonly AUCTION: "auction";
3
+ readonly OFFER: "offer";
4
+ readonly STANDARD: "standard";
5
+ };
6
+ export type CartLane = (typeof CART_LANE)[keyof typeof CART_LANE];
7
+ /**
8
+ * Highest obligation first. An auction win is a completed sale the buyer already
9
+ * committed to by bidding; an accepted offer is a price the seller has held for
10
+ * them on a clock; ordinary cart items carry no commitment at all.
11
+ */
12
+ export declare const CART_LANE_PRIORITY: readonly CartLane[];
13
+ export declare const CART_LANE_LABELS: Record<CartLane, string>;
14
+ /** The two lanes whose price is fixed outside the listing. */
15
+ export declare function isLockedLane(lane: CartLane): boolean;
16
+ /** Minimal shape this module needs — works on cart documents and client rows alike. */
17
+ export interface LaneAssignable {
18
+ isAuctionWin?: boolean;
19
+ isOffer?: boolean;
20
+ offerId?: string;
21
+ bidId?: string;
22
+ }
23
+ export declare function laneOf(item: LaneAssignable): CartLane;
24
+ export declare function laneItems<T extends LaneAssignable>(items: readonly T[], lane: CartLane): T[];
25
+ export declare function laneCounts(items: readonly LaneAssignable[]): Record<CartLane, number>;
26
+ /**
27
+ * The one lane the buyer may check out right now, or null for an empty cart.
28
+ */
29
+ export declare function activeLane(items: readonly LaneAssignable[]): CartLane | null;
30
+ export declare function isLaneCheckoutable(items: readonly LaneAssignable[], lane: CartLane): boolean;
31
+ /**
32
+ * Why `lane` can't be checked out right now — null when it can.
33
+ * Phrased for the buyer, naming the lane that's blocking and what to do.
34
+ */
35
+ export declare function laneBlockReason(items: readonly LaneAssignable[], lane: CartLane): string | null;
36
+ /**
37
+ * Whether a NEW item may be added to the cart at all. While a higher-obligation
38
+ * lane is pending, adding more shopping would let the buyer keep deferring the
39
+ * thing they already committed to.
40
+ */
41
+ export declare function canAddNewItems(items: readonly LaneAssignable[]): boolean;
@@ -0,0 +1,106 @@
1
+ /*
2
+ * WHY: A cart can hold three kinds of line with three different obligations — a
3
+ * won auction (you owe it), an accepted offer (you negotiated it, at a
4
+ * locked price, on a deadline), and ordinary shopping. Mixing them into one
5
+ * total lets a buyer settle a ₹200 sticker while a ₹30,000 auction win they
6
+ * already committed to sits unpaid, and makes every total ambiguous.
7
+ * WHAT: The lane model — a derived (never stored) partition of the cart, with a
8
+ * strict priority order. Exactly one lane is checkout-able at a time: the
9
+ * highest-priority non-empty one. Lower lanes stay visible but disabled,
10
+ * and while a higher lane is pending nothing new may be added to the cart.
11
+ *
12
+ * Derived on purpose. A stored `lane` mirror field would drift the first time a
13
+ * write path forgot it (CLAUDE.md Root Cause #42), exactly as the manual-payment
14
+ * queue state is derived rather than stored.
15
+ *
16
+ * EXPORTS:
17
+ * CartLane, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane,
18
+ * laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane
19
+ *
20
+ * @tag domain:checkout,cart
21
+ * @tag layer:shared
22
+ * @tag pattern:none
23
+ * @tag access:isomorphic
24
+ * @tag consumers:CartRouteClient,CheckoutRouteClient,cart-actions,order-splitter,checkout/actions
25
+ * @tag sideEffects:none
26
+ */
27
+ export const CART_LANE = {
28
+ AUCTION: "auction",
29
+ OFFER: "offer",
30
+ STANDARD: "standard",
31
+ };
32
+ /**
33
+ * Highest obligation first. An auction win is a completed sale the buyer already
34
+ * committed to by bidding; an accepted offer is a price the seller has held for
35
+ * them on a clock; ordinary cart items carry no commitment at all.
36
+ */
37
+ export const CART_LANE_PRIORITY = [
38
+ CART_LANE.AUCTION,
39
+ CART_LANE.OFFER,
40
+ CART_LANE.STANDARD,
41
+ ];
42
+ export const CART_LANE_LABELS = {
43
+ auction: "Auction wins",
44
+ offer: "Accepted offers",
45
+ standard: "Cart",
46
+ };
47
+ /** The two lanes whose price is fixed outside the listing. */
48
+ export function isLockedLane(lane) {
49
+ return lane === CART_LANE.AUCTION || lane === CART_LANE.OFFER;
50
+ }
51
+ export function laneOf(item) {
52
+ if (item.isAuctionWin || item.bidId)
53
+ return CART_LANE.AUCTION;
54
+ if (item.isOffer || item.offerId)
55
+ return CART_LANE.OFFER;
56
+ return CART_LANE.STANDARD;
57
+ }
58
+ export function laneItems(items, lane) {
59
+ return items.filter((i) => laneOf(i) === lane);
60
+ }
61
+ export function laneCounts(items) {
62
+ const counts = { auction: 0, offer: 0, standard: 0 };
63
+ for (const item of items)
64
+ counts[laneOf(item)] += 1;
65
+ return counts;
66
+ }
67
+ /**
68
+ * The one lane the buyer may check out right now, or null for an empty cart.
69
+ */
70
+ export function activeLane(items) {
71
+ const counts = laneCounts(items);
72
+ return CART_LANE_PRIORITY.find((lane) => counts[lane] > 0) ?? null;
73
+ }
74
+ export function isLaneCheckoutable(items, lane) {
75
+ return activeLane(items) === lane;
76
+ }
77
+ /**
78
+ * Why `lane` can't be checked out right now — null when it can.
79
+ * Phrased for the buyer, naming the lane that's blocking and what to do.
80
+ */
81
+ export function laneBlockReason(items, lane) {
82
+ const active = activeLane(items);
83
+ if (active === lane)
84
+ return null;
85
+ const counts = laneCounts(items);
86
+ if (counts[lane] === 0)
87
+ return "There's nothing in this tab yet.";
88
+ if (active === CART_LANE.AUCTION) {
89
+ const n = counts.auction;
90
+ return `Settle your ${n} won auction${n === 1 ? "" : "s"} first — auction wins are already committed purchases.`;
91
+ }
92
+ if (active === CART_LANE.OFFER) {
93
+ const n = counts.offer;
94
+ return `Complete your ${n} accepted offer${n === 1 ? "" : "s"} first — the agreed price is only held for a limited time.`;
95
+ }
96
+ return "This tab can't be checked out right now.";
97
+ }
98
+ /**
99
+ * Whether a NEW item may be added to the cart at all. While a higher-obligation
100
+ * lane is pending, adding more shopping would let the buyer keep deferring the
101
+ * thing they already committed to.
102
+ */
103
+ export function canAddNewItems(items) {
104
+ const active = activeLane(items);
105
+ return active === null || active === CART_LANE.STANDARD;
106
+ }
@@ -6,6 +6,25 @@
6
6
  */
7
7
  import type { CartItemDocument } from "../../../features/cart/schemas/firestore";
8
8
  import type { ProductDocument } from "../../../features/products/schemas/firestore";
9
+ /**
10
+ * The single source of truth for "what do we charge for this line".
11
+ *
12
+ * Three cases, in priority order:
13
+ *
14
+ * 1. **A locked price** — an accepted offer or a won auction. `lockedPrice` is
15
+ * the negotiated/won amount and it OVERRIDES the current listing price, which
16
+ * is the whole point of the negotiation. Until 2026-08-21 none of the five
17
+ * copies of this function looked at `lockedPrice`, so an accepted offer was
18
+ * billed at the seller's listed price while the cart UI displayed the agreed
19
+ * one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
20
+ * 2. **A bundle line** (SB-UNI-5 2026-05-13) — `item.price` is the bundle price
21
+ * locked at add-time.
22
+ * 3. **Everything else** — the live Firestore price, so a stale cart-cached
23
+ * price is never charged on a COD/UPI order.
24
+ *
25
+ * `product` may be null for a locked line whose listing has since been archived;
26
+ * the locked price is still owed, so that case must not dereference `product`.
27
+ */
9
28
  export declare function unitPriceFor(item: CartItemDocument, product: ProductDocument | null): number;
10
29
  /**
11
30
  * P-6 — pre-order groups charge each product's own `preOrderDepositPercent`
@@ -1,11 +1,35 @@
1
1
  import { roundRupees } from "../../../utils/number.formatter";
2
- // SB-UNI-5 2026-05-13 — bundle cart-lines use item.price (locked bundle price
3
- // at add-time); regular lines use product.price (current Firestore). Prevents
4
- // stale cart-cached prices from being charged on COD/UPI orders.
2
+ /**
3
+ * The single source of truth for "what do we charge for this line".
4
+ *
5
+ * Three cases, in priority order:
6
+ *
7
+ * 1. **A locked price** — an accepted offer or a won auction. `lockedPrice` is
8
+ * the negotiated/won amount and it OVERRIDES the current listing price, which
9
+ * is the whole point of the negotiation. Until 2026-08-21 none of the five
10
+ * copies of this function looked at `lockedPrice`, so an accepted offer was
11
+ * billed at the seller's listed price while the cart UI displayed the agreed
12
+ * one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
13
+ * 2. **A bundle line** (SB-UNI-5 2026-05-13) — `item.price` is the bundle price
14
+ * locked at add-time.
15
+ * 3. **Everything else** — the live Firestore price, so a stale cart-cached
16
+ * price is never charged on a COD/UPI order.
17
+ *
18
+ * `product` may be null for a locked line whose listing has since been archived;
19
+ * the locked price is still owed, so that case must not dereference `product`.
20
+ */
5
21
  export function unitPriceFor(item, product) {
6
- return item.bundleCategorySlug && item.bundleProductIds?.length
7
- ? item.price
8
- : product.price;
22
+ if (typeof item.lockedPrice === "number" && item.lockedPrice > 0) {
23
+ return item.lockedPrice;
24
+ }
25
+ if (item.bundleCategorySlug && item.bundleProductIds?.length) {
26
+ return item.price;
27
+ }
28
+ // Null-safe fallback: one of the four call sites this replaced (the cart
29
+ // summary/preview path) already guarded with `product?.price ?? item.price`,
30
+ // and collapsing onto a non-null assertion would have regressed it into a
31
+ // crash whenever a cart line outlives its listing.
32
+ return product?.price ?? item.price;
9
33
  }
10
34
  /**
11
35
  * P-6 — pre-order groups charge each product's own `preOrderDepositPercent`