@mohasinac/appkit 4.59.0 → 4.60.1

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 (532) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +62 -5
  3. package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +2 -2
  4. package/dist/_internal/server/features/brands/actions.js +78 -4
  5. package/dist/_internal/server/features/checkout/actions.d.ts +36 -13
  6. package/dist/_internal/server/features/checkout/actions.js +668 -183
  7. package/dist/_internal/server/features/checkout/supplier-identity.js +18 -1
  8. package/dist/_internal/server/features/gateway-events/index.d.ts +10 -0
  9. package/dist/_internal/server/features/gateway-events/index.js +10 -0
  10. package/dist/_internal/server/features/gateway-events/ingest.d.ts +127 -0
  11. package/dist/_internal/server/features/gateway-events/ingest.js +188 -0
  12. package/dist/_internal/server/features/gateway-events/redact.d.ts +66 -0
  13. package/dist/_internal/server/features/gateway-events/redact.js +151 -0
  14. package/dist/_internal/server/features/grievance/actions.d.ts +63 -0
  15. package/dist/_internal/server/features/grievance/actions.js +173 -0
  16. package/dist/_internal/server/features/grievance/index.d.ts +8 -0
  17. package/dist/_internal/server/features/grievance/index.js +8 -0
  18. package/dist/_internal/server/features/grouped/data.js +28 -3
  19. package/dist/_internal/server/features/lottery/actions.js +99 -4
  20. package/dist/_internal/server/features/order-completion/actions.d.ts +92 -0
  21. package/dist/_internal/server/features/order-completion/actions.js +336 -0
  22. package/dist/_internal/server/features/order-completion/index.d.ts +7 -0
  23. package/dist/_internal/server/features/order-completion/index.js +7 -0
  24. package/dist/_internal/server/features/orders/actions.d.ts +13 -0
  25. package/dist/_internal/server/features/orders/actions.js +56 -2
  26. package/dist/_internal/server/features/orders/adapters.d.ts +20 -2
  27. package/dist/_internal/server/features/orders/adapters.js +44 -0
  28. package/dist/_internal/server/features/orders/cancellation-refund.d.ts +116 -0
  29. package/dist/_internal/server/features/orders/cancellation-refund.js +466 -0
  30. package/dist/_internal/server/features/orders/index.d.ts +2 -1
  31. package/dist/_internal/server/features/orders/index.js +1 -1
  32. package/dist/_internal/server/features/orders/service.d.ts +60 -0
  33. package/dist/_internal/server/features/orders/service.js +74 -0
  34. package/dist/_internal/server/features/payment-cases/disputes.d.ts +76 -0
  35. package/dist/_internal/server/features/payment-cases/disputes.js +241 -0
  36. package/dist/_internal/server/features/payment-cases/index.d.ts +9 -0
  37. package/dist/_internal/server/features/payment-cases/index.js +9 -0
  38. package/dist/_internal/server/features/payouts/actions.d.ts +28 -3
  39. package/dist/_internal/server/features/payouts/actions.js +84 -10
  40. package/dist/_internal/server/features/points/adjust.d.ts +61 -0
  41. package/dist/_internal/server/features/points/adjust.js +121 -0
  42. package/dist/_internal/server/features/points/award.d.ts +65 -0
  43. package/dist/_internal/server/features/points/award.js +145 -0
  44. package/dist/_internal/server/features/price-index/market.d.ts +33 -0
  45. package/dist/_internal/server/features/price-index/market.js +54 -1
  46. package/dist/_internal/server/features/products/data.js +40 -22
  47. package/dist/_internal/server/features/protection-claims/actions.d.ts +72 -0
  48. package/dist/_internal/server/features/protection-claims/actions.js +228 -0
  49. package/dist/_internal/server/features/protection-claims/index.d.ts +8 -0
  50. package/dist/_internal/server/features/protection-claims/index.js +8 -0
  51. package/dist/_internal/server/features/refunds/actions.d.ts +20 -0
  52. package/dist/_internal/server/features/refunds/actions.js +259 -17
  53. package/dist/_internal/server/features/refunds/capacity.d.ts +29 -0
  54. package/dist/_internal/server/features/refunds/capacity.js +37 -0
  55. package/dist/_internal/server/features/reviews/actions.js +3 -2
  56. package/dist/_internal/server/features/search/index-build/sources.js +20 -1
  57. package/dist/_internal/server/features/shipping/reconcile.d.ts +10 -1
  58. package/dist/_internal/server/features/shipping/reconcile.js +34 -3
  59. package/dist/_internal/server/features/shipping/reimbursement.d.ts +117 -0
  60. package/dist/_internal/server/features/shipping/reimbursement.js +223 -0
  61. package/dist/_internal/server/features/stores/tabs.d.ts +19 -4
  62. package/dist/_internal/server/features/stores/tabs.js +24 -4
  63. package/dist/_internal/server/functions/https.d.ts +31 -1
  64. package/dist/_internal/server/functions/https.js +52 -1
  65. package/dist/_internal/server/functions/scheduled.d.ts +72 -1
  66. package/dist/_internal/server/functions/scheduled.js +124 -1
  67. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +62 -16
  68. package/dist/_internal/server/jobs/core/boxRollupReconcile.d.ts +46 -0
  69. package/dist/_internal/server/jobs/core/boxRollupReconcile.js +142 -0
  70. package/dist/_internal/server/jobs/core/dailyDataCleanup.js +149 -1
  71. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +14 -0
  72. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +79 -1
  73. package/dist/_internal/server/jobs/core/index.d.ts +2 -0
  74. package/dist/_internal/server/jobs/core/index.js +2 -0
  75. package/dist/_internal/server/jobs/core/marketPriceRollup.d.ts +64 -0
  76. package/dist/_internal/server/jobs/core/marketPriceRollup.js +153 -0
  77. package/dist/_internal/server/jobs/core/obligationSweep.d.ts +36 -0
  78. package/dist/_internal/server/jobs/core/obligationSweep.js +503 -0
  79. package/dist/_internal/server/jobs/core/paymentReconcile.d.ts +79 -0
  80. package/dist/_internal/server/jobs/core/paymentReconcile.js +614 -0
  81. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +34 -0
  82. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +35 -4
  83. package/dist/_internal/server/jobs/handlers/boxRollupReconcile.d.ts +2 -0
  84. package/dist/_internal/server/jobs/handlers/boxRollupReconcile.js +15 -0
  85. package/dist/_internal/server/jobs/handlers/countersReconcileNow.d.ts +65 -0
  86. package/dist/_internal/server/jobs/handlers/countersReconcileNow.js +89 -0
  87. package/dist/_internal/server/jobs/handlers/index.d.ts +5 -0
  88. package/dist/_internal/server/jobs/handlers/index.js +5 -0
  89. package/dist/_internal/server/jobs/handlers/marketPriceRollup.d.ts +2 -0
  90. package/dist/_internal/server/jobs/handlers/marketPriceRollup.js +2 -0
  91. package/dist/_internal/server/jobs/handlers/obligationSweep.d.ts +2 -0
  92. package/dist/_internal/server/jobs/handlers/obligationSweep.js +15 -0
  93. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +30 -1
  94. package/dist/_internal/server/jobs/handlers/paymentReconcile.d.ts +2 -0
  95. package/dist/_internal/server/jobs/handlers/paymentReconcile.js +6 -0
  96. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  97. package/dist/_internal/shared/actions/action-registry.js +71 -0
  98. package/dist/_internal/shared/checkout/coupon-discount.d.ts +73 -0
  99. package/dist/_internal/shared/checkout/coupon-discount.js +103 -0
  100. package/dist/_internal/shared/checkout/lanes.d.ts +42 -0
  101. package/dist/_internal/shared/checkout/lanes.js +46 -0
  102. package/dist/_internal/shared/checkout/order-math.d.ts +149 -1
  103. package/dist/_internal/shared/checkout/order-math.js +251 -3
  104. package/dist/_internal/shared/checkout/refundable-base.d.ts +116 -0
  105. package/dist/_internal/shared/checkout/refundable-base.js +122 -0
  106. package/dist/_internal/shared/features/auctions/config.d.ts +14 -1
  107. package/dist/_internal/shared/features/auctions/config.js +30 -1
  108. package/dist/_internal/shared/features/brands/schema.d.ts +42 -0
  109. package/dist/_internal/shared/features/brands/schema.js +18 -0
  110. package/dist/_internal/shared/features/orders/refund-copy.d.ts +22 -0
  111. package/dist/_internal/shared/features/orders/refund-copy.js +34 -0
  112. package/dist/_internal/shared/features/print/label-profiles.d.ts +198 -0
  113. package/dist/_internal/shared/features/print/label-profiles.js +350 -0
  114. package/dist/_internal/shared/features/products/to-product-item.js +33 -5
  115. package/dist/_internal/shared/fees/calculator.d.ts +31 -2
  116. package/dist/_internal/shared/fees/calculator.js +103 -5
  117. package/dist/_internal/shared/fees/india-states.d.ts +195 -0
  118. package/dist/_internal/shared/fees/india-states.js +190 -0
  119. package/dist/_internal/shared/fees/payout-hold.d.ts +80 -0
  120. package/dist/_internal/shared/fees/payout-hold.js +74 -0
  121. package/dist/_internal/shared/obligations/deadlines.d.ts +94 -0
  122. package/dist/_internal/shared/obligations/deadlines.js +155 -0
  123. package/dist/_internal/shared/shipping/quote.d.ts +96 -0
  124. package/dist/_internal/shared/shipping/quote.js +67 -0
  125. package/dist/_internal/shared/time/business-days.d.ts +91 -0
  126. package/dist/_internal/shared/time/business-days.js +185 -0
  127. package/dist/cli/index.js +0 -2
  128. package/dist/client.d.ts +10 -4
  129. package/dist/client.js +29 -3
  130. package/dist/configs/tailwind.d.ts +0 -12
  131. package/dist/configs/tailwind.js +28 -15
  132. package/dist/constants/api-endpoints.d.ts +51 -9
  133. package/dist/constants/api-endpoints.js +23 -7
  134. package/dist/constants/index.d.ts +1 -1
  135. package/dist/constants/index.js +1 -1
  136. package/dist/contracts/index.d.ts +1 -1
  137. package/dist/contracts/payment.d.ts +131 -0
  138. package/dist/features/about/components/AboutView.js +1 -1
  139. package/dist/features/about/components/DeveloperView.js +5 -2
  140. package/dist/features/about/components/FAQPageView.js +2 -1
  141. package/dist/features/about/components/FeesView.js +2 -1
  142. package/dist/features/about/components/HelpPageView.js +2 -1
  143. package/dist/features/about/components/HowAuctionsWorkView.js +2 -1
  144. package/dist/features/about/components/HowCheckoutWorksView.js +2 -1
  145. package/dist/features/about/components/HowEmiWorksView.js +2 -1
  146. package/dist/features/about/components/HowOffersWorkView.js +2 -1
  147. package/dist/features/about/components/HowOrdersWorkView.js +2 -1
  148. package/dist/features/about/components/HowPayoutsWorkView.js +2 -1
  149. package/dist/features/about/components/HowPreOrdersWorkView.js +2 -1
  150. package/dist/features/about/components/HowReviewsWorkView.js +2 -1
  151. package/dist/features/about/components/PolicyPageView.js +2 -1
  152. package/dist/features/about/components/ProseHero.d.ts +73 -0
  153. package/dist/features/about/components/ProseHero.js +18 -0
  154. package/dist/features/about/components/SecurityPrivacyView.js +2 -1
  155. package/dist/features/about/components/ShippingPolicyView.js +2 -1
  156. package/dist/features/about/components/TrackOrderView.js +2 -1
  157. package/dist/features/about/components/UnauthorizedView.js +1 -1
  158. package/dist/features/about/index.d.ts +2 -0
  159. package/dist/features/about/index.js +5 -0
  160. package/dist/features/account/actions/address-actions.d.ts +26 -2
  161. package/dist/features/account/actions/address-actions.js +25 -1
  162. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  163. package/dist/features/account/components/NotificationPreferencesPanel.js +1 -1
  164. package/dist/features/account/components/UserOrdersView.js +17 -0
  165. package/dist/features/account/components/UserSidebar.js +1 -1
  166. package/dist/features/account/hooks/useNotifications.js +29 -0
  167. package/dist/features/admin/actions/admin-coupon-actions.js +36 -0
  168. package/dist/features/admin/components/AdminBeysView.js +2 -2
  169. package/dist/features/admin/components/AdminBlogEditorView.js +2 -2
  170. package/dist/features/admin/components/AdminBrandEditorView.js +32 -2
  171. package/dist/features/admin/components/AdminBundleEditorView.js +1 -1
  172. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  173. package/dist/features/admin/components/AdminCategoriesView.js +1 -1
  174. package/dist/features/admin/components/AdminCategoryEditorView.js +73 -17
  175. package/dist/features/admin/components/AdminContactEditorView.js +1 -1
  176. package/dist/features/admin/components/AdminCouponEditorView.js +26 -1
  177. package/dist/features/admin/components/AdminFaqEditorView.js +1 -1
  178. package/dist/features/admin/components/AdminFaqsView.js +2 -2
  179. package/dist/features/admin/components/AdminGatewayEventsView.d.ts +54 -0
  180. package/dist/features/admin/components/AdminGatewayEventsView.js +209 -0
  181. package/dist/features/admin/components/AdminGuideHubView.js +1 -1
  182. package/dist/features/admin/components/AdminMoneyCasesView.d.ts +60 -0
  183. package/dist/features/admin/components/AdminMoneyCasesView.js +287 -0
  184. package/dist/features/admin/components/AdminPartsView.js +2 -2
  185. package/dist/features/admin/components/AdminProductsView.js +75 -4
  186. package/dist/features/admin/components/AdminSidebar.js +2 -2
  187. package/dist/features/admin/components/AdminSiteSettingsView.js +65 -7
  188. package/dist/features/admin/components/AdminSublistingCategoriesView.js +2 -0
  189. package/dist/features/admin/components/AdminSupportTicketDetailView.js +1 -1
  190. package/dist/features/admin/components/AdminSupportTicketsView.js +2 -2
  191. package/dist/features/admin/components/AdminTesterChecklistView.js +2 -2
  192. package/dist/features/admin/components/AdminUsersView.js +72 -1
  193. package/dist/features/admin/components/AdminViewCards.d.ts +3 -1
  194. package/dist/features/admin/components/AdminViewCards.js +4 -4
  195. package/dist/features/admin/components/DataListingView.d.ts +28 -0
  196. package/dist/features/admin/components/DataListingView.js +175 -14
  197. package/dist/features/admin/components/DataTable.d.ts +12 -1
  198. package/dist/features/admin/components/DataTable.js +3 -3
  199. package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
  200. package/dist/features/admin/components/OwnerQuickActions.js +60 -2
  201. package/dist/features/admin/components/RecordOwnerQuickActions.d.ts +1 -1
  202. package/dist/features/admin/components/RecordOwnerQuickActions.js +61 -14
  203. package/dist/features/admin/components/analytics/AdminStatCard.d.ts +35 -1
  204. package/dist/features/admin/components/analytics/AdminStatCard.js +77 -2
  205. package/dist/features/admin/components/index.d.ts +4 -0
  206. package/dist/features/admin/components/index.js +6 -0
  207. package/dist/features/admin/constants/filter-tabs.d.ts +156 -0
  208. package/dist/features/admin/constants/filter-tabs.js +106 -0
  209. package/dist/features/admin/repository/site-settings.repository.d.ts +22 -0
  210. package/dist/features/admin/repository/site-settings.repository.js +82 -1
  211. package/dist/features/admin/schemas/admin-editor-forms.d.ts +21 -0
  212. package/dist/features/admin/schemas/admin-editor-forms.js +44 -0
  213. package/dist/features/admin/schemas/firestore.js +5 -1
  214. package/dist/features/admin/schemas/site-settings-form.js +30 -1
  215. package/dist/features/auctions/actions/bid-actions.js +13 -1
  216. package/dist/features/auctions/components/AuctionBottomActions.js +2 -2
  217. package/dist/features/auctions/components/AuctionDetailPageView.js +3 -1
  218. package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -0
  219. package/dist/features/auctions/components/MarketplaceAuctionCard.js +5 -3
  220. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +8 -1
  221. package/dist/features/auctions/components/PlaceBidFormClient.js +2 -2
  222. package/dist/features/audit-log/schemas/firestore.d.ts +28 -0
  223. package/dist/features/audit-log/schemas/firestore.js +14 -0
  224. package/dist/features/auth/permissions/constants.d.ts +17 -1
  225. package/dist/features/auth/permissions/constants.js +18 -0
  226. package/dist/features/blog/components/BlogPostView.js +1 -1
  227. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +1 -1
  228. package/dist/features/categories/api/[id]/description-template/route.js +107 -1
  229. package/dist/features/categories/components/BrandDetailPageView.js +2 -2
  230. package/dist/features/categories/components/BreadcrumbTrail.js +7 -1
  231. package/dist/features/categories/components/BundleDetailView.js +1 -1
  232. package/dist/features/categories/components/CategoryDetailPageView.js +2 -2
  233. package/dist/features/categories/components/CategoryForm.js +12 -1
  234. package/dist/features/categories/components/CategoryTemplatePicker.js +38 -5
  235. package/dist/features/categories/repository/market-price-index.repository.d.ts +123 -0
  236. package/dist/features/categories/repository/market-price-index.repository.js +105 -0
  237. package/dist/features/categories/repository/price-observation.repository.d.ts +166 -0
  238. package/dist/features/categories/repository/price-observation.repository.js +132 -0
  239. package/dist/features/events/components/EventSaleBanner.js +1 -1
  240. package/dist/features/events/components/EventStatusBadge.js +1 -1
  241. package/dist/features/faq/components/FAQAccordion.js +2 -1
  242. package/dist/features/faq/components/FAQCategorySidebar.js +2 -2
  243. package/dist/features/faq/components/FAQHelpfulButtons.js +4 -4
  244. package/dist/features/faq/components/RelatedFAQs.js +1 -1
  245. package/dist/features/filters/FilterFacetSection.js +1 -1
  246. package/dist/features/filters/SwitchFilter.js +1 -1
  247. package/dist/features/gateway-events/repository/gateway-events.repository.d.ts +117 -0
  248. package/dist/features/gateway-events/repository/gateway-events.repository.js +274 -0
  249. package/dist/features/gateway-events/schemas/firestore.d.ts +217 -0
  250. package/dist/features/gateway-events/schemas/firestore.js +125 -0
  251. package/dist/features/gateway-events/schemas/index.d.ts +81 -0
  252. package/dist/features/gateway-events/schemas/index.js +79 -0
  253. package/dist/features/grouped/components/GroupedListingDetailView.js +1 -1
  254. package/dist/features/homepage/components/AnnouncementBar.js +37 -1
  255. package/dist/features/homepage/components/AnnouncementBar.style.css +30 -0
  256. package/dist/features/homepage/components/BrandsSection.js +1 -1
  257. package/dist/features/homepage/components/CTABannerSection.js +1 -1
  258. package/dist/features/homepage/components/FeaturedBundlesSection.js +14 -24
  259. package/dist/features/homepage/components/HeroCarousel.js +2 -2
  260. package/dist/features/homepage/components/SocialPostCard.js +1 -1
  261. package/dist/features/inventory/components/AdminMoveLedgerView.d.ts +7 -0
  262. package/dist/features/inventory/components/AdminMoveLedgerView.js +157 -0
  263. package/dist/features/inventory/components/AdminStorageBoxesView.d.ts +11 -0
  264. package/dist/features/inventory/components/AdminStorageBoxesView.js +168 -0
  265. package/dist/features/inventory/components/ReceiveStationView.d.ts +39 -0
  266. package/dist/features/inventory/components/ReceiveStationView.js +413 -0
  267. package/dist/features/inventory/components/operator-message.d.ts +29 -0
  268. package/dist/features/inventory/components/operator-message.js +41 -0
  269. package/dist/features/inventory/index.d.ts +13 -0
  270. package/dist/features/inventory/index.js +28 -0
  271. package/dist/features/inventory/repository/inventory-movements.repository.d.ts +97 -0
  272. package/dist/features/inventory/repository/inventory-movements.repository.js +186 -0
  273. package/dist/features/inventory/repository/inventory-placements.repository.d.ts +179 -0
  274. package/dist/features/inventory/repository/inventory-placements.repository.js +345 -0
  275. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +83 -0
  276. package/dist/features/inventory/repository/storage-boxes.repository.js +127 -0
  277. package/dist/features/inventory/schemas/firestore.d.ts +197 -0
  278. package/dist/features/inventory/schemas/firestore.js +159 -0
  279. package/dist/features/layout/AppLayoutShell.d.ts +12 -1
  280. package/dist/features/layout/AppLayoutShell.js +25 -12
  281. package/dist/features/layout/AutoBreadcrumbs.js +162 -3
  282. package/dist/features/layout/AutoBreadcrumbs.style.css +45 -0
  283. package/dist/features/layout/BottomNavbar.d.ts +1 -1
  284. package/dist/features/layout/BottomNavbar.js +2 -2
  285. package/dist/features/media/MediaAvatar.js +1 -1
  286. package/dist/features/media/MediaImage.d.ts +24 -4
  287. package/dist/features/media/MediaImage.js +44 -4
  288. package/dist/features/media/MediaLightbox.js +1 -1
  289. package/dist/features/media/modals/ImageCropModal.js +1 -1
  290. package/dist/features/media/modals/ImageEditor.js +1 -1
  291. package/dist/features/media/upload/MediaUploadField.js +91 -3
  292. package/dist/features/obligations/constants/policies.d.ts +92 -0
  293. package/dist/features/obligations/constants/policies.js +378 -0
  294. package/dist/features/obligations/repository/obligations.repository.d.ts +68 -0
  295. package/dist/features/obligations/repository/obligations.repository.js +158 -0
  296. package/dist/features/obligations/schemas/firestore.d.ts +160 -0
  297. package/dist/features/obligations/schemas/firestore.js +87 -0
  298. package/dist/features/orders/actions/order-actions.d.ts +43 -4
  299. package/dist/features/orders/actions/order-actions.js +60 -3
  300. package/dist/features/orders/actions/refund-actions.js +70 -10
  301. package/dist/features/orders/components/OrderCompletionPanel.d.ts +27 -0
  302. package/dist/features/orders/components/OrderCompletionPanel.js +192 -0
  303. package/dist/features/orders/components/OrdersList.js +2 -2
  304. package/dist/features/orders/components/RefundHistoryTable.js +3 -1
  305. package/dist/features/orders/components/index.d.ts +2 -0
  306. package/dist/features/orders/components/index.js +6 -0
  307. package/dist/features/orders/constants/transitions.d.ts +59 -0
  308. package/dist/features/orders/constants/transitions.js +104 -0
  309. package/dist/features/orders/repository/orders.repository.d.ts +109 -6
  310. package/dist/features/orders/repository/orders.repository.js +464 -21
  311. package/dist/features/orders/schemas/firestore.d.ts +270 -0
  312. package/dist/features/orders/schemas/firestore.js +24 -0
  313. package/dist/features/orders/schemas/index.d.ts +61 -0
  314. package/dist/features/orders/schemas/index.js +22 -0
  315. package/dist/features/orders/types/index.d.ts +79 -0
  316. package/dist/features/parts/components/PartsIndexPageView.js +8 -1
  317. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +170 -0
  318. package/dist/features/payment-cases/repository/payment-cases.repository.js +342 -0
  319. package/dist/features/payment-cases/schemas/firestore.d.ts +229 -0
  320. package/dist/features/payment-cases/schemas/firestore.js +105 -0
  321. package/dist/features/payment-cases/schemas/resolve-form.d.ts +97 -0
  322. package/dist/features/payment-cases/schemas/resolve-form.js +114 -0
  323. package/dist/features/payments/repository/payout.repository.d.ts +0 -1
  324. package/dist/features/payments/repository/payout.repository.js +0 -1
  325. package/dist/features/points/repository/points.repository.d.ts +122 -0
  326. package/dist/features/points/repository/points.repository.js +304 -1
  327. package/dist/features/points/schemas/adjust-form.d.ts +58 -0
  328. package/dist/features/points/schemas/adjust-form.js +63 -0
  329. package/dist/features/points/schemas/firestore.d.ts +108 -1
  330. package/dist/features/points/schemas/firestore.js +84 -0
  331. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
  332. package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
  333. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
  334. package/dist/features/price-index/components/PriceIndexToolView.d.ts +23 -4
  335. package/dist/features/price-index/components/PriceIndexToolView.js +71 -6
  336. package/dist/features/price-index/market-stats.d.ts +82 -0
  337. package/dist/features/price-index/market-stats.js +230 -0
  338. package/dist/features/print/components/BoxLabelPrintView.d.ts +25 -0
  339. package/dist/features/print/components/BoxLabelPrintView.js +66 -0
  340. package/dist/features/print/components/LabelProfilePicker.d.ts +13 -0
  341. package/dist/features/print/components/LabelProfilePicker.js +38 -0
  342. package/dist/features/print/components/labels.d.ts +117 -0
  343. package/dist/features/print/components/labels.js +258 -0
  344. package/dist/features/print/components/labels.style.css +46 -0
  345. package/dist/features/print/index.d.ts +6 -0
  346. package/dist/features/print/index.js +16 -0
  347. package/dist/features/products/columns/productTableColumns.js +5 -4
  348. package/dist/features/products/components/MarketplaceBundleCard.js +2 -2
  349. package/dist/features/products/components/MarketplacePrizeDrawCard.js +6 -6
  350. package/dist/features/products/components/PrizeDrawCollage.js +1 -1
  351. package/dist/features/products/components/PrizeDrawItemsEditor.js +1 -1
  352. package/dist/features/products/components/PrizeRevealModal.js +2 -2
  353. package/dist/features/products/components/ProductDetailPageView.js +18 -4
  354. package/dist/features/products/components/ProductForm.js +34 -23
  355. package/dist/features/products/components/ProductGrid.js +86 -62
  356. package/dist/features/products/constants/card-badges.d.ts +97 -0
  357. package/dist/features/products/constants/card-badges.js +100 -0
  358. package/dist/features/products/repository/products.repository.js +34 -1
  359. package/dist/features/products/schemas/firestore.d.ts +46 -3
  360. package/dist/features/products/schemas/firestore.js +21 -0
  361. package/dist/features/promotions/components/PromotionsView.js +1 -1
  362. package/dist/features/promotions/repository/claimed-coupons.repository.d.ts +32 -0
  363. package/dist/features/promotions/repository/claimed-coupons.repository.js +58 -0
  364. package/dist/features/promotions/schemas/firestore.js +30 -3
  365. package/dist/features/promotions/schemas/index.d.ts +6 -6
  366. package/dist/features/protection-claims/repository/protection-claims.repository.d.ts +54 -0
  367. package/dist/features/protection-claims/repository/protection-claims.repository.js +174 -0
  368. package/dist/features/protection-claims/schemas/firestore.d.ts +168 -0
  369. package/dist/features/protection-claims/schemas/firestore.js +130 -0
  370. package/dist/features/reviews/actions/review-actions.js +3 -2
  371. package/dist/features/reviews/components/ReviewModal.js +1 -1
  372. package/dist/features/reviews/components/ReviewsList.js +1 -1
  373. package/dist/features/reviews/schemas/firestore.d.ts +20 -1
  374. package/dist/features/scams/constants/scam-types.d.ts +2 -0
  375. package/dist/features/scams/constants/scam-types.js +18 -0
  376. package/dist/features/scams/schemas/report-form.d.ts +3 -3
  377. package/dist/features/scams/schemas/report-form.js +26 -1
  378. package/dist/features/search/components/Search.js +9 -9
  379. package/dist/features/search/components/SearchResultsClient.js +18 -3
  380. package/dist/features/search/components/SearchResultsPageView.d.ts +1 -6
  381. package/dist/features/search/components/SearchResultsPageView.js +2 -2
  382. package/dist/features/seller/actions/offer-actions.js +16 -1
  383. package/dist/features/seller/actions/seller-actions.js +14 -3
  384. package/dist/features/seller/components/CategoryInlineSelect.js +75 -11
  385. package/dist/features/seller/components/PrintCenterView.d.ts +5 -0
  386. package/dist/features/seller/components/PrintCenterView.js +41 -30
  387. package/dist/features/seller/components/SellerOrdersView.js +1 -1
  388. package/dist/features/seller/components/SellerPayoutRequestView.js +16 -5
  389. package/dist/features/seller/components/SellerPayoutSettingsView.js +1 -1
  390. package/dist/features/seller/components/SellerProductShell.d.ts +16 -0
  391. package/dist/features/seller/components/SellerProductShell.js +123 -13
  392. package/dist/features/seller/components/SellerReviewsView.js +4 -2
  393. package/dist/features/seller/components/SellerSidebar.js +1 -1
  394. package/dist/features/seller/schemas/coupon-form.d.ts +26 -0
  395. package/dist/features/seller/schemas/coupon-form.js +27 -0
  396. package/dist/features/seller/schemas/product-form.d.ts +3 -0
  397. package/dist/features/seller/schemas/product-form.js +5 -0
  398. package/dist/features/shell/FormShell.js +2 -2
  399. package/dist/features/shell/QuickFormDrawer.js +14 -0
  400. package/dist/features/shell/SectionForm.d.ts +19 -0
  401. package/dist/features/shell/SectionForm.js +133 -9
  402. package/dist/features/shell/SectionForm.style.css +54 -0
  403. package/dist/features/shipments/components/AdminShipmentsView.js +16 -1
  404. package/dist/features/shipments/repository/shipment-items.repository.d.ts +9 -0
  405. package/dist/features/shipments/repository/shipment-items.repository.js +14 -0
  406. package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
  407. package/dist/features/sticker-trace/components/StickerCommitPanel.js +1 -1
  408. package/dist/features/store-extensions/schemas/report-review-form.d.ts +8 -8
  409. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  410. package/dist/features/stores/components/StoreHeader.js +1 -1
  411. package/dist/features/support/schemas/firestore.d.ts +73 -2
  412. package/dist/features/support/schemas/firestore.js +59 -0
  413. package/dist/index.d.ts +16 -24
  414. package/dist/index.js +38 -47
  415. package/dist/next/ErrorBoundary.js +1 -1
  416. package/dist/next/routing/route-map.d.ts +24 -0
  417. package/dist/next/routing/route-map.js +40 -0
  418. package/dist/providers/payment-manual/index.d.ts +15 -1
  419. package/dist/providers/payment-manual/index.js +37 -0
  420. package/dist/providers/payment-phonepe/index.d.ts +14 -1
  421. package/dist/providers/payment-phonepe/index.js +36 -0
  422. package/dist/providers/payment-razorpay/index.d.ts +44 -1
  423. package/dist/providers/payment-razorpay/index.js +99 -0
  424. package/dist/providers/shipping-shiprocket/index.d.ts +18 -0
  425. package/dist/providers/shipping-shiprocket/index.js +18 -0
  426. package/dist/schemas/registry.d.ts +44 -11
  427. package/dist/server-entry.d.ts +2 -0
  428. package/dist/server-entry.js +27 -0
  429. package/dist/server.d.ts +43 -0
  430. package/dist/server.js +98 -0
  431. package/dist/styles.css +418 -149
  432. package/dist/tailwind-utilities.css +1 -1
  433. package/dist/tokens/themes/default-dark.js +15 -12
  434. package/dist/tokens/themes/default-light.js +14 -11
  435. package/dist/tokens/tokens.css +106 -23
  436. package/dist/ui/DataTable.style.css +4 -4
  437. package/dist/ui/columns/cell-renderers.d.ts +20 -0
  438. package/dist/ui/columns/cell-renderers.js +26 -1
  439. package/dist/ui/columns/index.d.ts +1 -1
  440. package/dist/ui/columns/index.js +4 -1
  441. package/dist/ui/components/Badge.style.css +1 -1
  442. package/dist/ui/components/Breadcrumb.js +19 -1
  443. package/dist/ui/components/BulkActionBar.style.css +4 -4
  444. package/dist/ui/components/Button.style.css +1 -1
  445. package/dist/ui/components/Checkbox.style.css +1 -1
  446. package/dist/ui/components/FallbackShell.js +1 -1
  447. package/dist/ui/components/Form.d.ts +14 -1
  448. package/dist/ui/components/Form.js +2 -2
  449. package/dist/ui/components/FormField.js +18 -4
  450. package/dist/ui/components/FormField.style.css +43 -3
  451. package/dist/ui/components/GatedPrice.style.css +1 -1
  452. package/dist/ui/components/IconButton.style.css +1 -1
  453. package/dist/ui/components/Input.js +22 -1
  454. package/dist/ui/components/Input.style.css +38 -22
  455. package/dist/ui/components/ListingFilterDrawer.js +1 -1
  456. package/dist/ui/components/ListingToolbar.js +1 -1
  457. package/dist/ui/components/PaginatedSelect.js +0 -0
  458. package/dist/ui/components/Radio.style.css +1 -1
  459. package/dist/ui/components/Repeater.d.ts +53 -0
  460. package/dist/ui/components/Repeater.js +7 -0
  461. package/dist/ui/components/RichTextEditor.d.ts +20 -1
  462. package/dist/ui/components/RichTextEditor.js +23 -2
  463. package/dist/ui/components/Select.style.css +7 -21
  464. package/dist/ui/components/Semantic.js +27 -3
  465. package/dist/ui/components/TagInput.style.css +4 -9
  466. package/dist/ui/components/Textarea.style.css +4 -18
  467. package/dist/ui/components/Toggle.style.css +1 -1
  468. package/dist/ui/components/Typography.d.ts +3 -3
  469. package/dist/ui/components/Typography.js +15 -15
  470. package/dist/ui/components/ViewToggle.style.css +4 -4
  471. package/dist/ui/forms/AddAnotherToggle.d.ts +16 -0
  472. package/dist/ui/forms/AddAnotherToggle.js +56 -0
  473. package/dist/ui/forms/ColorPickerField.js +15 -2
  474. package/dist/ui/forms/FieldCheckbox.js +15 -2
  475. package/dist/ui/forms/FieldInput.d.ts +22 -1
  476. package/dist/ui/forms/FieldInput.js +19 -5
  477. package/dist/ui/forms/FieldLabel.d.ts +35 -0
  478. package/dist/ui/forms/FieldLabel.js +70 -0
  479. package/dist/ui/forms/FieldSelect.d.ts +4 -1
  480. package/dist/ui/forms/FieldSelect.js +27 -5
  481. package/dist/ui/forms/FieldTextarea.d.ts +4 -1
  482. package/dist/ui/forms/FieldTextarea.js +22 -5
  483. package/dist/ui/forms/FormShell.d.ts +17 -1
  484. package/dist/ui/forms/FormShell.js +36 -2
  485. package/dist/ui/forms/FormShell.style.css +41 -33
  486. package/dist/ui/forms/index.d.ts +5 -1
  487. package/dist/ui/forms/index.js +2 -0
  488. package/dist/ui/icons/icon-registry.d.ts +1 -0
  489. package/dist/ui/icons/icon-registry.js +17 -1
  490. package/dist/utils/id-generators.d.ts +80 -0
  491. package/dist/utils/id-generators.js +93 -0
  492. package/dist/utils/search-txt-builders.d.ts +12 -0
  493. package/dist/utils/search-txt-builders.js +17 -0
  494. package/package.json +1 -1
  495. package/scripts/seed-cli.mjs +106 -5
  496. package/dist/features/loyalty/api/admin/loyalty/grant/route.d.ts +0 -12
  497. package/dist/features/loyalty/api/admin/loyalty/grant/route.js +0 -38
  498. package/dist/features/loyalty/api/balance/route.d.ts +0 -12
  499. package/dist/features/loyalty/api/balance/route.js +0 -41
  500. package/dist/features/loyalty/api/earn/route.d.ts +0 -12
  501. package/dist/features/loyalty/api/earn/route.js +0 -38
  502. package/dist/features/loyalty/api/history/route.d.ts +0 -12
  503. package/dist/features/loyalty/api/history/route.js +0 -58
  504. package/dist/features/loyalty/api/redeem/route.d.ts +0 -12
  505. package/dist/features/loyalty/api/redeem/route.js +0 -38
  506. package/dist/features/loyalty/columns/index.d.ts +0 -17
  507. package/dist/features/loyalty/columns/index.js +0 -40
  508. package/dist/features/loyalty/components/CoinsDisplay.d.ts +0 -12
  509. package/dist/features/loyalty/components/CoinsDisplay.js +0 -10
  510. package/dist/features/loyalty/components/index.d.ts +0 -1
  511. package/dist/features/loyalty/components/index.js +0 -1
  512. package/dist/features/loyalty/hooks/useLoyaltyBalance.d.ts +0 -4
  513. package/dist/features/loyalty/hooks/useLoyaltyBalance.js +0 -25
  514. package/dist/features/loyalty/index.d.ts +0 -6
  515. package/dist/features/loyalty/index.js +0 -6
  516. package/dist/features/loyalty/manifest.d.ts +0 -2
  517. package/dist/features/loyalty/manifest.js +0 -16
  518. package/dist/features/loyalty/messages/en.json +0 -19
  519. package/dist/features/loyalty/repository/loyalty.repository.d.ts +0 -8
  520. package/dist/features/loyalty/repository/loyalty.repository.js +0 -22
  521. package/dist/features/loyalty/schemas/index.d.ts +0 -85
  522. package/dist/features/loyalty/schemas/index.js +0 -40
  523. package/dist/features/loyalty/server.d.ts +0 -6
  524. package/dist/features/loyalty/server.js +0 -6
  525. package/dist/features/loyalty/types/index.d.ts +0 -31
  526. package/dist/features/loyalty/types/index.js +0 -1
  527. package/dist/features/loyalty/types/loyalty-config.d.ts +0 -7
  528. package/dist/features/loyalty/types/loyalty-config.js +0 -1
  529. package/dist/features/loyalty/types/loyalty-math.d.ts +0 -9
  530. package/dist/features/loyalty/types/loyalty-math.js +0 -27
  531. package/dist/features/products/components/ProductOwnerQuickActions.d.ts +0 -11
  532. package/dist/features/products/components/ProductOwnerQuickActions.js +0 -120
@@ -13,10 +13,9 @@ import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../
13
13
  import { ORDER_FIELDS } from "../../../../constants/field-names.js";
14
14
  import { serverLogger } from "../../../../monitoring/index.js";
15
15
  import { roundShippingUp, } from "../../../shared/shipping/quote.js";
16
- import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
17
- import { maxRedeemablePoints } from "../../../../features/points/schemas/firestore.js";
18
16
  import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories/index.js";
19
17
  import { isIntraStateSupply } from "../../../shared/fees/calculator.js";
18
+ import { resolveStateKey, INDIA_STATES } from "../../../shared/fees/india-states.js";
20
19
  import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
21
20
  import { COD_MAX_ORDER_VALUE_FALLBACK } from "../../../../features/admin/schemas/firestore.js";
22
21
  import { CHECKOUT_ERROR_CODES } from "../../../../errors/error-codes.js";
@@ -24,10 +23,17 @@ import { failedCheckoutRepository } from "../../../../features/checkout/reposito
24
23
  import { sendOrderConfirmationEmail } from "../../../../features/contact/server.js";
25
24
  import { splitCartIntoOrderGroups } from "../../../../features/orders/index.js";
26
25
  import { DEFAULT_STORE_SHIPPING_POLICY, quoteShipping, toStoreShippingPolicy, } from "../../../shared/features/shipping/mode.js";
26
+ /*
27
+ * D44's first consumer of `shippingUnavailable`. A store whose shipping cannot
28
+ * be priced is a configuration fault with a money consequence — we ship free —
29
+ * so it belongs in the operator queue, keyed on the store so a hundred
30
+ * checkouts produce one case.
31
+ */
32
+ import { paymentCasesRepository } from "../../../../features/payment-cases/repository/payment-cases.repository.js";
27
33
  import { computeProtectionFees } from "../../../shared/features/shipping/protections.js";
28
34
  import { resolveCouponCategorySlugs } from "../../../../features/promotions/actions/coupon-actions.js";
29
35
  import { assertCheckoutLane, assertLockedLinesStillValid, finalizeLockedLines, } from "./locked-lines.js";
30
- import { activeLane, defaultCheckoutItems, laneOf, } from "../../../shared/checkout/lanes.js";
36
+ import { activeLane, defaultCheckoutItems, resolveCheckoutItems, laneOf, } from "../../../shared/checkout/lanes.js";
31
37
  import { computeCodHandlingFee, computeGiftWrapFee, computeShipmentProtectionFee, computeSaleProtectionFee, computeCheckoutFees, allocateCheckoutFees, } from "../../../shared/fees/calculator.js";
32
38
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase/index.js";
33
39
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore.js";
@@ -51,6 +57,10 @@ import { checkEmiEligibility, computeBuyerEmiQuote, computeEmiSchedule } from ".
51
57
  import { formatShippingAddress } from "./data.js";
52
58
  import { enforceMaxPerUserForCart } from "./prize-bundle-gates.js";
53
59
  import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, } from "../../../shared/checkout/rules/index.js";
60
+ // The `GroupCouponDiscountResult` type is deliberately NOT imported: every
61
+ // call site here destructures the result inline, so naming the type would be
62
+ // an unused import.
63
+ import { computeCartCouponDiscount, computeGroupCouponDiscount, } from "../../../shared/checkout/coupon-discount.js";
54
64
  import { revalidateGroupLines } from "./group-rule-revalidation.js";
55
65
  import { cartIsDigitalOnly } from "../../../shared/listing-types/cart-shipping.js";
56
66
  import { processRefundAction } from "../refunds/actions.js";
@@ -60,6 +70,22 @@ import { resolveSupplierIdentity } from "./supplier-identity.js";
60
70
  import { PAYMENT_WINDOW_MS } from "../../../../features/orders/constants/payment-window.js";
61
71
  import { isCheckoutValueOtpVerified } from "../../../../features/checkout/actions/checkout-value-otp-actions.js";
62
72
  const AUDIT_LOG_FAIL_MSG = "checkout: audit log failed (non-critical)";
73
+ /**
74
+ * How long a `gatewayOrderClaims` holder owns the right to place an order
75
+ * before another caller may take the claim over.
76
+ *
77
+ * 🛑 Sized against the SLOWEST legitimate placement, not the typical one. The
78
+ * route runs with `maxDuration = 60`, and a placement fans out to the gateway
79
+ * re-fetch, the cart read, product validation, stock decrements and N order
80
+ * writes — so a lease shorter than the work would let a second placer start
81
+ * while the first is still mid-write, which is the duplicate order the claim
82
+ * exists to prevent. 90s is that ceiling plus headroom.
83
+ *
84
+ * It is also the buyer's worst-case wait before a retry can succeed, which is
85
+ * the other reason not to make it generous: before this existed the wait was
86
+ * unbounded, because a failed placement left the claim forever.
87
+ */
88
+ const GATEWAY_CLAIM_LEASE_MS = 90000;
63
89
  /**
64
90
  * SB-UNI-N — Atomically claim the next available digital code for a confirmed
65
91
  * order. Pre-fetches a candidate code outside the transaction then locks and
@@ -196,7 +222,37 @@ function accumulateCouponUsage(accumulator, couponCode, couponId, discountAmount
196
222
  * exact same per-seller shipping fee that order creation charges/records —
197
223
  * a single source of truth instead of a third re-implementation.
198
224
  */
199
- export async function resolveShippingCost(storeId) {
225
+ export async function resolveShippingCost(storeId,
226
+ /**
227
+ * The cart facts needed to QUOTE rather than merely resolve the policy.
228
+ *
229
+ * 🛑 D44 — OMITTING THIS IS TWO MONEY BUGS IN OPPOSITE DIRECTIONS, and until
230
+ * now every one of the five call sites omitted it.
231
+ *
232
+ * `quoteShipping` was called exactly once, here, with `goodsValue: 0` and
233
+ * `carrierRate: null`. Measured against its own logic
234
+ * (`shared/features/shipping/mode.ts`):
235
+ *
236
+ * 1. **Free-above-threshold can never fire.** The test is
237
+ * `goodsValue >= policy.freeAbove`, and `0 >= 999` is false for any
238
+ * positive threshold — so a store advertising "free shipping over ₹999"
239
+ * **charged its base rate on a ₹5,000 order.** The buyer is OVERCHARGED,
240
+ * and the seller's own offer silently does not exist.
241
+ * 2. **`estimate` mode ships FREE.** No carrier rate returns
242
+ * `kind: "unavailable"`, which the mapping below turns into
243
+ * `shippingFee: 0` — and `shippingUnavailable` had **zero consumers**, so
244
+ * nobody noticed. The platform/seller is UNDERCHARGED, which is what that
245
+ * field's own 🛑 warns about: "a zero there would ship every parcel free
246
+ * at our expense, silently, until somebody noticed."
247
+ *
248
+ * `flat` and `free` modes were unaffected, which is why this survived: the
249
+ * common cases are correct and only a store with a threshold or an estimate
250
+ * policy is mispriced.
251
+ *
252
+ * Still optional, because the one legitimate caller is the seller-settings
253
+ * surface that wants the POLICY and has no cart. Every money path passes it.
254
+ */
255
+ opts) {
200
256
  if (!storeId) {
201
257
  return {
202
258
  shippingFee: 0,
@@ -214,7 +270,11 @@ export async function resolveShippingCost(storeId) {
214
270
  const store = await storeRepository.findById(storeId);
215
271
  const storeOwnerId = store?.ownerId;
216
272
  const storeEmiEnabled = store?.emiEnabled === true;
217
- const storeState = await resolveStoreState(storeId);
273
+ // `store` is passed in rather than re-fetched inside: G3 needs its `gstin`,
274
+ // and the document is already in hand one line above. A second
275
+ // `storeRepository` read here is what line ~504's comment warns against,
276
+ // and it would cost a Firestore read on every checkout (Rule #6).
277
+ const storeState = await resolveStoreState(storeId, store?.gstin);
218
278
  /*
219
279
  * ── C5b: the store's shipping MODE, not a flat number ──────────────────
220
280
  *
@@ -240,17 +300,76 @@ export async function resolveShippingCost(storeId) {
240
300
  ? toStoreShippingPolicy(store.shippingPricing)
241
301
  : await legacyFlatPolicy(storeOwnerId);
242
302
  /*
243
- * 🛑 `goodsValue: 0` and no carrier rate here, deliberately.
244
- *
245
- * This function resolves the POLICY; it does not know the cart. A caller
246
- * that needs the charged amount calls `quoteShipping` with the group's goods
247
- * value and, for `estimate` mode, a rate — and `shippingFee` below stays the
248
- * non-carrier answer so every existing caller behaves exactly as before
249
- * until it is converted. An `estimate`-mode store therefore reports
250
- * `shippingUnavailable`, which is the signal to go and get a rate rather
251
- * than a zero that would ship for free at our expense.
303
+ * 🛑 D44 FIXED HERE: the quote now uses the caller's real goods value.
304
+ *
305
+ * This comment used to read *"`goodsValue: 0` and no carrier rate here,
306
+ * deliberately… a caller that needs the charged amount calls `quoteShipping`
307
+ * with the group's goods value"*. The design was right and **no caller ever
308
+ * did it** — `quoteShipping` had exactly one call site, this one, and all
309
+ * five callers destructured `shippingFee` and ignored both `shippingPolicy`
310
+ * and `shippingUnavailable`. A seam nobody used is not a seam; it is a
311
+ * default, and this default mispriced two policy shapes (see the `opts`
312
+ * docstring above).
313
+ *
314
+ * Defaulting `goodsValue` to 0 keeps the policy-only callers behaving
315
+ * exactly as before, so this is additive for them and corrective for the
316
+ * money paths.
252
317
  */
253
- const quote = quoteShipping({ policy, goodsValue: 0, carrierRate: null });
318
+ const quote = quoteShipping({
319
+ policy,
320
+ goodsValue: opts?.goodsValue ?? 0,
321
+ carrierRate: opts?.carrierRate ?? null,
322
+ });
323
+ /*
324
+ * ── D44, the other half: `shippingUnavailable` gets its FIRST CONSUMER ────
325
+ *
326
+ * The field was declared, assigned twice, and read by nothing — its own 🛑
327
+ * said "the caller MUST act on this rather than trusting `shippingFee: 0`"
328
+ * and no caller did. So an `estimate`-mode store shipped every parcel free.
329
+ *
330
+ * 🛑 IT DOES NOT REFUSE THE CHECKOUT, and that is a deliberate choice rather
331
+ * than timidity. **There is no carrier-rate source in the system at all**:
332
+ * Shiprocket is authenticated but its create-order/AWB/rate calls are
333
+ * unverified with zero callers, so `carrierRate` can only ever be null today.
334
+ * Throwing here would therefore break checkout for every estimate-mode store
335
+ * outright — converting a silent revenue leak into a total outage for those
336
+ * sellers, which is the worse of the two.
337
+ *
338
+ * So the leak is made VISIBLE instead: one case per store (the dedupe key is
339
+ * the store, so a hundred checkouts produce one row), carrying the amount we
340
+ * failed to charge. Once somebody has measured how many stores are actually
341
+ * in `estimate` mode, refusing becomes the correct stricter behaviour — and
342
+ * that measurement is a one-line query, not a design question.
343
+ *
344
+ * Fire-and-forget: a case write must never fail the checkout it is
345
+ * reporting on.
346
+ */
347
+ if (quote.kind === "unavailable" && opts?.goodsValue !== undefined) {
348
+ void paymentCasesRepository()
349
+ .openOrObserve({
350
+ kind: "shipping_unpriced",
351
+ subjectId: storeId,
352
+ severity: "warning",
353
+ title: `Shipping could not be priced — ${storeId} is shipping free`,
354
+ detail: [
355
+ `This store's shipping policy is \`estimate\` mode and no carrier rate`,
356
+ `was available, so the quote came back "unavailable" and the checkout`,
357
+ `charged ₹0 for shipping.`,
358
+ "",
359
+ `Store: ${storeId}`,
360
+ `Goods value on this order group: ${opts.goodsValue}`,
361
+ "",
362
+ `🛑 There is no carrier-rate integration wired, so this will recur for`,
363
+ `every order from this store. Fix by giving the store a \`flat\` or`,
364
+ `\`free\` policy in its shipping settings, or by wiring a rate source.`,
365
+ ].join("\n"),
366
+ entityType: "store",
367
+ entityId: storeId,
368
+ })
369
+ .catch((err) => {
370
+ void normalizeError(err);
371
+ });
372
+ }
254
373
  return {
255
374
  shippingFee: quote.kind === "charge" ? quote.amount : 0,
256
375
  storeOwnerId: storeOwnerId ?? undefined,
@@ -302,60 +421,50 @@ async function legacyFlatPolicy(storeOwnerId) {
302
421
  return { mode: { kind: "flat", amount } };
303
422
  }
304
423
  /** P-8 GST — the seller's registered/pickup state, used to determine intra- vs inter-state tax. */
305
- async function resolveStoreState(storeId) {
424
+ async function resolveStoreState(storeId,
425
+ /**
426
+ * The store's GSTIN, supplied by the caller which already holds the store
427
+ * document. Not re-fetched here — see the call site.
428
+ */
429
+ storeGstin) {
430
+ /*
431
+ * ── G3: the GSTIN's state code wins over the typed pickup address ────────
432
+ *
433
+ * A GSTIN's first two characters ARE the state of registration — it is
434
+ * issued per state, so for a registered seller it is the authoritative
435
+ * place of supply. The pickup address is free text a seller typed, and a
436
+ * store whose pickup state disagrees with its GSTIN prefix (a warehouse in
437
+ * another state, a typo, a seller who moved) splits the tax on the wrong
438
+ * one.
439
+ *
440
+ * `resolveStateKey` accepts a bare code, a full GSTIN, or a name, so the
441
+ * GSTIN goes to it whole rather than being sliced here — that keeps the
442
+ * "first two characters are the state" knowledge in exactly one place.
443
+ *
444
+ * 🛑 Falls back to the pickup address rather than failing: an UNREGISTERED
445
+ * seller legitimately has no GSTIN and is the common case on this platform.
446
+ * Returns the canonical display NAME, not the raw code, so every downstream
447
+ * reader still receives a state name.
448
+ */
449
+ const fromGstin = resolveStateKey(storeGstin);
450
+ if (fromGstin)
451
+ return INDIA_STATES[fromGstin].name;
306
452
  const addresses = await addressesRepository.listByOwner("store", storeId);
307
453
  const pickup = addresses.find((a) => a.isDefault) ?? addresses[0];
308
454
  return pickup?.state;
309
455
  }
310
- /**
311
- * Prorates every buyer-applied coupon against one seller-group's total. Pure
312
- * — no side effects (usage-accumulator bookkeeping is layered on by callers
313
- * that actually place an order). Shared by createOrderForGroup,
314
- * createPhonePeGroupOrder, and the read-only previewCheckoutPricing so the
315
- * three can never drift on how a coupon splits across sellers (was three
316
- * near-identical copies before this extraction — Duplication Decision
317
- * Framework's Rule of Three).
456
+ /*
457
+ * 🛑 `computeGroupCouponDiscount` and `GroupCouponDiscountResult` MOVED to
458
+ * `_internal/shared/checkout/coupon-discount.ts` on 2026-10-21. Imported at
459
+ * the top of this file; do not re-add a local copy.
460
+ *
461
+ * It was module-private here, which meant the three order paths in this file
462
+ * could reach it and `/api/payment/create-order` — the route that decides what
463
+ * the buyer's CARD is charged — could not, because a consumer route cannot
464
+ * deep-import `_internal/server`. That route consequently had no coupon term at
465
+ * all, so every coupon order paid by card over-captured by the discount. One
466
+ * implementation, four callers.
318
467
  */
319
- function computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, storeId) {
320
- let couponDiscount = 0;
321
- const appliedDiscounts = [];
322
- for (const coupon of appliedCoupons) {
323
- let couponGroupDiscount = 0;
324
- const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
325
- if (isSellerScoped) {
326
- if (coupon.storeId !== storeId)
327
- continue;
328
- if (coupon.applicableItemIds?.length) {
329
- const eligibleTotal = groupLines
330
- .filter((l) => coupon.applicableItemIds.includes(l.itemId))
331
- .reduce((s, l) => s + l.lineTotal, 0);
332
- couponGroupDiscount =
333
- eligibleTotal > 0
334
- ? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
335
- : 0;
336
- }
337
- else {
338
- couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
339
- }
340
- }
341
- else if (cartSubtotal > 0) {
342
- couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
343
- }
344
- if (couponGroupDiscount > 0) {
345
- couponDiscount += couponGroupDiscount;
346
- appliedDiscounts.push({
347
- code: coupon.code,
348
- couponId: coupon.couponId,
349
- type: "coupon",
350
- discountAmount: couponGroupDiscount,
351
- scope: coupon.scope,
352
- storeId: coupon.storeId,
353
- });
354
- }
355
- }
356
- couponDiscount = Math.min(couponDiscount, groupTotal);
357
- return { couponDiscount, appliedDiscounts };
358
- }
359
468
  function buildStockUpdatePayload(product, qtyDelta) {
360
469
  const lt = (product.listingType ?? "standard");
361
470
  const rule = getListingRule(lt);
@@ -500,6 +609,29 @@ async function createOrderForGroup(group, orderType, ctx) {
500
609
  const { giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, saleProtectionAddon = false, } = storeAddons?.[firstItem.storeId] ?? {};
501
610
  const { platformFee = 0, gstOnFee: platformFeeGst = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
502
611
  const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
612
+ /*
613
+ * ── G1: the discount is computed HERE, before the GST, on purpose ────────
614
+ *
615
+ * This used to sit ~150 lines below, after `sumGroupGst` had already backed
616
+ * the tax out of the UNDISCOUNTED gross — so the order recorded tax on money
617
+ * the buyer never paid (₹30.51 on a ₹1,000 item with a ₹200 coupon at 18%,
618
+ * verified).
619
+ *
620
+ * A discount shown on the invoice generally reduces the value of supply, so
621
+ * the pipeline order has to be goods → discount → tax, and that makes the
622
+ * computation's POSITION load-bearing rather than incidental. Moved up
623
+ * rather than duplicated: it is pure (no Firestore, no side effects — the
624
+ * usage-accumulator bookkeeping is layered on separately below), so hoisting
625
+ * it changes nothing but the order of two pure steps.
626
+ *
627
+ * 🛑 Do not move `sumGroupGst` above this, and do not recompute the discount
628
+ * below. Both re-open the defect.
629
+ */
630
+ const groupLines = group.map(({ item, product }) => ({
631
+ itemId: item.itemId,
632
+ lineTotal: lineTotalFor(item, product),
633
+ }));
634
+ const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
503
635
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
504
636
  // A multi-member line expands into one row PER MEMBER sharing a groupSlug, so
505
637
  // each row carries its own HSN/GST and can be cancelled independently; the
@@ -510,7 +642,15 @@ async function createOrderForGroup(group, orderType, ctx) {
510
642
  return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
511
643
  });
512
644
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
513
- const { shippingFee: resolvedShippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
645
+ /*
646
+ * D44: the group's real goods value, so free-above-threshold can fire.
647
+ *
648
+ * `lineTotalFor`, never `item.price * item.quantity` — that is the
649
+ * hand-rolled rule Root Cause #75 is about, and on a locked-price or
650
+ * grouped line the two differ.
651
+ */
652
+ const groupGoodsValue = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
653
+ const { shippingFee: resolvedShippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupGoodsValue });
514
654
  /*
515
655
  * 🛑 The buyer's quote WINS over the seller's configured rate, and it is
516
656
  * re-rounded here rather than trusted as sent.
@@ -543,50 +683,22 @@ async function createOrderForGroup(group, orderType, ctx) {
543
683
  // then split the group total into a single cgst/sgst/igst breakdown for the
544
684
  // order document. Skipped entirely when GST is off or the buyer's state is
545
685
  // unknown (digital-only carts have no shipping address).
546
- let gstBreakdown;
547
- if (gstSettings?.enabled && buyerState) {
548
- /*
549
- * 🛑 `isIntraStateSupply`, not `storeState === buyerState`. That raw
550
- * comparison read "Karnataka" / "karnataka" / "KA" as three different
551
- * states — the exact drift `constants/geo/subdivisions.ts` opens by
552
- * documenting — and a store with no address resolved to `false`, i.e.
553
- * silently inter-state. `null` means "cannot tell" and is logged rather
554
- * than guessed; the AMOUNT is identical either way (IGST total ==
555
- * CGST+SGST total), so what was wrong was the split on the invoice.
556
- */
557
- const intra = isIntraStateSupply(storeState, buyerState);
558
- if (intra === null) {
559
- serverLogger.warn("[gst] place of supply undetermined — defaulting to inter-state (IGST)", { storeId: firstItem.storeId, hasStoreState: !!storeState });
560
- }
561
- // Per-member, not per-line: a bundle or grouped line spans several products
562
- // with several different rates, and taxing the whole line at one product's
563
- // rate is wrong in both directions. Shared with previewCheckoutPricing so
564
- // the figure shown and the figure charged cannot drift.
565
- const summary = sumGroupGst(group.map(({ item }) => item), ctx.productById, intra ?? false);
566
- /*
567
- * The split is taken AS COMPUTED, per slice, by `calculateGst` inside
568
- * `sumGroupGst`. It used to be re-derived here as
569
- * `Math.round(gstAmount / 2)` — whole rupees, so ₹161.82 became
570
- * 81.00/80.82 rather than 80.91/80.91, and a ₹0.52 total rounded to 0 and
571
- * was then dropped off the invoice entirely by the `|| undefined` below.
572
- *
573
- * The guard is on EITHER base: an order of purely exempt goods has a real
574
- * taxable value to report and zero tax, and skipping it left the invoice
575
- * unable to reconcile.
576
- */
577
- if (summary.taxableAmount > 0 || summary.exemptAmount > 0) {
578
- gstBreakdown = summary;
579
- }
580
- }
581
686
  const isCodLike = paymentMethod === "cod" || paymentMethod === "upi_manual" || paymentMethod === "cash";
582
- const depositAmount = !isCodLike
583
- ? undefined
584
- : orderType === "preorder"
585
- ? computePreOrderDepositAmount(group, commissions.codDepositPercent)
586
- : roundRupees(groupTotal * (commissions.codDepositPercent / 100));
587
- const codRemainingAmount = isCodLike
588
- ? roundRupees(groupTotal - (depositAmount ?? 0))
589
- : undefined;
687
+ /*
688
+ * 🛑 D43 — THE DEPOSIT/REMAINING SPLIT MOVED DOWN, BELOW `orderTotal`.
689
+ *
690
+ * It used to be computed HERE, on `groupTotal` — the goods value — so
691
+ * `depositAmount + codRemainingAmount` summed to the goods only and omitted
692
+ * shipping, the COD handling fee, add-ons, the platform fee and its GST.
693
+ * A COD buyer was told to "pay ₹X on delivery" where X was short by every
694
+ * fee they had just agreed to, and the courier would collect the wrong
695
+ * amount.
696
+ *
697
+ * It could not be fixed in place: `codHandlingFee` (below), `giftWrapFee`
698
+ * and both protection fees are all computed AFTER this point, so the total
699
+ * simply did not exist yet. The split now sits immediately after
700
+ * `orderTotal`, which is the only place every term is known.
701
+ */
590
702
  let emiSchedule;
591
703
  if (paymentMethod === "emi" && emiTenureMonths) {
592
704
  const eligibility = checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings);
@@ -626,9 +738,80 @@ async function createOrderForGroup(group, orderType, ctx) {
626
738
  shippingFee: (s, sel) => computeShipmentProtectionFee(s, sel, commissions),
627
739
  saleFee: (s, sel) => computeSaleProtectionFee(s, sel, commissions),
628
740
  });
741
+ /*
742
+ * 🛑 THIS BLOCK WAS MOVED DOWN (2026-10-21) and its position is load-bearing.
743
+ *
744
+ * It used to sit ~90 lines above, before `codHandlingFee`, `giftWrapFee` and
745
+ * the two protection fees were computed — so it could not possibly tax them,
746
+ * and they entered the order total as bare addends carrying no tax (G5).
747
+ *
748
+ * It now runs after every ancillary charge is known and before `orderTotal`
749
+ * consumes them. Do not move it back up, and do not move a fee computation
750
+ * below it.
751
+ */
752
+ let gstBreakdown;
753
+ if (gstSettings?.enabled && buyerState) {
754
+ /*
755
+ * 🛑 `isIntraStateSupply`, not `storeState === buyerState`. That raw
756
+ * comparison read "Karnataka" / "karnataka" / "KA" as three different
757
+ * states — the exact drift `constants/geo/subdivisions.ts` opens by
758
+ * documenting — and a store with no address resolved to `false`, i.e.
759
+ * silently inter-state. `null` means "cannot tell" and is logged rather
760
+ * than guessed; the AMOUNT is identical either way (IGST total ==
761
+ * CGST+SGST total), so what was wrong was the split on the invoice.
762
+ */
763
+ const intra = isIntraStateSupply(storeState, buyerState);
764
+ if (intra === null) {
765
+ serverLogger.warn("[gst] place of supply undetermined — defaulting to inter-state (IGST)", { storeId: firstItem.storeId, hasStoreState: !!storeState });
766
+ }
767
+ // Per-member, not per-line: a bundle or grouped line spans several products
768
+ // with several different rates, and taxing the whole line at one product's
769
+ // rate is wrong in both directions. Shared with previewCheckoutPricing so
770
+ // the figure shown and the figure charged cannot drift.
771
+ const summary = sumGroupGst(group.map(({ item }) => item), ctx.productById, intra ?? false,
772
+ // G1 — the taxable value is the POST-discount gross. See the hoist above.
773
+ couponDiscount,
774
+ /*
775
+ * G5 — the ancillary charges, taxed at the principal rate. Shipping,
776
+ * COD handling, gift wrap and both protections; NOT the platform fee,
777
+ * which already carries its own `platformFeeGst` and would be taxed
778
+ * twice, and NOT the EMI surcharge, which is a financing charge with
779
+ * its own treatment and is deliberately outside the decided scope.
780
+ */
781
+ roundRupees(shippingFee + codHandlingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee));
782
+ /*
783
+ * The split is taken AS COMPUTED, per slice, by `calculateGst` inside
784
+ * `sumGroupGst`. It used to be re-derived here as
785
+ * `Math.round(gstAmount / 2)` — whole rupees, so ₹161.82 became
786
+ * 81.00/80.82 rather than 80.91/80.91, and a ₹0.52 total rounded to 0 and
787
+ * was then dropped off the invoice entirely by the `|| undefined` below.
788
+ *
789
+ * The guard is on EITHER base: an order of purely exempt goods has a real
790
+ * taxable value to report and zero tax, and skipping it left the invoice
791
+ * unable to reconcile.
792
+ */
793
+ if (summary.taxableAmount > 0 || summary.exemptAmount > 0) {
794
+ gstBreakdown = summary;
795
+ }
796
+ }
629
797
  const groupCouponCodes = new Set();
630
- const groupLines = group.map(({ item }) => ({ itemId: item.itemId, lineTotal: item.price * item.quantity }));
631
- const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
798
+ /*
799
+ * 🛑 `couponDiscount` / `appliedDiscounts` are HOISTED to just after
800
+ * `groupTotal` (~line 860), not computed here. The move is load-bearing:
801
+ * `sumGroupGst` sits between the two and now takes the discount, because the
802
+ * taxable value is the post-discount gross (G1). Recomputing here would be a
803
+ * second copy; moving the GST call below this would re-open the defect.
804
+ *
805
+ * What REMAINS here is the side effect — accumulating coupon usage — which
806
+ * must stay where it is, after the group has been confirmed placeable.
807
+ *
808
+ * Also fixed in the hoist: `groupLines` was built with
809
+ * `item.price * item.quantity` while `groupTotal` (155 lines above, same
810
+ * function) used `lineTotalFor`, and `computeGroupCouponDiscount` divides one
811
+ * by the other — so on a locked-price line the numerator carried the LIST
812
+ * price and the denominator the AGREED one, the ratio exceeded 1, and the
813
+ * coupon over-discounted that group. Root Cause #75, sixth copy.
814
+ */
632
815
  for (const discount of appliedDiscounts) {
633
816
  if (discount.couponId) {
634
817
  accumulateCouponUsage(couponUsageAccumulator, discount.code, discount.couponId, discount.discountAmount);
@@ -650,7 +833,49 @@ async function createOrderForGroup(group, orderType, ctx) {
650
833
  * OUR service fee to the seller, charged to the buyer on top of the fee —
651
834
  * not tax on the goods.
652
835
  */
653
- Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0);
836
+ /*
837
+ * 🛑 D42 — ROUNDED ONCE, HERE, AT THE END.
838
+ *
839
+ * This was a raw float sum of nine 2dp terms written verbatim to
840
+ * Firestore. IEEE-754 does not represent 0.1 exactly, so a nine-term sum
841
+ * lands on values like 1011.8000000000001 — and that is what got stored as
842
+ * the order's total, rendered on the invoice, and compared against the
843
+ * captured amount. The capture route was saved only because
844
+ * `rupeesToPaise` rounds on the way to the gateway; the Firestore write had
845
+ * no such protection.
846
+ *
847
+ * Rounded ONCE on the sum, never per term: rounding each addend first
848
+ * compounds nine half-paisa errors instead of one, which is how a total
849
+ * drifts from the sum of the lines shown beside it.
850
+ */
851
+ roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0));
852
+ /*
853
+ * ── D43: the COD deposit/remaining split, on the ORDER TOTAL ──────────────
854
+ *
855
+ * Moved here from ~150 lines above, where it was computed on `groupTotal`
856
+ * and therefore summed to the goods value alone — see the 🛑 at its old
857
+ * site. Every term `orderTotal` needs is finally known at this point, which
858
+ * is why this is the only correct home for it.
859
+ *
860
+ * 🛑 The pre-order branch is deliberately left on `group`:
861
+ * `computePreOrderDepositAmount` reads each line's own
862
+ * `preOrderDepositPercent`, because a pre-order deposit is a per-PRODUCT
863
+ * commercial term the seller set, not a percentage of the order. Rebasing it
864
+ * on `orderTotal` would silently change what every existing pre-order
865
+ * charges — a different defect, not this one.
866
+ *
867
+ * `roundRupees` on both halves, and the remainder is taken as
868
+ * `orderTotal − deposit` rather than independently rounded, so the two
869
+ * provably sum to the total instead of drifting by a paisa (D42's shape).
870
+ */
871
+ const depositAmount = !isCodLike
872
+ ? undefined
873
+ : orderType === "preorder"
874
+ ? computePreOrderDepositAmount(group, commissions.codDepositPercent)
875
+ : roundRupees(orderTotal * (commissions.codDepositPercent / 100));
876
+ const codRemainingAmount = isCodLike
877
+ ? roundRupees(orderTotal - (depositAmount ?? 0))
878
+ : undefined;
654
879
  const imageUrls = [
655
880
  ...new Set(group
656
881
  .map(({ product }) => product.mainImage)
@@ -729,6 +954,16 @@ async function createOrderForGroup(group, orderType, ctx) {
729
954
  // This group's pro-rata share of the checkout's single capped commission —
730
955
  // the shares across a multi-store checkout sum to exactly what was charged.
731
956
  platformFee: !adminBypass && platformFee > 0 ? platformFee : undefined,
957
+ /*
958
+ * Persisted alongside the fee, 2026-10-21. It was computed here, charged
959
+ * to the buyer as its own term in `orderTotal`, and then folded into
960
+ * `gstAmount` — so no field held the fee's own tax and a refund had to
961
+ * re-derive it from live percentages, which is how D32's ~10x
962
+ * over-deduction happened. The note above this function's `orderTotal`
963
+ * already explains why the two are kept separate; this persists the second
964
+ * one so a later reader does not have to reconstruct it.
965
+ */
966
+ platformFeeGst: !adminBypass && platformFeeGst > 0 ? platformFeeGst : undefined,
732
967
  giftWrapAddon: !adminBypass && giftWrapFee > 0 ? true : undefined,
733
968
  giftWrapFee: !adminBypass && giftWrapFee > 0 ? giftWrapFee : undefined,
734
969
  giftWrapMessage: !adminBypass && giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
@@ -782,6 +1017,14 @@ async function createOrderForGroup(group, orderType, ctx) {
782
1017
  emiTenureMonths: !adminBypass && emiSchedule ? emiTenureMonths : undefined,
783
1018
  emiTokenAmount: !adminBypass ? emiSchedule?.tokenAmount : undefined,
784
1019
  emiSurchargeAmount: !adminBypass ? emiSchedule?.surchargeAmount : undefined,
1020
+ /*
1021
+ * D45: the seller's share of the surcharge, recorded for the first time.
1022
+ * `computeEmiSchedule` has always returned it and nothing ever stored it,
1023
+ * so a split the admin had configured (default 50%) was computed and
1024
+ * discarded on every EMI order. Persisting it moves no money — see the
1025
+ * field's own 🛑 — it just stops the decision being thrown away.
1026
+ */
1027
+ emiSurchargeSellerShare: !adminBypass ? emiSchedule?.surchargeSellerShare : undefined,
785
1028
  emiInstallments: !adminBypass && emiSchedule
786
1029
  ? emiSchedule.installments.map((i) => ({ index: i.index, dueDate: i.dueDate, amount: i.amount, status: "pending" }))
787
1030
  : undefined,
@@ -1178,45 +1421,46 @@ export async function createCheckoutOrderAction(input) {
1178
1421
  }
1179
1422
  await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
1180
1423
  /*
1181
- * Spend points AFTER the orders exist.
1182
- *
1183
- * 🛑 This ordering is the whole safety argument. Redeeming first and then
1184
- * failing to create an order would debit a buyer for something they never
1185
- * received, and points have no refund path that does not need a human. This
1186
- * way the worst case is an order placed at full price with the balance
1187
- * intact — recoverable, and visible to the buyer, who still has their
1188
- * points.
1189
- *
1190
- * The amount is re-derived server-side against the real total rather than
1191
- * taken from the request; `maxRedeemablePoints` applies the 50% cap, and
1192
- * `redeem` re-checks the balance inside its own transaction.
1424
+ * ── 🛑 THE POINTS REDEMPTION BLOCK WAS DELETED HERE, 2026-10-21 (D46) ─────
1425
+ *
1426
+ * It debited the buyer's points and reduced NOTHING, and its own comment
1427
+ * asserted the opposite. Preserved verbatim because the reasoning error is
1428
+ * the instructive part:
1429
+ *
1430
+ * "This ordering is the whole safety argument. … This way the worst case
1431
+ * is an order placed at full price with the balance intact."
1432
+ *
1433
+ * The balance was not intact. It was debited. That sentence reasons only
1434
+ * about the branch where `redeem` THROWS — in which case, yes, the buyer
1435
+ * keeps their points. The SUCCESS branch is the broken one: by the time this
1436
+ * ran, `createOrderForGroup` had already created every order and the gateway
1437
+ * had already captured `total`, so a successful redeem took points from a
1438
+ * buyer who had just paid full price. No order field, no cart field and no
1439
+ * gateway amount was ever reduced by `spend`.
1440
+ *
1441
+ * 🛑 It was unreachable, and that is the only reason it cost nothing: no
1442
+ * route schema, no caller and no UI ever passed `pointsToRedeem` — verified
1443
+ * across `src/` and `appkit/src/`. So the hazard was not the behaviour, it
1444
+ * was that ADDING ONE FIELD to one route schema would have made it live,
1445
+ * while `UserPointsView` already promises buyers "up to half of an order's
1446
+ * total can be paid with points". Somebody would have wired it.
1447
+ *
1448
+ * Deleting it leaves a promise with no mechanism, which is a disappointment.
1449
+ * Keeping it left a mechanism that silently takes points, which is a theft.
1450
+ *
1451
+ * ## What the real tender has to do instead
1452
+ *
1453
+ * Points are a TENDER, so they belong in the pricing pipeline, not after it:
1454
+ * a stage in `computeCheckoutTotal` AFTER rounding — `amountDue = total −
1455
+ * pointsApplied` — with the gateway capturing `amountDue` and the
1456
+ * post-capture guard comparing against `amountDue`. Anything else makes
1457
+ * points a sixth way the captured amount can diverge from the recorded order
1458
+ * total, which is the D37/D38/D40 family this plan exists to close.
1459
+ *
1460
+ * Everything that work needs now exists: `purchase_earn`, per-batch expiry,
1461
+ * FIFO consumption, `redemption_reversal` and the seller-funded deduction.
1462
+ * What is missing is the pipeline stage and the two document fields.
1193
1463
  */
1194
- if (input.pointsToRedeem && input.pointsToRedeem > 0 && orderIds.length > 0) {
1195
- try {
1196
- const balance = await pointsRepository.getBalance(uid);
1197
- const spend = maxRedeemablePoints(total, Math.min(balance, input.pointsToRedeem));
1198
- if (spend > 0) {
1199
- await pointsRepository.redeem({
1200
- userId: uid,
1201
- points: spend,
1202
- orderId: orderIds[0],
1203
- description: `Redeemed on ${orderIds[0]}`,
1204
- });
1205
- }
1206
- }
1207
- catch (err) {
1208
- /*
1209
- * A failed redemption must not fail a placed order. The buyer keeps the
1210
- * points and paid full price — the strictly safer side of this trade.
1211
- */
1212
- const normalized = normalizeError(err);
1213
- serverLogger.error("points redemption failed after order creation", {
1214
- uid,
1215
- orderId: orderIds[0],
1216
- message: normalized.message,
1217
- });
1218
- }
1219
- }
1220
1464
  dispatchOrderConfirmationEmails(emailsToSend);
1221
1465
  serverLogger.info(`createCheckoutOrderAction: ${orderIds.length} order(s) placed for uid=${uid}` +
1222
1466
  (unavailable.length > 0 ? ` (${unavailable.length} item(s) unavailable)` : ""));
@@ -1315,7 +1559,14 @@ export async function previewCheckoutPricing(input) {
1315
1559
  for (const { items: group } of orderGroups) {
1316
1560
  const firstItem = group[0].item;
1317
1561
  const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
1318
- const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId);
1562
+ /*
1563
+ * D44: `groupTotal` is already computed one line above with `lineTotalFor`
1564
+ * and was simply not being passed — so the PREVIEW told the buyer they owed
1565
+ * shipping on an order that qualified for the store's free-above threshold,
1566
+ * and then the order path (equally unpassed) charged it. Preview and charge
1567
+ * agreed, and both were wrong in the same direction.
1568
+ */
1569
+ const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupTotal });
1319
1570
  // Add-ons are per store, read off the cart document — the same entry
1320
1571
  // createOrderForGroup will read when the order is actually placed.
1321
1572
  const addons = cart.storeAddons?.[firstItem.storeId] ?? {};
@@ -1382,6 +1633,17 @@ export async function previewCheckoutPricing(input) {
1382
1633
  shippingFee: (s, sel) => computeShipmentProtectionFee(s, sel, commissions),
1383
1634
  saleFee: (s, sel) => computeSaleProtectionFee(s, sel, commissions),
1384
1635
  });
1636
+ /*
1637
+ * G1 — the discount is computed BEFORE the GST block below, the same hoist
1638
+ * `createOrderForGroup` now has. It sat AFTER it until 2026-10-21, so the
1639
+ * tax figure SHOWN to the buyer was backed out of the undiscounted gross.
1640
+ *
1641
+ * The preview and the charge must agree on the taxable BASE, not only on
1642
+ * the total (Root Cause #59) — so the ordering has to match in both, which
1643
+ * makes the position of this block load-bearing rather than incidental.
1644
+ */
1645
+ const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
1646
+ const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
1385
1647
  let groupGstAmount = 0;
1386
1648
  if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
1387
1649
  /*
@@ -1395,10 +1657,23 @@ export async function previewCheckoutPricing(input) {
1395
1657
  // Same helper createOrderForGroup uses — the two disagreeing is the
1396
1658
  // recurring defect here (Root Cause #59), and it is the buyer seeing one
1397
1659
  // tax figure and being charged another.
1398
- groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById, intra ?? false).gstAmount;
1660
+ groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById, intra ?? false, groupCouponDiscount,
1661
+ /*
1662
+ * G5 — the same five ancillary charges the order path taxes, in the
1663
+ * same order, so the figure SHOWN and the figure CHARGED cannot
1664
+ * diverge on the tax base (Root Cause #59). All five are already in
1665
+ * scope here, so unlike `createOrderForGroup` no block had to move.
1666
+ *
1667
+ * `groupEmiSurcharge` is deliberately absent — a financing charge with
1668
+ * its own treatment, outside the decided scope — and the platform fee
1669
+ * is absent because it already carries `platformFeeGst`.
1670
+ */
1671
+ roundRupees(groupShippingFee +
1672
+ groupCodHandlingFee +
1673
+ groupGiftWrapFee +
1674
+ groupShipmentProtectionFee +
1675
+ groupSaleProtectionFee)).gstAmount;
1399
1676
  }
1400
- const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
1401
- const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
1402
1677
  stores.push({
1403
1678
  storeId: firstItem.storeId,
1404
1679
  storeName: firstItem.storeName || firstItem.storeId,
@@ -1569,6 +1844,18 @@ async function refundDroppedItemsForPhonePeCheckout(input) {
1569
1844
  */
1570
1845
  reasonCode: "not_received",
1571
1846
  reasonNote: `Automatic refund — ${unavailablePaid.length} item(s) unavailable at checkout: ${unavailablePaid.map((u) => u.productTitle).join(", ")}`,
1847
+ /*
1848
+ * A NATURAL key, not a uuid — and this is a money fix in its own right,
1849
+ * not just satisfying a new required field.
1850
+ *
1851
+ * This path runs inside the gateway-settlement flow, which is itself
1852
+ * retried: by the browser's `/verify`, by the webhook, and now (Phase 0)
1853
+ * by the gateway's own 5xx retry. Every one of those retries used to
1854
+ * mint a fresh `randomUUID()` and issue ANOTHER real refund for the same
1855
+ * dropped items. Keying on the gateway order id makes the whole
1856
+ * auto-refund idempotent across every retry of the checkout.
1857
+ */
1858
+ idempotencyKey: `oos_${phonepeOrderId}`,
1572
1859
  method: "phonepe",
1573
1860
  phonepeOrderId,
1574
1861
  confirmIrrevocable: true,
@@ -1644,7 +1931,9 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1644
1931
  // Reuses the same store/seller lookup as the COD/UPI path above instead
1645
1932
  // of re-implementing it inline — was two sequential findById calls per
1646
1933
  // seller group here, duplicated from resolveShippingCost.
1647
- const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections } = await resolveShippingCost(firstItem.storeId);
1934
+ // D44: `groupTotal` is computed just above — pass it so a free-above
1935
+ // threshold can fire and an unpriceable store is recorded.
1936
+ const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupTotal });
1648
1937
  /*
1649
1938
  * Same supplier-of-record rule as `createOrderForGroup`, through the same
1650
1939
  * function. This path (PhonePe verify / webhook) is the SECOND place an
@@ -1743,14 +2032,28 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1743
2032
  if (intra === null) {
1744
2033
  serverLogger.warn("[gst] place of supply undetermined on the online path — defaulting to inter-state (IGST)", { storeId: firstItem.storeId, hasStoreState: !!storeState });
1745
2034
  }
1746
- const summary = sumGroupGst(group.map(({ item }) => item), phonepeProductById, intra ?? false);
2035
+ // G1 — post-discount taxable base. `couponDiscount` is already computed
2036
+ // ~100 lines above in this function, so unlike the other two call sites no
2037
+ // hoist was needed here; only the argument was missing.
2038
+ const summary = sumGroupGst(group.map(({ item }) => item), phonepeProductById, intra ?? false, couponDiscount,
2039
+ /*
2040
+ * G5 — the ancillary charges. 🛑 FOUR, not five: this is the ONLINE
2041
+ * payment path, so there is no `codHandlingFee` in scope and none is
2042
+ * charged — COD is a different payment method handled by
2043
+ * `createOrderForGroup`. The absence is correct, not an omission, and
2044
+ * worth saying so because the other two call sites pass five and a
2045
+ * reader comparing them would otherwise look for the missing term.
2046
+ */
2047
+ roundRupees(shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee));
1747
2048
  if (summary.taxableAmount > 0 || summary.exemptAmount > 0)
1748
2049
  phonepeGst = summary;
1749
2050
  }
1750
2051
  /* Product GST is not a term — prices are tax-inclusive, so it is already
1751
2052
  * inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
1752
2053
  * stays because that is GST on our fee, charged on top. */
1753
- const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst;
2054
+ // D42: rounded once on the sum — see the COD/UPI path's 🛑 for why a raw
2055
+ // float sum of seven 2dp terms reached Firestore as 1011.8000000000001.
2056
+ const orderTotal = roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst);
1754
2057
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
1755
2058
  // Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
1756
2059
  // the price rule (`isBundle ? item.price : product.price`) that — like the
@@ -1833,6 +2136,10 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1833
2136
  shippingAddress,
1834
2137
  notes,
1835
2138
  platformFee,
2139
+ // Persisted alongside the fee — see the COD/UPI path's note. Both paths
2140
+ // charged this to the buyer and recorded only the combined `gstAmount`,
2141
+ // leaving the refund path to re-derive it from live percentages (D32).
2142
+ platformFeeGst: platformFeeGst > 0 ? platformFeeGst : undefined,
1836
2143
  shippingFee: shippingFee > 0 ? shippingFee : undefined,
1837
2144
  giftWrapAddon: giftWrapFee > 0 ? true : undefined,
1838
2145
  giftWrapFee: giftWrapFee > 0 ? giftWrapFee : undefined,
@@ -1942,15 +2249,39 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
1942
2249
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.PAYMENT_FAILED);
1943
2250
  }
1944
2251
  }
1945
- // Idempotency: the buyer's /verify call and the async webhook can both
1946
- // race to confirm+place for the same PhonePe payment (see the module
1947
- // comment above). `.create()` throws on conflict, so whichever caller
1948
- // creates this claim doc first is the sole placer; the loser returns
1949
- // whatever the winner already placed, or a retryable "in progress" error
1950
- // if the winner genuinely hasn't finished yet.
2252
+ /*
2253
+ * Idempotency: the buyer's /verify call and the async webhook can both race
2254
+ * to confirm+place for the same payment (see the module comment above).
2255
+ * `.create()` throws on conflict, so whichever caller creates this claim doc
2256
+ * first is the sole placer; the loser returns whatever the winner already
2257
+ * placed.
2258
+ *
2259
+ * 🛑 THE CLAIM CARRIES A LEASE, AND THAT IS NOT A REFINEMENT — IT IS THE
2260
+ * WHOLE POINT. Measured in production 2026-10-21: `order_TqJvzY4gVjR2cc` /
2261
+ * `pay_TqJx88j8OB7dSJ`, ₹2,511.80 captured by UPI, no order, and a manual
2262
+ * refund from the Razorpay dashboard.
2263
+ *
2264
+ * The old version wrote `{ claimedAt }` and nothing else. Everything below
2265
+ * this block — ~400 lines, a dozen throw sites, `ADDRESS_REQUIRED` and the
2266
+ * amount guard among them — runs AFTER the claim is taken, and **nothing
2267
+ * released it on failure**. So one failed placement made the claim a
2268
+ * tombstone: every later attempt hit ALREADY_EXISTS, found zero orders, and
2269
+ * returned "still being confirmed — please refresh". Refreshing could never
2270
+ * work. The error told the buyer to do the one thing guaranteed to fail.
2271
+ *
2272
+ * A lease fixes it without a 400-line try/catch, and fixes strictly more: a
2273
+ * `catch` cannot run if the process is killed mid-placement, and an expired
2274
+ * lease is reclaimable either way. The loser now distinguishes three states
2275
+ * rather than two — placed, genuinely in flight, or abandoned.
2276
+ */
1951
2277
  const claimRef = getAdminDb().collection("gatewayOrderClaims").doc(merchantOrderId);
2278
+ const claimNow = new Date();
2279
+ const claimLease = {
2280
+ claimedAt: claimNow,
2281
+ leaseExpiresAt: new Date(claimNow.getTime() + GATEWAY_CLAIM_LEASE_MS),
2282
+ };
1952
2283
  try {
1953
- await claimRef.create({ claimedAt: new Date() });
2284
+ await claimRef.create(claimLease);
1954
2285
  }
1955
2286
  catch (claimErr) {
1956
2287
  void normalizeError(claimErr);
@@ -1962,7 +2293,45 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
1962
2293
  itemCount: existingOrders.length,
1963
2294
  };
1964
2295
  }
1965
- throw new ValidationError(ERROR_MESSAGES.CHECKOUT.ORDER_PLACEMENT_IN_PROGRESS);
2296
+ /*
2297
+ * No order, and the holder's lease has run out: the previous attempt died
2298
+ * or threw. Take the claim over rather than refusing forever.
2299
+ *
2300
+ * The takeover is a TRANSACTION, not a bare update, because two retries
2301
+ * can arrive together — and two placers for one payment is the duplicate
2302
+ * order this claim exists to prevent. Re-reading inside the transaction
2303
+ * means exactly one of them wins the takeover; the other sees a fresh
2304
+ * lease and backs off.
2305
+ */
2306
+ const tookOver = await getAdminDb()
2307
+ .runTransaction(async (tx) => {
2308
+ const snap = await tx.get(claimRef);
2309
+ const held = snap.data();
2310
+ const expiresAt = held?.leaseExpiresAt?.toDate?.();
2311
+ // A claim written before this field existed has no lease — treat it as
2312
+ // expired rather than immortal, which is the bug being fixed. Every
2313
+ // tombstone already in the collection is reclaimed by this line.
2314
+ if (expiresAt && expiresAt.getTime() > Date.now())
2315
+ return false;
2316
+ tx.update(claimRef, {
2317
+ claimedAt: new Date(),
2318
+ leaseExpiresAt: new Date(Date.now() + GATEWAY_CLAIM_LEASE_MS),
2319
+ // Incremented in JS rather than with `FieldValue.increment`: the
2320
+ // read is already inside this transaction, so it serialises
2321
+ // correctly — and `firebase-admin/firestore` is loaded lazily here
2322
+ // to keep it out of client graphs (Root Cause #24).
2323
+ takeoverCount: (held?.takeoverCount ?? 0) + 1,
2324
+ });
2325
+ return true;
2326
+ })
2327
+ .catch((txErr) => {
2328
+ void normalizeError(txErr);
2329
+ return false;
2330
+ });
2331
+ if (!tookOver) {
2332
+ throw new ValidationError(ERROR_MESSAGES.CHECKOUT.ORDER_PLACEMENT_IN_PROGRESS);
2333
+ }
2334
+ serverLogger.warn("gateway claim taken over from an expired lease", { merchantOrderId });
1966
2335
  }
1967
2336
  /*
1968
2337
  * THE authoritative check, for either gateway: re-fetch the order from the
@@ -2024,15 +2393,36 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2024
2393
  if (!cart.items || cart.items.length === 0) {
2025
2394
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
2026
2395
  }
2027
- // Same lane priority + locked-line revalidation the manual/COD path runs.
2028
- //
2029
- // 🛑 This path carries no selectedItemIds, so it settles the ACTIVE LANE —
2030
- // not the cart. It used to pass `cart.items` as its own "selected" set,
2031
- // which made assertCheckoutLane compare the whole cart against its own
2032
- // active lane and throw on every multi-lane cart. Everything downstream
2033
- // reads `settleItems` for the same reason: stock decrements, jurisdiction
2034
- // and the order rows must cover exactly what was charged for.
2035
- const settleItems = defaultCheckoutItems(cart.items);
2396
+ /*
2397
+ * Same lane priority + locked-line revalidation the manual/COD path runs.
2398
+ *
2399
+ * 🛑 D40 — THIS LINE USED TO IGNORE THE BUYER'S SELECTION, and the capture
2400
+ * route did not. `defaultCheckoutItems(cart.items)` is the active lane and
2401
+ * nothing else, while `/api/payment/create-order` filtered by
2402
+ * `cart.selectedItemIds` and fell back to the WHOLE CART across every lane.
2403
+ * Two divergences fell out of that:
2404
+ *
2405
+ * with a selection → charged for the selected items, orders for the whole
2406
+ * lane: goods and stock leaving without payment.
2407
+ * no selection → charged for EVERY lane, orders for one: the buyer
2408
+ * OVERPAYS, and multi-lane carts are the normal product
2409
+ * of the lane model (see `defaultCheckoutItems`).
2410
+ *
2411
+ * `resolveCheckoutItems` is now the one definition both paths call: active
2412
+ * lane first, then the selection as a filter within it. Everything
2413
+ * downstream still reads `settleItems`, because stock decrements,
2414
+ * jurisdiction checks and the order rows must cover exactly what was charged.
2415
+ */
2416
+ const settleItems = resolveCheckoutItems(cart.items, cart.selectedItemIds);
2417
+ if (settleItems.length === 0) {
2418
+ /*
2419
+ * An empty intersection is a refusal, not a fallback. It means the
2420
+ * selection names only off-lane items — so the buyer is looking at
2421
+ * something the server will not sell them, and widening to the whole lane
2422
+ * would charge them for lines they deliberately unticked.
2423
+ */
2424
+ throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
2425
+ }
2036
2426
  assertCheckoutLane(cart.items, settleItems);
2037
2427
  await assertLockedLinesStillValid(settleItems, uid);
2038
2428
  const isDigitalCartPp = cartIsDigitalOnly(settleItems);
@@ -2125,13 +2515,25 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2125
2515
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.INSUFFICIENT_STOCK);
2126
2516
  }
2127
2517
  {
2128
- // SB-UNI-5 2026-05-13 — bundle cart-lines use item.price (locked
2129
- // bundlePrice) instead of the representative member's product.price.
2130
- const cartSubtotalRs = productChecks.reduce((sum, { item, product }) => {
2131
- const isBundle = Boolean(item.bundleCategorySlug && item.bundleProductIds?.length);
2132
- const unit = isBundle ? item.price : product.price;
2133
- return sum + unit * item.quantity;
2134
- }, 0);
2518
+ /*
2519
+ * 🛑 D38 — this guard used to HAND-ROLL the price rule it is guarding:
2520
+ *
2521
+ * const isBundle = Boolean(item.bundleCategorySlug && item.bundleProductIds?.length);
2522
+ * const unit = isBundle ? item.price : product!.price;
2523
+ *
2524
+ * It reproduced the BUNDLE branch of `unitPriceFor` and omitted the
2525
+ * `lockedPrice` and `groupMembers` ones — which is precisely what that
2526
+ * function's docstring forbids, and the fifth copy of the rule found
2527
+ * across three sessions (Root Cause #75).
2528
+ *
2529
+ * The failure was the worst available shape. On an accepted ₹800 offer for
2530
+ * a ₹1,000 listing: the capture route takes ₹800 (it uses `lineTotalFor`),
2531
+ * this guard expects ₹1,000, `paid < expected − 1` fires, and the function
2532
+ * THROWS — after the money has already been taken, on a branch with no
2533
+ * refund. The buyer is charged and gets no order. That is the D64 incident
2534
+ * class, reached by a different door.
2535
+ */
2536
+ const cartSubtotalRs = productChecks.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
2135
2537
  // Must use the SAME helper /api/payment/create-order used to compute what
2136
2538
  // it charged. This block used to re-derive the fee inline from the raw
2137
2539
  // percentages, so the moment the cap landed it would have expected more
@@ -2143,7 +2545,45 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2143
2545
  // below will use to build each order's recorded totalPrice) — keeps this
2144
2546
  // floor check from passing an amount that's short by the shipping fee.
2145
2547
  const expectedShippingGroups = splitCartIntoOrderGroups(bucketedPaid.available);
2146
- const expectedShippingFees = await Promise.all(expectedShippingGroups.map((g) => resolveShippingCost(g.items[0].item.storeId)));
2548
+ /*
2549
+ * The coupon, which this guard also omitted — the mirror of D37, and the
2550
+ * two MUST move together. With the capture route now correctly deducting
2551
+ * the discount, a guard that does not would expect MORE than was collected
2552
+ * and would reject every legitimate coupon payment — turning an overcharge
2553
+ * into a post-capture throw, which is strictly worse.
2554
+ *
2555
+ * Grouped identically to the capture route (`splitCartIntoOrderGroups` over
2556
+ * the same available items), so the two compute the same number from the
2557
+ * same shape rather than agreeing by coincidence.
2558
+ */
2559
+ const { couponDiscount: expectedCouponDiscount } = computeCartCouponDiscount(cart.appliedCoupons ?? [], expectedShippingGroups.map((g) => ({
2560
+ storeId: g.items[0].item.storeId,
2561
+ lines: g.items.map(({ item, product }) => ({
2562
+ itemId: item.itemId,
2563
+ lineTotal: lineTotalFor(item, product),
2564
+ })),
2565
+ })));
2566
+ const expectedDiscountedSubtotal = Math.max(0, cartSubtotalRs - expectedCouponDiscount);
2567
+ const expectedShippingFees = await Promise.all(
2568
+ /*
2569
+ * 🛑 D44 — THE GUARD MUST PASS `goodsValue` TOO, AND IN THIS SAME
2570
+ * COMMIT. This is the D37/D38 sequencing trap, one defect over.
2571
+ *
2572
+ * The capture route and both order paths now quote with the group's real
2573
+ * goods value, so a store's free-above threshold can fire and shipping
2574
+ * can legitimately be ₹0. A guard still quoting with `goodsValue: 0`
2575
+ * would compute the UN-thresholded fee, expect more than was collected,
2576
+ * trip `paid < expected − 1`, and **throw after the money was taken** —
2577
+ * converting a fixed overcharge into a charged-with-no-order, which is
2578
+ * the D64 incident class and strictly worse than the bug being fixed.
2579
+ *
2580
+ * Verified by construction rather than by hope: the goods value here is
2581
+ * computed with `lineTotalFor` over the same `expectedShippingGroups`
2582
+ * the capture route groups, so the two cannot disagree about the base.
2583
+ */
2584
+ expectedShippingGroups.map((g) => resolveShippingCost(g.items[0].item.storeId, {
2585
+ goodsValue: g.items.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
2586
+ })));
2147
2587
  const expectedShippingFee = expectedShippingFees.reduce((sum, r) => sum + r.shippingFee, 0);
2148
2588
  // Add-ons are per store, so the expected total sums each group's own
2149
2589
  // selection — a single cart-wide figure would over- or under-count as soon
@@ -2185,11 +2625,44 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2185
2625
  * would make this guard demand ~₹580 more than the buyer owes on a
2186
2626
  * ₹5,547 cart — rejecting every correctly-charged order.
2187
2627
  */
2188
- const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee + expectedAddonFees + expectedShippingFee;
2628
+ const expectedPaymentAmountRs = expectedDiscountedSubtotal +
2629
+ expectedPlatformFee +
2630
+ expectedGstOnFee +
2631
+ expectedAddonFees +
2632
+ expectedShippingFee;
2189
2633
  // Reuses the order already fetched above to confirm COMPLETED — PhonePe's
2190
2634
  // own amount is in its wire-format smallest unit. // audit-money-units-ok: describes the PhonePe boundary conversion this line performs, not our storage convention
2191
2635
  const paidAmountRs = paiseToRupees(gatewayOrder.amount);
2192
- if (paidAmountRs < expectedPaymentAmountRs - 1) {
2636
+ /*
2637
+ * ── TWO-SIDED, and the second side is how D37 survived ─────────────────
2638
+ *
2639
+ * 🛑 This read `paidAmountRs < expectedPaymentAmountRs - 1` — a FLOOR
2640
+ * check only. An over-capture of any size passed silently, which is
2641
+ * exactly what let the missing coupon term go unnoticed: the gateway took
2642
+ * the undiscounted amount, this guard saw "paid ≥ expected" and waved it
2643
+ * through, and the order recorded the discounted figure. Nothing in our
2644
+ * data disagreed.
2645
+ *
2646
+ * A ceiling is now checked too. The tolerance stays asymmetric and that is
2647
+ * deliberate rather than sloppy:
2648
+ *
2649
+ * UNDER by > ₹1 → we were short-changed. Refuse.
2650
+ * OVER by > ₹1 → the BUYER was over-charged. Record loudly, and
2651
+ * PROCEED to place the order.
2652
+ *
2653
+ * Proceeding on an over-capture is the important choice. The alternative —
2654
+ * throwing — is what D38 did, and it leaves the buyer charged with no
2655
+ * order and no refund on that branch, which is the D64 incident class.
2656
+ * Placing the order means the buyer has what they paid for and the surplus
2657
+ * is a recorded, refundable discrepancy rather than a vanished payment.
2658
+ * Phase 4's reconciler reads `amount_mismatch` rows and opens a case; the
2659
+ * refund itself waits for Phase 1's idempotency key, because an automatic
2660
+ * refund on top of a non-idempotent refund path is a double-refund
2661
+ * generator.
2662
+ */
2663
+ const amountDelta = paidAmountRs - expectedPaymentAmountRs;
2664
+ const AMOUNT_TOLERANCE_RS = 1;
2665
+ if (amountDelta < -AMOUNT_TOLERANCE_RS) {
2193
2666
  serverLogger.warn(`Payment amount mismatch for user ${uid}: paid ₹${paidAmountRs}, expected ≥ ₹${expectedPaymentAmountRs}`);
2194
2667
  failedCheckoutRepository
2195
2668
  .logPayment(uid, "amount_mismatch", `Paid ₹${paidAmountRs}, expected ≥ ₹${expectedPaymentAmountRs}`, {
@@ -2201,6 +2674,18 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2201
2674
  .catch((err) => { void normalizeError(err); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: err instanceof Error ? err.message : String(err) }); });
2202
2675
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.PAYMENT_FAILED);
2203
2676
  }
2677
+ if (amountDelta > AMOUNT_TOLERANCE_RS) {
2678
+ serverLogger.error(`OVER-CAPTURE for user ${uid}: paid ₹${paidAmountRs}, expected ₹${expectedPaymentAmountRs} (surplus ₹${amountDelta.toFixed(2)}) — order will still be placed`, { merchantOrderId, expectedCouponDiscount });
2679
+ failedCheckoutRepository
2680
+ .logPayment(uid, "amount_mismatch", `OVER-CAPTURE: paid ₹${paidAmountRs}, expected ₹${expectedPaymentAmountRs}, surplus ₹${amountDelta.toFixed(2)} — refund owed`, {
2681
+ gatewayOrderId: gatewayOrder.gatewayOrderId,
2682
+ gatewayPaymentId: merchantOrderId,
2683
+ amountRs: paidAmountRs,
2684
+ addressId,
2685
+ })
2686
+ .catch((err) => { void normalizeError(err); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: err instanceof Error ? err.message : String(err) }); });
2687
+ // Deliberately NO throw — see above.
2688
+ }
2204
2689
  }
2205
2690
  const { coupons: appliedCoupons, dropped: droppedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], bucketedPaid.available, uid);
2206
2691
  // "skip_items" (or an all-available cart under any policy) — build order