@mohasinac/appkit 4.11.2 → 4.12.0

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 (246) hide show
  1. package/dist/_internal/server/features/checkout/actions.d.ts +40 -3
  2. package/dist/_internal/server/features/checkout/actions.js +232 -47
  3. package/dist/_internal/server/features/checkout/data.d.ts +10 -0
  4. package/dist/_internal/server/features/orders/adapters.js +20 -0
  5. package/dist/_internal/server/features/products/data.d.ts +12 -3
  6. package/dist/_internal/server/features/products/data.js +55 -4
  7. package/dist/_internal/server/features/products/index.d.ts +1 -1
  8. package/dist/_internal/server/features/products/index.js +1 -1
  9. package/dist/_internal/server/features/products/list-public.d.ts +47 -0
  10. package/dist/_internal/server/features/products/list-public.js +131 -6
  11. package/dist/_internal/server/features/reviews/data.d.ts +1 -1
  12. package/dist/_internal/server/features/reviews/data.js +3 -2
  13. package/dist/_internal/server/features/tester/visibility.js +3 -3
  14. package/dist/_internal/server/jobs/core/auctionSettlement.js +5 -2
  15. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +1 -1
  16. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +4 -1
  17. package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +2 -0
  18. package/dist/_internal/server/jobs/handlers/_helpers.js +4 -1
  19. package/dist/_internal/shared/checkout/lanes.d.ts +20 -0
  20. package/dist/_internal/shared/checkout/lanes.js +28 -0
  21. package/dist/_internal/shared/features/checkout/config.d.ts +4 -0
  22. package/dist/_internal/shared/features/checkout/config.js +8 -0
  23. package/dist/_internal/shared/features/promotions/schema.d.ts +0 -16
  24. package/dist/_internal/shared/features/promotions/schema.js +0 -2
  25. package/dist/_internal/shared/features/reviews/config.d.ts +8 -1
  26. package/dist/_internal/shared/features/reviews/config.js +8 -1
  27. package/dist/_internal/shared/fees/calculator.d.ts +34 -2
  28. package/dist/_internal/shared/fees/calculator.js +45 -1
  29. package/dist/_internal/shared/listing-types/_registry.d.ts +88 -1
  30. package/dist/_internal/shared/listing-types/_registry.js +63 -1
  31. package/dist/_internal/shared/listing-types/art/config.d.ts +59 -0
  32. package/dist/_internal/shared/listing-types/art/config.js +12 -0
  33. package/dist/_internal/shared/listing-types/auction/config.d.ts +53 -0
  34. package/dist/_internal/shared/listing-types/auction/config.js +17 -0
  35. package/dist/_internal/shared/listing-types/classified/config.d.ts +59 -0
  36. package/dist/_internal/shared/listing-types/classified/config.js +14 -0
  37. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +59 -0
  38. package/dist/_internal/shared/listing-types/digital-code/config.js +12 -0
  39. package/dist/_internal/shared/listing-types/live/config.d.ts +59 -0
  40. package/dist/_internal/shared/listing-types/live/config.js +14 -0
  41. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +110 -0
  42. package/dist/_internal/shared/listing-types/pre-order/config.js +14 -0
  43. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +44 -0
  44. package/dist/_internal/shared/listing-types/prize-draw/config.js +10 -0
  45. package/dist/_internal/shared/listing-types/standard/config.d.ts +59 -0
  46. package/dist/_internal/shared/listing-types/standard/config.js +9 -0
  47. package/dist/_internal/shared/listing-types/stickers/config.d.ts +59 -0
  48. package/dist/_internal/shared/listing-types/stickers/config.js +12 -0
  49. package/dist/client.d.ts +12 -2
  50. package/dist/client.js +8 -2
  51. package/dist/constants/api-endpoints.d.ts +6 -0
  52. package/dist/constants/api-endpoints.js +2 -0
  53. package/dist/constants/field-names.d.ts +12 -0
  54. package/dist/constants/field-names.js +12 -0
  55. package/dist/constants/table-keys.d.ts +2 -0
  56. package/dist/constants/table-keys.js +2 -0
  57. package/dist/features/account/components/UserOrdersView.js +27 -5
  58. package/dist/features/admin/components/AdminAddressesView.js +21 -11
  59. package/dist/features/admin/components/AdminArtView.d.ts +9 -0
  60. package/dist/features/admin/components/AdminArtView.js +11 -35
  61. package/dist/features/admin/components/AdminClassifiedView.d.ts +9 -0
  62. package/dist/features/admin/components/AdminClassifiedView.js +11 -35
  63. package/dist/features/admin/components/AdminCouponEditorView.js +1 -4
  64. package/dist/features/admin/components/AdminDigitalCodesView.d.ts +9 -0
  65. package/dist/features/admin/components/AdminDigitalCodesView.js +11 -42
  66. package/dist/features/admin/components/AdminLiveView.d.ts +9 -0
  67. package/dist/features/admin/components/AdminLiveView.js +11 -41
  68. package/dist/features/admin/components/AdminOrderEditorView.d.ts +8 -1
  69. package/dist/features/admin/components/AdminOrderEditorView.js +4 -3
  70. package/dist/features/admin/components/AdminOrdersView.js +14 -1
  71. package/dist/features/admin/components/AdminPaymentMethodsView.js +18 -11
  72. package/dist/features/admin/components/AdminProductsView.js +34 -18
  73. package/dist/features/admin/components/AdminSiteConfigGuideView.js +1 -1
  74. package/dist/features/admin/components/AdminSiteSettingsView.js +4 -2
  75. package/dist/features/admin/components/AdminStickersView.d.ts +9 -0
  76. package/dist/features/admin/components/AdminStickersView.js +11 -35
  77. package/dist/features/admin/constants/filter-tabs.d.ts +26 -9
  78. package/dist/features/admin/constants/filter-tabs.js +22 -9
  79. package/dist/features/admin/hooks/useAdminListing.js +7 -2
  80. package/dist/features/admin/schemas/firestore.d.ts +5 -0
  81. package/dist/features/admin/schemas/firestore.js +1 -0
  82. package/dist/features/auctions/actions/bid-actions.js +2 -2
  83. package/dist/features/auctions/components/AuctionBidsTable.js +1 -1
  84. package/dist/features/auctions/components/AuctionBottomActions.js +1 -0
  85. package/dist/features/auctions/components/AuctionDetailPageView.js +13 -11
  86. package/dist/features/cart/components/CartDrawer.d.ts +9 -1
  87. package/dist/features/cart/components/CartDrawer.js +4 -3
  88. package/dist/features/cart/components/CartPriceBreakdown.d.ts +61 -0
  89. package/dist/features/cart/components/CartPriceBreakdown.js +25 -0
  90. package/dist/features/cart/components/StoreAddonsPicker.d.ts +47 -0
  91. package/dist/features/cart/components/StoreAddonsPicker.js +21 -0
  92. package/dist/features/cart/components/index.d.ts +4 -0
  93. package/dist/features/cart/components/index.js +2 -0
  94. package/dist/features/cart/repository/cart.repository.d.ts +20 -2
  95. package/dist/features/cart/repository/cart.repository.js +49 -0
  96. package/dist/features/cart/schemas/firestore.d.ts +26 -2
  97. package/dist/features/categories/components/BrandDetailTabs.js +31 -11
  98. package/dist/features/categories/components/CategoryDetailTabs.js +27 -7
  99. package/dist/features/categories/components/CategoryProductsListing.d.ts +9 -1
  100. package/dist/features/categories/components/CategoryProductsListing.js +5 -2
  101. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -1
  102. package/dist/features/contact/email.js +5 -1
  103. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +17 -16
  104. package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
  105. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  106. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  107. package/dist/features/layout/AppLayoutShell.js +9 -2
  108. package/dist/features/layout/BottomActions.js +62 -6
  109. package/dist/features/layout/BottomActionsContext.d.ts +26 -0
  110. package/dist/features/layout/BottomActionsContext.js +18 -1
  111. package/dist/features/layout/hooks/useBottomActions.d.ts +12 -1
  112. package/dist/features/layout/hooks/useBottomActions.js +14 -1
  113. package/dist/features/layout/index.d.ts +1 -1
  114. package/dist/features/live/components/LiveItemDetailPageView.js +7 -6
  115. package/dist/features/orders/components/OrderAddonBadges.d.ts +36 -0
  116. package/dist/features/orders/components/OrderAddonBadges.js +28 -0
  117. package/dist/features/orders/components/index.d.ts +1 -0
  118. package/dist/features/orders/components/index.js +1 -0
  119. package/dist/features/orders/repository/orders.repository.d.ts +36 -0
  120. package/dist/features/orders/repository/orders.repository.js +18 -0
  121. package/dist/features/orders/types/index.d.ts +38 -0
  122. package/dist/features/pre-orders/components/PreOrderBottomActions.d.ts +1 -1
  123. package/dist/features/pre-orders/components/PreOrderBottomActions.js +3 -19
  124. package/dist/features/products/components/ArtStickersListView.js +5 -4
  125. package/dist/features/products/components/AuctionsIndexListing.js +1 -2
  126. package/dist/features/products/components/ListingBottomActions.d.ts +28 -0
  127. package/dist/features/products/components/ListingBottomActions.js +25 -0
  128. package/dist/features/products/components/PrizeDrawBottomActions.d.ts +1 -1
  129. package/dist/features/products/components/PrizeDrawBottomActions.js +3 -21
  130. package/dist/features/products/components/PrizeDrawsListingView.js +18 -33
  131. package/dist/features/products/components/ProductDetailActions.js +4 -0
  132. package/dist/features/products/components/ProductDetailPageView.js +7 -7
  133. package/dist/features/products/components/ProductsIndexListing.js +87 -11
  134. package/dist/features/products/components/ProductsIndexPageView.js +14 -2
  135. package/dist/features/products/config/listing-type-listing-config.d.ts +31 -0
  136. package/dist/features/products/config/listing-type-listing-config.js +74 -0
  137. package/dist/features/products/constants/listing-tabs.d.ts +43 -198
  138. package/dist/features/products/constants/listing-tabs.js +71 -58
  139. package/dist/features/products/constants/sieve.d.ts +104 -1
  140. package/dist/features/products/constants/sieve.js +27 -7
  141. package/dist/features/products/hooks/useProducts.js +14 -0
  142. package/dist/features/products/repository/products.repository.d.ts +45 -1
  143. package/dist/features/products/repository/products.repository.js +26 -3
  144. package/dist/features/products/types/index.d.ts +15 -0
  145. package/dist/features/products/utils/listing-badge-variant.d.ts +20 -1
  146. package/dist/features/products/utils/listing-badge-variant.js +26 -1
  147. package/dist/features/products/utils/listing-type.d.ts +24 -0
  148. package/dist/features/products/utils/listing-type.js +48 -0
  149. package/dist/features/promotions/actions/coupon-actions.d.ts +7 -0
  150. package/dist/features/promotions/actions/coupon-actions.js +12 -1
  151. package/dist/features/promotions/actions/coupon-stacking.d.ts +24 -0
  152. package/dist/features/promotions/actions/coupon-stacking.js +21 -0
  153. package/dist/features/promotions/actions/index.d.ts +1 -0
  154. package/dist/features/promotions/actions/index.js +1 -0
  155. package/dist/features/promotions/actions/seller-coupon-actions.d.ts +0 -1
  156. package/dist/features/promotions/actions/seller-coupon-actions.js +0 -1
  157. package/dist/features/promotions/components/CouponHelpDetails.d.ts +20 -0
  158. package/dist/features/promotions/components/CouponHelpDetails.js +17 -0
  159. package/dist/features/promotions/components/CouponsIndexListing.js +2 -1
  160. package/dist/features/promotions/components/index.d.ts +2 -0
  161. package/dist/features/promotions/components/index.js +1 -0
  162. package/dist/features/promotions/constants/coupon-help.d.ts +27 -0
  163. package/dist/features/promotions/constants/coupon-help.js +42 -0
  164. package/dist/features/promotions/repository/coupons.repository.d.ts +10 -1
  165. package/dist/features/promotions/repository/coupons.repository.js +22 -8
  166. package/dist/features/promotions/schemas/firestore.d.ts +0 -1
  167. package/dist/features/promotions/schemas/firestore.js +1 -1
  168. package/dist/features/promotions/schemas/index.d.ts +0 -20
  169. package/dist/features/promotions/schemas/index.js +0 -1
  170. package/dist/features/reviews/actions/review-actions.js +5 -1
  171. package/dist/features/reviews/components/ReviewDetailPageView.js +2 -1
  172. package/dist/features/reviews/components/ReviewFilters.d.ts +7 -1
  173. package/dist/features/reviews/components/ReviewFilters.js +4 -3
  174. package/dist/features/reviews/components/ReviewsIndexListing.d.ts +6 -0
  175. package/dist/features/reviews/components/ReviewsIndexListing.js +9 -109
  176. package/dist/features/reviews/components/ReviewsListingPanel.d.ts +41 -0
  177. package/dist/features/reviews/components/ReviewsListingPanel.js +226 -0
  178. package/dist/features/reviews/components/index.d.ts +2 -0
  179. package/dist/features/reviews/components/index.js +1 -0
  180. package/dist/features/reviews/hooks/useReviews.d.ts +8 -0
  181. package/dist/features/reviews/hooks/useReviews.js +14 -6
  182. package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
  183. package/dist/features/reviews/repository/reviews.repository.js +77 -41
  184. package/dist/features/reviews/types/index.d.ts +8 -1
  185. package/dist/features/seller/components/SellerOrdersView.js +2 -1
  186. package/dist/features/seller/components/SellerProductsCards.js +2 -2
  187. package/dist/features/seller/components/SellerProductsView.js +21 -18
  188. package/dist/features/stores/actions/store-query-actions.js +1 -1
  189. package/dist/features/stores/components/StoreArtStickersPageView.d.ts +21 -0
  190. package/dist/features/stores/components/StoreArtStickersPageView.js +30 -0
  191. package/dist/features/stores/components/StoreAuctionsListing.js +5 -11
  192. package/dist/features/stores/components/StoreAuctionsPageView.d.ts +4 -1
  193. package/dist/features/stores/components/StoreAuctionsPageView.js +17 -17
  194. package/dist/features/stores/components/StoreClassifiedsListing.js +7 -0
  195. package/dist/features/stores/components/StoreClassifiedsPageView.d.ts +4 -1
  196. package/dist/features/stores/components/StoreClassifiedsPageView.js +16 -12
  197. package/dist/features/stores/components/StoreDetailLayoutView.js +4 -3
  198. package/dist/features/stores/components/StoreDigitalCodesListing.js +5 -0
  199. package/dist/features/stores/components/StoreDigitalCodesPageView.d.ts +4 -1
  200. package/dist/features/stores/components/StoreDigitalCodesPageView.js +16 -12
  201. package/dist/features/stores/components/StoreLiveItemsListing.js +7 -0
  202. package/dist/features/stores/components/StoreLiveItemsPageView.d.ts +4 -1
  203. package/dist/features/stores/components/StoreLiveItemsPageView.js +16 -12
  204. package/dist/features/stores/components/StorePreOrdersListing.js +6 -11
  205. package/dist/features/stores/components/StorePreOrdersPageView.d.ts +4 -1
  206. package/dist/features/stores/components/StorePreOrdersPageView.js +17 -17
  207. package/dist/features/stores/components/StorePrizeDrawsPageView.d.ts +4 -8
  208. package/dist/features/stores/components/StorePrizeDrawsPageView.js +17 -22
  209. package/dist/features/stores/components/StoreProductsListing.d.ts +7 -1
  210. package/dist/features/stores/components/StoreProductsListing.js +3 -2
  211. package/dist/features/stores/components/StoreProductsPageView.d.ts +1 -1
  212. package/dist/features/stores/components/StoreProductsPageView.js +16 -21
  213. package/dist/features/stores/components/StoreReviewsListing.d.ts +4 -0
  214. package/dist/features/stores/components/StoreReviewsListing.js +7 -108
  215. package/dist/features/stores/schemas/index.d.ts +2 -2
  216. package/dist/features/tester/seed-data/coupons-tester-seed-data.js +3 -3
  217. package/dist/features/tester/seed-data/index.d.ts +1 -0
  218. package/dist/features/tester/seed-data/index.js +1 -0
  219. package/dist/features/tester/seed-data/offers-tester-seed-data.d.ts +2 -0
  220. package/dist/features/tester/seed-data/offers-tester-seed-data.js +61 -0
  221. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +939 -1
  222. package/dist/index.d.ts +9 -3
  223. package/dist/index.js +11 -2
  224. package/dist/next/routing/route-map.d.ts +2 -0
  225. package/dist/next/routing/route-map.js +5 -0
  226. package/dist/react/hooks/useListingTypeFlags.d.ts +12 -19
  227. package/dist/react/hooks/useListingTypeFlags.js +4 -9
  228. package/dist/schemas/registry.d.ts +0 -12
  229. package/dist/seed/claimed-coupons-seed-data.js +3 -3
  230. package/dist/seed/coupons-seed-data.js +29 -10
  231. package/dist/seed/faq-seed-data.js +2 -2
  232. package/dist/seed/manifest.js +2 -2
  233. package/dist/seed/reviews-seed-data.js +52 -0
  234. package/dist/server-entry.d.ts +1 -0
  235. package/dist/server-entry.js +1 -0
  236. package/dist/styles.css +55 -39
  237. package/dist/tailwind-utilities.css +1 -1
  238. package/dist/ui/components/Checkbox.d.ts +10 -1
  239. package/dist/ui/components/Checkbox.js +14 -2
  240. package/dist/ui/components/Checkbox.style.css +11 -0
  241. package/dist/ui/components/FilterChipGroup.d.ts +21 -3
  242. package/dist/ui/components/FilterChipGroup.js +33 -4
  243. package/dist/ui/components/HorizontalScroller.d.ts +1 -2
  244. package/dist/ui/components/HorizontalScroller.js +9 -5
  245. package/dist/ui/components/HorizontalScroller.style.css +43 -38
  246. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { type CartLane } from "../../../shared/checkout/lanes";
1
2
  import type { OutOfStockPolicy } from "../../../../features/orders/schemas/index";
2
3
  import { type CheckoutPaymentMethod } from "../../../shared/features/checkout/config";
3
4
  import { type CheckoutOrderResult } from "./data";
@@ -44,6 +45,10 @@ export declare function resolveShippingCost(storeId: string | undefined): Promis
44
45
  storeEmiEnabled: boolean;
45
46
  storeState: string | undefined;
46
47
  }>;
48
+ export interface DroppedCoupon {
49
+ code: string;
50
+ reason: string;
51
+ }
47
52
  /**
48
53
  * Place order(s) from the user's cart in a single Firestore transaction.
49
54
  *
@@ -57,9 +62,29 @@ export interface CheckoutPricingPreviewInput {
57
62
  addressId?: string;
58
63
  paymentMethod: CheckoutPaymentMethod;
59
64
  excludedProductIds?: string[];
60
- whatsappNotifyAddon?: boolean;
61
- giftWrapAddon?: boolean;
62
- shipmentProtectionAddon?: boolean;
65
+ /**
66
+ * Which cart lane to price. Only one lane is payable in a single checkout, so
67
+ * a preview spanning lanes would be a number the buyer can never be charged.
68
+ *
69
+ * Passed in rather than derived from `activeLane` server-side because the cart
70
+ * page previews whichever tab the buyer is looking at — including a lane that
71
+ * is currently gated off.
72
+ */
73
+ lane?: CartLane;
74
+ }
75
+ /** One store's slice of the preview — what that seller's order will cost. */
76
+ export interface CheckoutPricingPreviewStore {
77
+ storeId: string;
78
+ storeName: string;
79
+ subtotal: number;
80
+ shippingFee: number;
81
+ codHandlingFee: number;
82
+ whatsappNotifyFee: number;
83
+ giftWrapFee: number;
84
+ shipmentProtectionFee: number;
85
+ gstAmount: number;
86
+ couponDiscount: number;
87
+ total: number;
63
88
  }
64
89
  export interface CheckoutPricingPreview {
65
90
  subtotal: number;
@@ -70,7 +95,19 @@ export interface CheckoutPricingPreview {
70
95
  shipmentProtectionFee: number;
71
96
  gstAmount: number;
72
97
  couponDiscount: number;
98
+ /** Buyer-facing platform commission, capped, charged once for this checkout. */
99
+ platformFee: number;
100
+ /** GST on the (capped) platform commission. */
101
+ gstOnFee: number;
73
102
  total: number;
103
+ /**
104
+ * Per-store breakdown, in group order. Every figure here was already being
105
+ * computed inside the per-group loop and then discarded into an aggregate —
106
+ * surfacing it is what lets each seller card show its own fees.
107
+ *
108
+ * A store with no selected items forms no group and therefore never appears.
109
+ */
110
+ stores: CheckoutPricingPreviewStore[];
74
111
  }
75
112
  /**
76
113
  * Read-only "what will I actually be charged" preview for the checkout
@@ -17,9 +17,10 @@ import { computePreOrderDepositAmount, unitPriceFor } from "../../../shared/chec
17
17
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
18
18
  import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
19
19
  import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
20
+ import { resolveCouponCategorySlugs } from "../../../../features/promotions/actions/coupon-actions";
20
21
  import { assertCheckoutLane, assertLockedLinesStillValid, finalizeLockedLines, } from "./locked-lines";
21
22
  import { activeLane, laneOf } from "../../../shared/checkout/lanes";
22
- import { computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, } from "../../../shared/fees/calculator";
23
+ import { computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, computeCheckoutFees, allocateCheckoutFees, } from "../../../shared/fees/calculator";
23
24
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase";
24
25
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore";
25
26
  import { CART_COLLECTION } from "../../../../features/cart/schemas/index";
@@ -238,6 +239,90 @@ function buildStockUpdatePayload(product, qtyDelta) {
238
239
  ...rule.stockDecrementExtras(product, qtyDelta),
239
240
  };
240
241
  }
242
+ /**
243
+ * Re-checks every coupon persisted on the cart against the items actually
244
+ * being ordered, immediately before pricing.
245
+ *
246
+ * A cart can sit for days, so the `discountAmount` frozen onto
247
+ * `cart.appliedCoupons` at apply-time may since have become wrong or
248
+ * unearned — the coupon may have expired, hit its total/per-user limit, been
249
+ * deactivated, or had its eligible items removed from the cart. Trusting the
250
+ * stored amount let all of those redeem anyway.
251
+ *
252
+ * Invalid coupons are DROPPED (and reported back to the caller) rather than
253
+ * failing the whole checkout: the buyer is on the payment step and cannot fix
254
+ * a lapsed coupon from there, so killing an otherwise-valid order is hostile.
255
+ *
256
+ * One parallel round of reads for at most a handful of coupons (the stacking
257
+ * rule caps it at one per store plus one platform-wide), so this stays inside
258
+ * the Vercel Hobby round-trip budget.
259
+ */
260
+ async function revalidateAppliedCoupons(appliedCoupons, available, uid) {
261
+ if (appliedCoupons.length === 0)
262
+ return { coupons: [], dropped: [] };
263
+ const cartItems = available.map(({ item, product }) => ({
264
+ productId: item.productId,
265
+ storeId: item.storeId,
266
+ price: unitPriceFor(item, product),
267
+ quantity: item.quantity,
268
+ listingType: (item.listingType ?? "standard"),
269
+ }));
270
+ const results = await Promise.all(appliedCoupons.map(async (coupon) => {
271
+ try {
272
+ const check = await couponsRepository.validateCouponForCart(coupon.code, uid, cartItems, { resolveCategorySlugs: resolveCouponCategorySlugs });
273
+ if (!check.valid) {
274
+ return {
275
+ dropped: {
276
+ code: coupon.code,
277
+ reason: check.message ?? "This coupon is no longer valid",
278
+ },
279
+ };
280
+ }
281
+ // Re-derive the amount and the eligible lines from the CURRENT cart —
282
+ // the stored values were computed against whatever was in the cart at
283
+ // apply-time.
284
+ const applicableItemIds = check.eligibleProductIds
285
+ ? available
286
+ .filter(({ item }) => check.eligibleProductIds.includes(item.productId))
287
+ .map(({ item }) => item.itemId)
288
+ : undefined;
289
+ return {
290
+ coupon: {
291
+ ...coupon,
292
+ discountAmount: check.discountAmount ?? 0,
293
+ scope: check.scope ?? coupon.scope,
294
+ storeId: check.storeId ?? coupon.storeId,
295
+ applicableItemIds,
296
+ },
297
+ };
298
+ }
299
+ catch (err) {
300
+ void normalizeError(err);
301
+ // A lookup failure must not silently grant the discount.
302
+ return {
303
+ dropped: {
304
+ code: coupon.code,
305
+ reason: "This coupon could not be verified",
306
+ },
307
+ };
308
+ }
309
+ }));
310
+ const coupons = [];
311
+ const dropped = [];
312
+ for (const r of results) {
313
+ if (r.coupon)
314
+ coupons.push(r.coupon);
315
+ else if (r.dropped)
316
+ dropped.push(r.dropped);
317
+ }
318
+ if (dropped.length > 0) {
319
+ serverLogger.info("Dropped invalid coupons at checkout", {
320
+ uid,
321
+ codes: dropped.map((d) => d.code).join(","),
322
+ });
323
+ }
324
+ return { coupons, dropped };
325
+ }
241
326
  async function flushCouponUsageAccumulator(accumulator, uid) {
242
327
  if (accumulator.size === 0)
243
328
  return;
@@ -282,9 +367,12 @@ function dispatchOrderConfirmationEmails(emailsToSend) {
282
367
  * so the caller can accumulate the checkout-wide `total`.
283
368
  */
284
369
  async function createOrderForGroup(group, orderType, ctx) {
285
- const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, whatsappNotifyAddon, giftWrapAddon, giftWrapMessage, shipmentProtectionAddon, } = ctx;
370
+ const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, } = ctx;
286
371
  const firstItem = group[0].item;
287
372
  const firstProduct = group[0].product;
373
+ // Add-ons are this store's choice, not the cart's — see OrderGroupContext.
374
+ const { whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, } = storeAddons?.[firstItem.storeId] ?? {};
375
+ const { platformFee = 0, gstOnFee: platformFeeGst = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
288
376
  const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
289
377
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
290
378
  const orderItems = group.map(({ item, product }) => {
@@ -371,7 +459,10 @@ async function createOrderForGroup(group, orderType, ctx) {
371
459
  groupCouponCodes.add(discount.code);
372
460
  }
373
461
  }
374
- const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + (emiSchedule?.surchargeAmount ?? 0) + (gstBreakdown?.gstAmount ?? 0);
462
+ // The platform commission is now charged on EVERY payment method. It used to
463
+ // be added only on the Razorpay path, so COD / UPI-manual / cash / EMI buyers
464
+ // silently never paid it.
465
+ const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0) + (gstBreakdown?.gstAmount ?? 0);
375
466
  const imageUrls = [
376
467
  ...new Set(group
377
468
  .map(({ product }) => product.mainImage)
@@ -425,6 +516,9 @@ async function createOrderForGroup(group, orderType, ctx) {
425
516
  depositAmount: adminBypass ? undefined : depositAmount,
426
517
  codRemainingAmount: adminBypass ? undefined : codRemainingAmount,
427
518
  codHandlingFee: !adminBypass && codHandlingFee > 0 ? codHandlingFee : undefined,
519
+ // This group's pro-rata share of the checkout's single capped commission —
520
+ // the shares across a multi-store checkout sum to exactly what was charged.
521
+ platformFee: !adminBypass && platformFee > 0 ? platformFee : undefined,
428
522
  whatsappNotifyAddon: !adminBypass && whatsappNotifyFee > 0 ? true : undefined,
429
523
  whatsappNotifyFee: !adminBypass && whatsappNotifyFee > 0 ? whatsappNotifyFee : undefined,
430
524
  giftWrapAddon: !adminBypass && giftWrapFee > 0 ? true : undefined,
@@ -506,7 +600,7 @@ async function createOrderForGroup(group, orderType, ctx) {
506
600
  * auth + rate-limiting before calling this and supply the resolved user fields.
507
601
  */
508
602
  export async function createCheckoutOrderAction(input) {
509
- const { userId: uid, userName, userEmail, addressId, paymentMethod, emiTenureMonths, notes, excludedProductIds = [], adminBypass = false, adminBypassBy, outOfStockPolicy = OutOfStockPolicyValues.SKIP_ITEMS, whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, } = input;
603
+ const { userId: uid, userName, userEmail, addressId, paymentMethod, emiTenureMonths, notes, excludedProductIds = [], adminBypass = false, adminBypassBy, outOfStockPolicy = OutOfStockPolicyValues.SKIP_ITEMS, } = input;
510
604
  const siteSettings = await siteSettingsRepository.getSingleton();
511
605
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
512
606
  const emiSettings = siteSettings?.emi ?? CHECKOUT_DEFAULT_EMI_SETTINGS;
@@ -701,7 +795,7 @@ export async function createCheckoutOrderAction(input) {
701
795
  .catch((logErr) => { void normalizeError(logErr); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: logErr instanceof Error ? logErr.message : String(logErr) }); });
702
796
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.INSUFFICIENT_STOCK);
703
797
  }
704
- const appliedCoupons = cart.appliedCoupons ?? [];
798
+ const { coupons: appliedCoupons, dropped: droppedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], available, uid);
705
799
  const orderGroups = splitCartIntoOrderGroups(available);
706
800
  // Admin-bypass orders are immediately paid and processing; generate a shared
707
801
  // transaction reference for all orders in this checkout batch.
@@ -713,6 +807,12 @@ export async function createCheckoutOrderAction(input) {
713
807
  const emailsToSend = [];
714
808
  const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
715
809
  const couponUsageAccumulator = new Map();
810
+ // One commission on one transaction, capped, then apportioned across the
811
+ // orders this cart splits into — see allocateCheckoutFees.
812
+ const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
813
+ g[0].item.storeId,
814
+ g.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0),
815
+ ]), computeCheckoutFees(cartSubtotal, commissions));
716
816
  const groupCtx = {
717
817
  paymentMethod,
718
818
  emiTenureMonths,
@@ -736,10 +836,8 @@ export async function createCheckoutOrderAction(input) {
736
836
  buyerState: resolvedAddress?.state,
737
837
  gstSettings: siteSettings?.gst,
738
838
  siteContactUpiVpa: siteSettings?.contact?.upiVpa,
739
- whatsappNotifyAddon,
740
- giftWrapAddon,
741
- giftWrapMessage,
742
- shipmentProtectionAddon,
839
+ storeAddons: cart.storeAddons,
840
+ platformFeeByStore,
743
841
  };
744
842
  for (const { items: group, orderType } of orderGroups) {
745
843
  total += await createOrderForGroup(group, orderType, groupCtx);
@@ -753,6 +851,7 @@ export async function createCheckoutOrderAction(input) {
753
851
  total,
754
852
  itemCount: orderIds.length,
755
853
  ...(unavailable.length > 0 ? { unavailableItems: unavailable } : {}),
854
+ ...(droppedCoupons.length > 0 ? { droppedCoupons } : {}),
756
855
  };
757
856
  }
758
857
  const EMPTY_PRICING_PREVIEW = {
@@ -764,7 +863,10 @@ const EMPTY_PRICING_PREVIEW = {
764
863
  shipmentProtectionFee: 0,
765
864
  gstAmount: 0,
766
865
  couponDiscount: 0,
866
+ platformFee: 0,
867
+ gstOnFee: 0,
767
868
  total: 0,
869
+ stores: [],
768
870
  };
769
871
  /**
770
872
  * Read-only "what will I actually be charged" preview for the checkout
@@ -776,13 +878,16 @@ const EMPTY_PRICING_PREVIEW = {
776
878
  * stock, creates an order, or writes coupon usage.
777
879
  */
778
880
  export async function previewCheckoutPricing(input) {
779
- const { userId: uid, addressId, paymentMethod, excludedProductIds = [], whatsappNotifyAddon = false, giftWrapAddon = false, shipmentProtectionAddon = false, } = input;
881
+ const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, } = input;
780
882
  const siteSettings = await siteSettingsRepository.getSingleton();
781
883
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
782
884
  const cart = await unitOfWork.carts.getOrCreate(uid);
783
885
  const excludedSet = new Set(excludedProductIds);
784
886
  const selectedSet = cart.selectedItemIds?.length ? new Set(cart.selectedItemIds) : null;
785
- const previewLane = activeLane(cart.items ?? []);
887
+ // An explicit lane wins: the cart page prices whichever tab is open, which may
888
+ // not be the one that is currently payable. Falling back to activeLane keeps
889
+ // the checkout page's existing behaviour unchanged.
890
+ const previewLane = lane ?? activeLane(cart.items ?? []);
786
891
  const cartItems = (cart.items ?? []).filter((item) => !excludedSet.has(item.productId) &&
787
892
  (!selectedSet || selectedSet.has(item.itemId)) &&
788
893
  // A preview never throws — it just shows the lane the buyer can actually
@@ -805,46 +910,78 @@ export async function previewCheckoutPricing(input) {
805
910
  const checks = cartItems.map((item) => ({ item, product: productById.get(item.productId) ?? null }));
806
911
  const orderGroups = splitCartIntoOrderGroups(checks);
807
912
  const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
808
- const appliedCoupons = cart.appliedCoupons ?? [];
809
- let shippingFee = 0;
810
- let codHandlingFee = 0;
811
- let whatsappNotifyFeeTotal = 0;
812
- let giftWrapFeeTotal = 0;
813
- let shipmentProtectionFeeTotal = 0;
814
- let gstAmount = 0;
815
- let couponDiscount = 0;
913
+ // Same re-validation the real order placement runs, so the preview never
914
+ // shows a discount the order won't actually honour.
915
+ const { coupons: appliedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], checks, uid);
916
+ // Each group's figures are kept, not just summed away — the aggregates below
917
+ // are derived FROM this array, so a store card and the summary panel can never
918
+ // disagree about the same fee.
919
+ const stores = [];
816
920
  for (const { items: group } of orderGroups) {
817
921
  const firstItem = group[0].item;
818
922
  const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
819
923
  const { shippingFee: groupShippingFee, storeState } = await resolveShippingCost(firstItem.storeId);
820
- shippingFee += groupShippingFee;
924
+ // Add-ons are per store, read off the cart document — the same entry
925
+ // createOrderForGroup will read when the order is actually placed.
926
+ const addons = cart.storeAddons?.[firstItem.storeId] ?? {};
821
927
  // Mirrors createOrderForGroup exactly — the COD handling fee is only
822
928
  // ever charged for literal "cod" (pay the courier), never upi_manual or
823
929
  // cash (both pre-paid via UPI/bank transfer before dispatch).
824
- if (paymentMethod === "cod")
825
- codHandlingFee += computeCodHandlingFee(groupTotal, commissions);
826
- whatsappNotifyFeeTotal += computeWhatsAppNotifyFee(whatsappNotifyAddon, commissions);
827
- giftWrapFeeTotal += computeGiftWrapFee(giftWrapAddon, commissions);
828
- shipmentProtectionFeeTotal += computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, commissions);
930
+ const groupCodHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
931
+ const groupWhatsappFee = computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissions);
932
+ const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
933
+ const groupShipmentProtectionFee = computeShipmentProtectionFee(groupTotal, addons.shipmentProtectionAddon ?? false, commissions);
934
+ let groupGstAmount = 0;
829
935
  if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
830
936
  const intraState = !!storeState && storeState === resolvedAddress.state;
831
937
  for (const { item, product } of group) {
832
938
  const lineTotal = unitPriceFor(item, product) * item.quantity;
833
939
  const rate = product?.gstRate ?? 0;
834
940
  if (rate > 0)
835
- gstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
941
+ groupGstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
836
942
  }
837
943
  }
838
944
  const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
839
945
  const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
840
- couponDiscount += groupCouponDiscount;
946
+ stores.push({
947
+ storeId: firstItem.storeId,
948
+ storeName: firstItem.storeName || firstItem.storeId,
949
+ subtotal: groupTotal,
950
+ shippingFee: groupShippingFee,
951
+ codHandlingFee: groupCodHandlingFee,
952
+ whatsappNotifyFee: groupWhatsappFee,
953
+ giftWrapFee: groupGiftWrapFee,
954
+ shipmentProtectionFee: groupShipmentProtectionFee,
955
+ gstAmount: groupGstAmount,
956
+ couponDiscount: groupCouponDiscount,
957
+ total: Math.max(0, groupTotal - groupCouponDiscount) +
958
+ groupShippingFee +
959
+ groupCodHandlingFee +
960
+ groupWhatsappFee +
961
+ groupGiftWrapFee +
962
+ groupShipmentProtectionFee +
963
+ groupGstAmount,
964
+ });
841
965
  }
966
+ const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
967
+ const shippingFee = sum((s) => s.shippingFee);
968
+ const codHandlingFee = sum((s) => s.codHandlingFee);
969
+ const whatsappNotifyFeeTotal = sum((s) => s.whatsappNotifyFee);
970
+ const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
971
+ const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
972
+ const gstAmount = sum((s) => s.gstAmount);
973
+ const couponDiscount = sum((s) => s.couponDiscount);
974
+ // One capped commission for the whole checkout, on every payment method —
975
+ // deliberately not per store, so it is not added to the per-store rows above.
976
+ const { platformFee, gstOnFee } = computeCheckoutFees(cartSubtotal, commissions);
842
977
  const total = Math.max(0, cartSubtotal - couponDiscount) +
843
978
  shippingFee +
844
979
  codHandlingFee +
845
980
  whatsappNotifyFeeTotal +
846
981
  giftWrapFeeTotal +
847
982
  shipmentProtectionFeeTotal +
983
+ platformFee +
984
+ gstOnFee +
848
985
  gstAmount;
849
986
  return {
850
987
  subtotal: cartSubtotal,
@@ -855,7 +992,10 @@ export async function previewCheckoutPricing(input) {
855
992
  shipmentProtectionFee: shipmentProtectionFeeTotal,
856
993
  gstAmount,
857
994
  couponDiscount,
995
+ platformFee,
996
+ gstOnFee,
858
997
  total,
998
+ stores,
859
999
  };
860
1000
  }
861
1001
  /**
@@ -976,7 +1116,7 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
976
1116
  * from the inline version this replaced.
977
1117
  */
978
1118
  async function createRazorpayGroupOrder(group, orderType, ctx) {
979
- const { appliedCoupons, cartSubtotal, platformFeePercent, gstPercent, whatsappNotifyFee, giftWrapFee, giftWrapMessage, shipmentProtectionAddon, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, } = ctx;
1119
+ const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, } = ctx;
980
1120
  const firstItem = group[0].item;
981
1121
  const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
982
1122
  // Reuses the same store/seller lookup as the COD/UPI path above instead
@@ -985,11 +1125,31 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
985
1125
  const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
986
1126
  const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
987
1127
  const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
988
- const rawPlatformFee = roundRupees(groupTotal * (platformFeePercent / 100));
989
- const gstOnFee = roundRupees(rawPlatformFee * (gstPercent / 100));
1128
+ // Mirrors createOrderForGroup without this the online-payment path wrote
1129
+ // the discount onto the order but never incremented usage.currentUsage or
1130
+ // the per-user counter, so limits were unenforceable for Razorpay orders.
1131
+ const groupCouponCodes = new Set();
1132
+ for (const discount of appliedDiscounts) {
1133
+ if (discount.couponId) {
1134
+ accumulateCouponUsage(couponUsageAccumulator, discount.code, discount.couponId, discount.discountAmount);
1135
+ groupCouponCodes.add(discount.code);
1136
+ }
1137
+ }
1138
+ // Was a per-group, uncapped `groupTotal × platformFeePercent` computed inline
1139
+ // here — the only path that charged the commission at all, and it charged a
1140
+ // different (larger, multiplied-per-store) number than the cap now allows.
1141
+ // Both the amount and its allocation are now decided once for the checkout.
1142
+ const { platformFee: rawPlatformFee = 0, gstOnFee = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
990
1143
  const platformFee = rawPlatformFee + gstOnFee;
991
- const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS);
992
- const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee;
1144
+ // Add-ons are this store's choice, read off the cart doc — previously one
1145
+ // cart-wide fee was passed in and billed against every group.
1146
+ const commissionRates = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1147
+ const addons = storeAddons?.[firstItem.storeId] ?? {};
1148
+ const whatsappNotifyFee = computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissionRates);
1149
+ const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
1150
+ const giftWrapMessage = addons.giftWrapMessage;
1151
+ const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, addons.shipmentProtectionAddon ?? false, commissionRates);
1152
+ const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + platformFee;
993
1153
  // P-8 GST — deliberately NOT wired into this Razorpay-verify path. The
994
1154
  // amount-mismatch check above (expectedPaymentAmountRs) compares against
995
1155
  // what the buyer already paid via the Razorpay order created earlier in
@@ -1103,6 +1263,11 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
1103
1263
  productTitle: firstItem.productTitle,
1104
1264
  }).catch((e) => serverLogger.error("claimDigitalCode", e));
1105
1265
  }
1266
+ for (const code of groupCouponCodes) {
1267
+ const entry = couponUsageAccumulator.get(code);
1268
+ if (entry)
1269
+ entry.orderIds.push(order.id);
1270
+ }
1106
1271
  if (userEmail) {
1107
1272
  emailsToSend.push({
1108
1273
  to: userEmail,
@@ -1129,12 +1294,11 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
1129
1294
  return orderTotal;
1130
1295
  }
1131
1296
  export async function verifyAndPlaceRazorpayOrderAction(input) {
1132
- const { userId: uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, addressId, notes, outOfStockPolicy = OutOfStockPolicyValues.CANCEL_ORDER, whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, } = input;
1297
+ const { userId: uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, addressId, notes, outOfStockPolicy = OutOfStockPolicyValues.CANCEL_ORDER, } = input;
1298
+ // Add-on fees are no longer resolved here. They are per store, and this
1299
+ // function has no single store — each group reads its own selection off
1300
+ // `cart.storeAddons` inside createRazorpayGroupOrder.
1133
1301
  const siteSettings = await siteSettingsRepository.getSingleton();
1134
- const platformFeePercent = siteSettings?.commissions?.platformFeePercent ?? 5;
1135
- const gstPercent = siteSettings?.commissions?.gstPercent ?? 18;
1136
- const whatsappNotifyFee = computeWhatsAppNotifyFee(whatsappNotifyAddon, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS);
1137
- const giftWrapFee = computeGiftWrapFee(giftWrapAddon, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS);
1138
1302
  const isValid = await verifyPaymentSignatureWithKeys({
1139
1303
  razorpay_order_id,
1140
1304
  razorpay_payment_id,
@@ -1257,8 +1421,12 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1257
1421
  const unit = isBundle ? item.price : product.price;
1258
1422
  return sum + unit * item.quantity;
1259
1423
  }, 0);
1260
- const expectedPlatformFee = roundRupees(cartSubtotalRs * (platformFeePercent / 100));
1261
- const expectedGstOnFee = roundRupees(expectedPlatformFee * (gstPercent / 100));
1424
+ // Must use the SAME helper /api/payment/create-order used to compute what
1425
+ // it charged. This block used to re-derive the fee inline from the raw
1426
+ // percentages, so the moment the cap landed it would have expected more
1427
+ // than was actually collected and rejected every legitimate payment.
1428
+ const commissionRates = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1429
+ const { platformFee: expectedPlatformFee, gstOnFee: expectedGstOnFee } = computeCheckoutFees(cartSubtotalRs, commissionRates);
1262
1430
  // Same per-seller-group shipping sum /api/payment/create-order charged
1263
1431
  // upfront (and the same resolveShippingCost createRazorpayGroupOrder
1264
1432
  // below will use to build each order's recorded totalPrice) — keeps this
@@ -1266,7 +1434,19 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1266
1434
  const expectedShippingGroups = splitCartIntoOrderGroups(bucketedPaid.available);
1267
1435
  const expectedShippingFees = await Promise.all(expectedShippingGroups.map((g) => resolveShippingCost(g.items[0].item.storeId)));
1268
1436
  const expectedShippingFee = expectedShippingFees.reduce((sum, r) => sum + r.shippingFee, 0);
1269
- const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee + expectedShippingFee;
1437
+ // Add-ons are per store, so the expected total sums each group's own
1438
+ // selection — a single cart-wide figure would over- or under-count as soon
1439
+ // as the buyer picked add-ons for only some sellers.
1440
+ const expectedAddonFees = expectedShippingGroups.reduce((sum, g) => {
1441
+ const storeId = g.items[0].item.storeId;
1442
+ const addons = cart.storeAddons?.[storeId] ?? {};
1443
+ const groupSubtotal = g.items.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0);
1444
+ return (sum +
1445
+ computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissionRates) +
1446
+ computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
1447
+ computeShipmentProtectionFee(groupSubtotal, addons.shipmentProtectionAddon ?? false, commissionRates));
1448
+ }, 0);
1449
+ const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee + expectedAddonFees + expectedShippingFee;
1270
1450
  const rzpOrderRecord = await fetchRazorpayOrder(razorpay_order_id);
1271
1451
  const paidAmountRs = paiseToRupees(rzpOrderRecord.amount);
1272
1452
  if (paidAmountRs < expectedPaymentAmountRs - 1) {
@@ -1282,7 +1462,7 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1282
1462
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.PAYMENT_FAILED);
1283
1463
  }
1284
1464
  }
1285
- const appliedCoupons = cart.appliedCoupons ?? [];
1465
+ const { coupons: appliedCoupons, dropped: droppedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], bucketedPaid.available, uid);
1286
1466
  // "skip_items" (or an all-available cart under any policy) — build order
1287
1467
  // groups from the available bucket only. Dropped items are never ordered;
1288
1468
  // their value is refunded below since Razorpay already captured payment
@@ -1293,16 +1473,18 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1293
1473
  const emailsToSend = [];
1294
1474
  const cartSubtotal = orderGroups.reduce((s, { items: g }) => s +
1295
1475
  g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
1476
+ const couponUsageAccumulator = new Map();
1477
+ // Same single capped commission + pro-rata split as the COD/UPI path.
1478
+ const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
1479
+ g[0].item.storeId,
1480
+ g.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0),
1481
+ ]), computeCheckoutFees(cartSubtotal, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS));
1296
1482
  for (const { items: group, orderType } of orderGroups) {
1297
1483
  total += await createRazorpayGroupOrder(group, orderType, {
1298
1484
  appliedCoupons,
1299
1485
  cartSubtotal,
1300
- platformFeePercent,
1301
- gstPercent,
1302
- whatsappNotifyFee,
1303
- giftWrapFee,
1304
- giftWrapMessage,
1305
- shipmentProtectionAddon,
1486
+ storeAddons: cart.storeAddons,
1487
+ platformFeeByStore,
1306
1488
  siteSettings,
1307
1489
  uid,
1308
1490
  userName,
@@ -1316,8 +1498,10 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1316
1498
  unavailablePaid,
1317
1499
  orderIds,
1318
1500
  emailsToSend,
1501
+ couponUsageAccumulator,
1319
1502
  });
1320
1503
  }
1504
+ await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
1321
1505
  // SB-UNI-5 2026-05-13 — bundle-aware batch decrement. We iterate the
1322
1506
  // cumulative `decrements` map (one entry per unique member product) so a
1323
1507
  // bundle's members + any sibling cart line touching the same product
@@ -1366,5 +1550,6 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1366
1550
  total,
1367
1551
  itemCount: orderIds.length,
1368
1552
  ...(unavailablePaid.length > 0 ? { unavailableItems: unavailablePaid } : {}),
1553
+ ...(droppedCoupons.length > 0 ? { droppedCoupons } : {}),
1369
1554
  };
1370
1555
  }
@@ -10,4 +10,14 @@ export interface CheckoutOrderResult {
10
10
  requestedQty: number;
11
11
  availableQty: number;
12
12
  }[];
13
+ /**
14
+ * Coupons that were on the cart but failed re-validation at placement time
15
+ * (expired, limit reached, no longer any eligible items). They were removed
16
+ * and the order priced without them — surfaced so the UI can tell the buyer
17
+ * rather than silently charging more than the cart showed.
18
+ */
19
+ droppedCoupons?: {
20
+ code: string;
21
+ reason: string;
22
+ }[];
13
23
  }
@@ -50,12 +50,32 @@ export function orderDocumentToOrder(doc) {
50
50
  address,
51
51
  orderStatus: doc.status,
52
52
  paymentStatus: doc.paymentStatus,
53
+ // Which checkout lane produced this order (standard / auction / offer /
54
+ // preorder / prize-draw). Drives the lane tabs on /user/orders. Absent on
55
+ // orders written before `orderType` existed — every reader must treat a
56
+ // missing value as "standard" rather than filtering it out.
57
+ orderType: doc.orderType,
58
+ offerId: doc.offerId,
53
59
  subtotal,
54
60
  shippingCost: shippingCost || undefined,
55
61
  discount: discount || undefined,
56
62
  total: doc.totalPrice,
57
63
  currency: doc.currency,
58
64
  couponCode: doc.couponCode,
65
+ couponDiscount: doc.couponDiscount,
66
+ appliedDiscounts: doc.appliedDiscounts,
67
+ // Add-ons are operational, not decorative: the status-change notifier reads
68
+ // whatsappNotifyAddon, and the packer needs giftWrapMessage in hand. An
69
+ // adapter that drops them makes every downstream surface render its
70
+ // "not applicable" fallback instead (Root Cause #57).
71
+ whatsappNotifyAddon: doc.whatsappNotifyAddon,
72
+ whatsappNotifyFee: doc.whatsappNotifyFee,
73
+ giftWrapAddon: doc.giftWrapAddon,
74
+ giftWrapFee: doc.giftWrapFee,
75
+ giftWrapMessage: doc.giftWrapMessage,
76
+ shipmentProtectionAddon: doc.shipmentProtectionAddon,
77
+ shipmentProtectionFee: doc.shipmentProtectionFee,
78
+ platformFee: doc.platformFee,
59
79
  trackingNumber: doc.trackingNumber,
60
80
  shippingCarrier: doc.shippingCarrier,
61
81
  trackingUrl: doc.trackingUrl,
@@ -4,15 +4,24 @@ import type { ProductDocument } from "../../../../features/products/schemas/fire
4
4
  import type { ListingType } from "../../../../features/products/types";
5
5
  import type { ProductItem } from "../../../../features/products/types";
6
6
  import type { FirestoreDocument } from "../../../../schemas/types";
7
- import type { Review } from "../../../../features/reviews/types";
7
+ import type { Review, ReviewListResponse } from "../../../../features/reviews/types";
8
8
  /** Fetch a single product by slug or id, deduped per request. */
9
9
  export declare const getProductForDetail: (slugOrId: string) => Promise<ProductDocument | null>;
10
10
  /** Fetch the first page of approved reviews for a product, deduped per request. */
11
- export declare const getReviewsForProduct: (productId: string) => Promise<unknown[]>;
11
+ export declare const getReviewsForProduct: (productId: string, limit?: number) => Promise<unknown[]>;
12
12
  /** Firestore review doc → client Review shape. Shared by every listing-type detail page's reviews tab. */
13
13
  export declare function toReview(doc: FirestoreDocument): Review;
14
14
  /** Fetch + map approved reviews for a product straight to the client Review shape, deduped per request. */
15
- export declare const getReviewItemsForProduct: (productId: string) => Promise<Review[]>;
15
+ export declare const getReviewItemsForProduct: (productId: string, limit?: number) => Promise<Review[]>;
16
+ /**
17
+ * Page 1 of a product's approved reviews, shaped exactly like `GET /api/reviews?productId=`
18
+ * so it can seed `<ReviewsListingPanel initialData>` without a client round-trip.
19
+ *
20
+ * The defaults here MUST match the panel's bare (unfiltered, page-1) query defaults —
21
+ * `staleTime: Infinity` freezes whatever `initialData` is handed over, so a mismatch would
22
+ * pin the wrong rows into the cache (Recurrent Root Cause #30).
23
+ */
24
+ export declare const getReviewPageForProduct: (productId: string, pageSize?: number) => Promise<ReviewListResponse>;
16
25
  /** Minimal product fields needed for sitemap generation. */
17
26
  export interface SitemapProduct {
18
27
  slugOrId: string;