@mohasinac/appkit 4.62.0 → 4.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (391) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +8 -29
  3. package/dist/_internal/client/features/layout/filterNavItems.d.ts +28 -0
  4. package/dist/_internal/client/features/layout/filterNavItems.js +35 -0
  5. package/dist/_internal/server/features/ads/booking.d.ts +118 -0
  6. package/dist/_internal/server/features/ads/booking.js +326 -0
  7. package/dist/_internal/server/features/checkout/actions.d.ts +129 -9
  8. package/dist/_internal/server/features/checkout/actions.js +850 -79
  9. package/dist/_internal/server/features/checkout/expand-order-items.d.ts +1 -2
  10. package/dist/_internal/server/features/checkout/expand-order-items.js +32 -0
  11. package/dist/_internal/server/features/checkout/freeze-parts.d.ts +50 -0
  12. package/dist/_internal/server/features/checkout/freeze-parts.js +94 -0
  13. package/dist/_internal/server/features/checkout/value-otp-gate.d.ts +55 -0
  14. package/dist/_internal/server/features/checkout/value-otp-gate.js +60 -0
  15. package/dist/_internal/server/features/dashboard/attention.d.ts +128 -0
  16. package/dist/_internal/server/features/dashboard/attention.js +183 -0
  17. package/dist/_internal/server/features/dashboard/data.d.ts +47 -0
  18. package/dist/_internal/server/features/dashboard/data.js +82 -0
  19. package/dist/_internal/server/features/inventory/reservations.d.ts +99 -0
  20. package/dist/_internal/server/features/inventory/reservations.js +273 -0
  21. package/dist/_internal/server/features/invoicing/allocate.d.ts +63 -0
  22. package/dist/_internal/server/features/invoicing/allocate.js +162 -0
  23. package/dist/_internal/server/features/invoicing/credit-note.d.ts +57 -0
  24. package/dist/_internal/server/features/invoicing/credit-note.js +126 -0
  25. package/dist/_internal/server/features/invoicing/gstr1.d.ts +74 -0
  26. package/dist/_internal/server/features/invoicing/gstr1.js +251 -0
  27. package/dist/_internal/server/features/invoicing/issue.d.ts +96 -0
  28. package/dist/_internal/server/features/invoicing/issue.js +202 -0
  29. package/dist/_internal/server/features/offers/destinations.d.ts +40 -0
  30. package/dist/_internal/server/features/offers/destinations.js +65 -0
  31. package/dist/_internal/server/features/orders/actions.d.ts +8 -1
  32. package/dist/_internal/server/features/orders/actions.js +81 -8
  33. package/dist/_internal/server/features/orders/adapters.js +54 -0
  34. package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
  35. package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
  36. package/dist/_internal/server/features/orders/balance.d.ts +89 -0
  37. package/dist/_internal/server/features/orders/balance.js +256 -0
  38. package/dist/_internal/server/features/orders/cancellation-refund.d.ts +15 -9
  39. package/dist/_internal/server/features/orders/cancellation-refund.js +15 -9
  40. package/dist/_internal/server/features/points/award.js +46 -4
  41. package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
  42. package/dist/_internal/server/features/pricing/actions.js +144 -0
  43. package/dist/_internal/server/features/refunds/actions.js +26 -0
  44. package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
  45. package/dist/_internal/server/features/shipping/book-split.js +130 -0
  46. package/dist/_internal/server/features/shipping/book.d.ts +61 -0
  47. package/dist/_internal/server/features/shipping/book.js +176 -0
  48. package/dist/_internal/server/features/shipping/courier-block.d.ts +69 -0
  49. package/dist/_internal/server/features/shipping/courier-block.js +264 -0
  50. package/dist/_internal/server/features/site-settings/adapters.d.ts +2 -2
  51. package/dist/_internal/server/features/site-settings/adapters.js +29 -0
  52. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  53. package/dist/_internal/server/features/stores/adapters.js +65 -0
  54. package/dist/_internal/server/functions/scheduled.d.ts +2 -1
  55. package/dist/_internal/server/functions/scheduled.js +95 -2
  56. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +11 -2
  57. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +4 -0
  58. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +19 -2
  59. package/dist/_internal/server/jobs/core/index.d.ts +2 -1
  60. package/dist/_internal/server/jobs/core/index.js +2 -1
  61. package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
  62. package/dist/_internal/server/jobs/core/onJobCreated.d.ts +30 -7
  63. package/dist/_internal/server/jobs/core/onJobCreated.js +78 -45
  64. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +7 -0
  65. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +65 -0
  66. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +33 -0
  67. package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
  68. package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
  69. package/dist/_internal/server/jobs/core/runJobOnce.d.ts +48 -0
  70. package/dist/_internal/server/jobs/core/runJobOnce.js +195 -0
  71. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +25 -2
  72. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  73. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  74. package/dist/_internal/server/jobs/handlers/onOrderStatusChange.js +17 -0
  75. package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
  76. package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
  77. package/dist/_internal/server/notifications/channel-health.d.ts +26 -3
  78. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  79. package/dist/_internal/shared/actions/action-registry.js +296 -0
  80. package/dist/_internal/shared/checkout/coupon-discount.d.ts +43 -1
  81. package/dist/_internal/shared/checkout/coupon-discount.js +26 -1
  82. package/dist/_internal/shared/checkout/order-math.d.ts +174 -0
  83. package/dist/_internal/shared/checkout/order-math.js +159 -0
  84. package/dist/_internal/shared/checkout/refundable-base.d.ts +25 -0
  85. package/dist/_internal/shared/checkout/refundable-base.js +13 -1
  86. package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
  87. package/dist/_internal/shared/checkout/rules/index.js +1 -1
  88. package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
  89. package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
  90. package/dist/_internal/shared/checkout/split.d.ts +75 -0
  91. package/dist/_internal/shared/checkout/split.js +120 -0
  92. package/dist/_internal/shared/content/disclaimers.d.ts +88 -0
  93. package/dist/_internal/shared/content/disclaimers.js +217 -0
  94. package/dist/_internal/shared/content/published-values.d.ts +93 -0
  95. package/dist/_internal/shared/content/published-values.js +206 -0
  96. package/dist/_internal/shared/features/admin/integrations.d.ts +139 -0
  97. package/dist/_internal/shared/features/admin/integrations.js +263 -0
  98. package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
  99. package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
  100. package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
  101. package/dist/_internal/shared/features/emi/choice.js +69 -0
  102. package/dist/_internal/shared/features/emi/group.d.ts +84 -0
  103. package/dist/_internal/shared/features/emi/group.js +126 -0
  104. package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
  105. package/dist/_internal/shared/features/ledger/posting.js +165 -0
  106. package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
  107. package/dist/_internal/shared/features/ledger/reports.js +127 -0
  108. package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
  109. package/dist/_internal/shared/features/ledger/rules.js +221 -0
  110. package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
  111. package/dist/_internal/shared/features/offers/coordination.js +80 -0
  112. package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
  113. package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
  114. package/dist/_internal/shared/features/orders/dispute-reasons.d.ts +113 -0
  115. package/dist/_internal/shared/features/orders/dispute-reasons.js +181 -0
  116. package/dist/_internal/shared/features/orders/refund-copy.d.ts +1 -1
  117. package/dist/_internal/shared/features/orders/refund-copy.js +18 -1
  118. package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
  119. package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
  120. package/dist/_internal/shared/features/points/accepts.d.ts +29 -0
  121. package/dist/_internal/shared/features/points/accepts.js +49 -0
  122. package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
  123. package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
  124. package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
  125. package/dist/_internal/shared/features/shipping/parcel.js +139 -0
  126. package/dist/_internal/shared/features/shipping/shared-purchase.d.ts +91 -0
  127. package/dist/_internal/shared/features/shipping/shared-purchase.js +108 -0
  128. package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
  129. package/dist/_internal/shared/features/site-settings/fees.js +12 -8
  130. package/dist/_internal/shared/features/site-settings/types.d.ts +11 -0
  131. package/dist/_internal/shared/fees/calculator.d.ts +33 -1
  132. package/dist/_internal/shared/fees/calculator.js +32 -2
  133. package/dist/_internal/shared/fees/explain.d.ts +81 -0
  134. package/dist/_internal/shared/fees/explain.js +101 -0
  135. package/dist/_internal/shared/history/index.d.ts +1 -1
  136. package/dist/_internal/shared/history/index.js +1 -1
  137. package/dist/_internal/shared/history/types.d.ts +7 -1
  138. package/dist/_internal/shared/history/types.js +25 -0
  139. package/dist/client.d.ts +21 -0
  140. package/dist/client.js +43 -0
  141. package/dist/constants/api-endpoints.d.ts +114 -0
  142. package/dist/constants/api-endpoints.js +55 -0
  143. package/dist/constants/field-names.d.ts +42 -0
  144. package/dist/constants/field-names.js +52 -0
  145. package/dist/contracts/index.d.ts +1 -1
  146. package/dist/contracts/shipping.d.ts +45 -0
  147. package/dist/contracts/shipping.js +15 -0
  148. package/dist/features/about/components/AboutView.js +1 -1
  149. package/dist/features/account/components/BuyerAuctionsGuideView.js +4 -1
  150. package/dist/features/account/components/UserOrdersView.js +47 -35
  151. package/dist/features/admin/components/AdminCouponEditorView.js +17 -0
  152. package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
  153. package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
  154. package/dist/features/admin/components/AdminInvoicingView.d.ts +2 -0
  155. package/dist/features/admin/components/AdminInvoicingView.js +65 -0
  156. package/dist/features/admin/components/AdminOrderEditorView.d.ts +33 -1
  157. package/dist/features/admin/components/AdminOrderEditorView.js +81 -3
  158. package/dist/features/admin/components/AdminOrdersGuideView.js +23 -2
  159. package/dist/features/admin/components/AdminOrdersView.js +86 -5
  160. package/dist/features/admin/components/OwnerQuickActions.js +24 -2
  161. package/dist/features/admin/constants/filter-tabs.d.ts +6 -0
  162. package/dist/features/admin/constants/filter-tabs.js +6 -0
  163. package/dist/features/admin/constants/site-settings-tabs.d.ts +1 -1
  164. package/dist/features/admin/hooks/useAdminListingData.js +16 -0
  165. package/dist/features/admin/schemas/admin-user-form.d.ts +2 -2
  166. package/dist/features/admin/schemas/firestore.d.ts +62 -0
  167. package/dist/features/admin/schemas/firestore.js +5 -0
  168. package/dist/features/ads/schemas/booking-write.d.ts +120 -0
  169. package/dist/features/ads/schemas/booking-write.js +75 -0
  170. package/dist/features/ads/schemas/creative-form.d.ts +18 -0
  171. package/dist/features/ads/schemas/creative-form.js +51 -0
  172. package/dist/features/ads/schemas/firestore.d.ts +140 -0
  173. package/dist/features/ads/schemas/firestore.js +109 -0
  174. package/dist/features/auctions/repository/bid.repository.js +3 -1
  175. package/dist/features/audit-log/schemas/firestore.d.ts +140 -0
  176. package/dist/features/audit-log/schemas/firestore.js +89 -0
  177. package/dist/features/auth/actions/profile-actions.d.ts +69 -1
  178. package/dist/features/auth/actions/profile-actions.js +62 -1
  179. package/dist/features/auth/permissions/constants.js +13 -0
  180. package/dist/features/auth/schemas/firestore.d.ts +16 -2
  181. package/dist/features/auth/schemas/firestore.js +2 -1
  182. package/dist/features/blog/actions/blog-actions.d.ts +6 -0
  183. package/dist/features/blog/actions/blog-actions.js +17 -2
  184. package/dist/features/blog/api/route.js +4 -1
  185. package/dist/features/blog/columns/index.js +5 -0
  186. package/dist/features/blog/components/BlogIndexPageView.js +2 -1
  187. package/dist/features/blog/repository/blog.repository.d.ts +4 -0
  188. package/dist/features/blog/repository/blog.repository.js +5 -0
  189. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  190. package/dist/features/blog/schemas/blog-form.js +1 -0
  191. package/dist/features/blog/schemas/firestore.d.ts +38 -1
  192. package/dist/features/blog/schemas/firestore.js +23 -0
  193. package/dist/features/blog/schemas/index.d.ts +6 -0
  194. package/dist/features/blog/schemas/index.js +2 -0
  195. package/dist/features/blog/types/index.d.ts +2 -0
  196. package/dist/features/cart/components/CartPriceBreakdown.d.ts +25 -1
  197. package/dist/features/cart/components/CartPriceBreakdown.js +29 -6
  198. package/dist/features/cart/components/PointsTenderPicker.d.ts +61 -0
  199. package/dist/features/cart/components/PointsTenderPicker.js +15 -0
  200. package/dist/features/cart/components/StoreAddonsPicker.js +2 -1
  201. package/dist/features/cart/components/index.d.ts +2 -0
  202. package/dist/features/cart/components/index.js +1 -0
  203. package/dist/features/cart/repository/cart.repository.d.ts +33 -0
  204. package/dist/features/cart/repository/cart.repository.js +47 -0
  205. package/dist/features/cart/schemas/firestore.d.ts +116 -0
  206. package/dist/features/contact/email.d.ts +27 -10
  207. package/dist/features/contact/email.js +20 -3
  208. package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
  209. package/dist/features/dashboard/components/DashboardHubView.js +56 -0
  210. package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
  211. package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
  212. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
  213. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
  214. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +62 -0
  215. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +108 -0
  216. package/dist/features/dispute-reasons/schemas/firestore.d.ts +62 -0
  217. package/dist/features/dispute-reasons/schemas/firestore.js +40 -0
  218. package/dist/features/events/components/EventSaleBanner.js +1 -1
  219. package/dist/features/faq/components/FAQAccordion.d.ts +13 -1
  220. package/dist/features/faq/components/FAQAccordion.js +29 -4
  221. package/dist/features/faq/components/FAQPageContent.js +2 -2
  222. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  223. package/dist/features/homepage/schemas/index.d.ts +8 -8
  224. package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
  225. package/dist/features/inventory/schemas/stock-reservation.js +103 -0
  226. package/dist/features/invoicing/schemas/firestore.d.ts +112 -0
  227. package/dist/features/invoicing/schemas/firestore.js +45 -0
  228. package/dist/features/invoicing/utils/financial-year.d.ts +43 -0
  229. package/dist/features/invoicing/utils/financial-year.js +53 -0
  230. package/dist/features/jobs/actions/enqueue-job.js +18 -1
  231. package/dist/features/jobs/repository/jobs.repository.d.ts +62 -1
  232. package/dist/features/jobs/repository/jobs.repository.js +84 -1
  233. package/dist/features/jobs/schemas/firestore.d.ts +80 -0
  234. package/dist/features/jobs/schemas/firestore.js +37 -0
  235. package/dist/features/layout/AppLayoutShell.d.ts +9 -1
  236. package/dist/features/layout/AppLayoutShell.js +12 -4
  237. package/dist/features/layout/BottomActions.js +2 -1
  238. package/dist/features/layout/BottomNavbar.d.ts +10 -2
  239. package/dist/features/layout/SideActionDock.js +52 -2
  240. package/dist/features/layout/TitleBarLayout.js +13 -4
  241. package/dist/features/layout/TitleBarLayout.style.css +59 -0
  242. package/dist/features/layout/useCtaReveal.d.ts +16 -1
  243. package/dist/features/layout/useCtaReveal.js +17 -26
  244. package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
  245. package/dist/features/ledger/repository/journal.repository.js +312 -0
  246. package/dist/features/ledger/schemas/expenses.d.ts +87 -0
  247. package/dist/features/ledger/schemas/expenses.js +27 -0
  248. package/dist/features/ledger/schemas/firestore.d.ts +183 -0
  249. package/dist/features/ledger/schemas/firestore.js +69 -0
  250. package/dist/features/lottery/schemas/zod.d.ts +6 -6
  251. package/dist/features/media/MediaImage.js +22 -1
  252. package/dist/features/media/upload/ImageUpload.js +1 -1
  253. package/dist/features/obligations/constants/policies.js +24 -1
  254. package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
  255. package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
  256. package/dist/features/orders/components/OrderPaymentSummary.js +10 -1
  257. package/dist/features/orders/components/OrderProvenanceCard.d.ts +8 -0
  258. package/dist/features/orders/components/OrderProvenanceCard.js +185 -0
  259. package/dist/features/orders/components/RaiseDisputeForm.d.ts +14 -0
  260. package/dist/features/orders/components/RaiseDisputeForm.js +119 -0
  261. package/dist/features/orders/components/RefundRequestView.js +19 -1
  262. package/dist/features/orders/components/index.d.ts +4 -0
  263. package/dist/features/orders/components/index.js +2 -0
  264. package/dist/features/orders/constants/order-lane-tabs.d.ts +69 -0
  265. package/dist/features/orders/constants/order-lane-tabs.js +88 -0
  266. package/dist/features/orders/repository/orders.repository.d.ts +85 -1
  267. package/dist/features/orders/repository/orders.repository.js +205 -6
  268. package/dist/features/orders/schemas/balance.d.ts +40 -0
  269. package/dist/features/orders/schemas/balance.js +47 -0
  270. package/dist/features/orders/schemas/firestore.d.ts +398 -1
  271. package/dist/features/orders/schemas/firestore.js +43 -0
  272. package/dist/features/orders/schemas/index.d.ts +56 -8
  273. package/dist/features/orders/schemas/index.js +12 -0
  274. package/dist/features/orders/types/index.d.ts +47 -0
  275. package/dist/features/orders/utils/courier-block.d.ts +25 -0
  276. package/dist/features/orders/utils/courier-block.js +23 -0
  277. package/dist/features/payment-cases/schemas/firestore.d.ts +26 -1
  278. package/dist/features/payment-cases/schemas/firestore.js +1 -0
  279. package/dist/features/payments/repository/payout.repository.js +3 -2
  280. package/dist/features/payments/schemas/firestore.d.ts +92 -1
  281. package/dist/features/payments/schemas/firestore.js +23 -0
  282. package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
  283. package/dist/features/pricing/components/AdminPricingView.js +157 -0
  284. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -2
  285. package/dist/features/products/components/ProductDetailPageView.d.ts +17 -1
  286. package/dist/features/products/components/ProductDetailPageView.js +2 -2
  287. package/dist/features/products/schemas/firestore.d.ts +26 -1
  288. package/dist/features/products/schemas/firestore.js +2 -0
  289. package/dist/features/products/schemas/index.d.ts +2 -2
  290. package/dist/features/products/schemas/product-features.validators.d.ts +10 -10
  291. package/dist/features/promotions/actions/coupon-stacking.d.ts +30 -3
  292. package/dist/features/promotions/actions/coupon-stacking.js +21 -2
  293. package/dist/features/promotions/components/CouponCard.js +13 -0
  294. package/dist/features/promotions/constants/coupon-help.d.ts +2 -2
  295. package/dist/features/promotions/constants/coupon-help.js +9 -1
  296. package/dist/features/promotions/schemas/firestore.d.ts +24 -0
  297. package/dist/features/promotions/schemas/firestore.js +4 -0
  298. package/dist/features/promotions/types/index.d.ts +19 -1
  299. package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
  300. package/dist/features/reviews/repository/reviews.repository.js +16 -0
  301. package/dist/features/scams/components/ScamProfileView.js +2 -2
  302. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  303. package/dist/features/scams/repository/scammer.repository.js +2 -1
  304. package/dist/features/scams/schemas/firestore.d.ts +2 -1
  305. package/dist/features/scams/schemas/firestore.js +2 -1
  306. package/dist/features/search/action-index/tab-entries.js +1 -1
  307. package/dist/features/seller/actions/offer-actions.js +56 -0
  308. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  309. package/dist/features/seller/actions/seller-actions.js +37 -3
  310. package/dist/features/seller/components/DispatchForm.d.ts +11 -0
  311. package/dist/features/seller/components/DispatchForm.js +190 -0
  312. package/dist/features/seller/components/SellerOrdersView.js +221 -27
  313. package/dist/features/seller/components/index.d.ts +1 -0
  314. package/dist/features/seller/components/index.js +1 -0
  315. package/dist/features/seller/hooks/useSellerListingData.js +25 -0
  316. package/dist/features/seller/repository/offer.repository.js +4 -3
  317. package/dist/features/seller/schemas/firestore.d.ts +23 -2
  318. package/dist/features/seller/schemas/firestore.js +3 -2
  319. package/dist/features/seller/schemas/index.d.ts +4 -4
  320. package/dist/features/seller/schemas/order-forms.d.ts +61 -4
  321. package/dist/features/seller/schemas/order-forms.js +41 -8
  322. package/dist/features/shipments/schemas/firestore.d.ts +25 -0
  323. package/dist/features/shipments/schemas/validation.d.ts +4 -4
  324. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +82 -0
  325. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +145 -0
  326. package/dist/features/shipping-boxes/schemas/firestore.d.ts +80 -0
  327. package/dist/features/shipping-boxes/schemas/firestore.js +53 -0
  328. package/dist/features/store-extensions/schemas/report-create-form.d.ts +2 -2
  329. package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +2 -2
  330. package/dist/features/stores/components/StoreOrdersGuideView.js +6 -1
  331. package/dist/features/stores/repository/store.repository.d.ts +18 -0
  332. package/dist/features/stores/repository/store.repository.js +27 -1
  333. package/dist/features/stores/schemas/firestore.d.ts +112 -0
  334. package/dist/features/stores/schemas/firestore.js +20 -0
  335. package/dist/features/stores/schemas/index.d.ts +6 -6
  336. package/dist/features/stores/types/index.d.ts +19 -0
  337. package/dist/features/support/repository/support.repository.js +72 -6
  338. package/dist/features/support/schemas/firestore.d.ts +17 -0
  339. package/dist/features/support/schemas/index.d.ts +8 -8
  340. package/dist/features/wishlist/hooks/useWishlistWithGuest.d.ts +1 -1
  341. package/dist/index.d.ts +11 -1
  342. package/dist/index.js +33 -1
  343. package/dist/next/routing/route-map.d.ts +112 -0
  344. package/dist/next/routing/route-map.js +63 -0
  345. package/dist/providers/shipping-shiprocket/index.d.ts +27 -1
  346. package/dist/providers/shipping-shiprocket/index.js +84 -1
  347. package/dist/react/contexts/SessionContext.d.ts +17 -1
  348. package/dist/schemas/registry.d.ts +69 -34
  349. package/dist/schemas/sieve.d.ts +2 -2
  350. package/dist/security/pii-schemas.d.ts +1 -1
  351. package/dist/security/pii-schemas.js +45 -0
  352. package/dist/server-entry.d.ts +2 -2
  353. package/dist/server-entry.js +8 -1
  354. package/dist/server.d.ts +36 -1
  355. package/dist/server.js +183 -1
  356. package/dist/styles.css +412 -80
  357. package/dist/tailwind-utilities.css +1 -1
  358. package/dist/tokens/themes/default-dark.js +1 -0
  359. package/dist/tokens/themes/default-light.js +1 -0
  360. package/dist/tokens/themes/required.d.ts +1 -1
  361. package/dist/tokens/themes/required.js +1 -0
  362. package/dist/tokens/tokens.css +15 -0
  363. package/dist/ui/components/Button.d.ts +10 -1
  364. package/dist/ui/components/Button.js +25 -3
  365. package/dist/ui/components/Chart.d.ts +63 -0
  366. package/dist/ui/components/Chart.js +169 -0
  367. package/dist/ui/components/Chart.style.css +214 -0
  368. package/dist/ui/components/Disclaimer.d.ts +19 -0
  369. package/dist/ui/components/Disclaimer.js +83 -0
  370. package/dist/ui/components/OverflowRow.js +54 -2
  371. package/dist/ui/components/OverflowRow.style.css +38 -0
  372. package/dist/ui/components/RichTextEditor.js +99 -8
  373. package/dist/ui/components/chart-spec.d.ts +68 -0
  374. package/dist/ui/components/chart-spec.js +140 -0
  375. package/dist/ui/components/index.style.css +1 -0
  376. package/dist/ui/components/surface-tokens.d.ts +1 -1
  377. package/dist/ui/components/surface-tokens.js +9 -0
  378. package/dist/ui/components/tabs-overflow.js +64 -11
  379. package/dist/ui/components/toolbar-collapse.d.ts +13 -0
  380. package/dist/ui/components/toolbar-collapse.js +10 -1
  381. package/dist/ui/index.d.ts +8 -0
  382. package/dist/ui/index.js +9 -0
  383. package/dist/ui/rich-text/RichText.d.ts +10 -1
  384. package/dist/ui/rich-text/RichText.js +33 -2
  385. package/dist/ui/rich-text/RichText.style.css +83 -79
  386. package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
  387. package/dist/ui/rich-text/RichTextRenderer.js +34 -2
  388. package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
  389. package/dist/ui/rich-text/chart-blocks.js +87 -0
  390. package/package.json +1 -1
  391. package/scripts/seed-cli.mjs +1834 -1822
@@ -16,7 +16,18 @@ import { roundShippingUp, } from "../../../shared/shipping/quote.js";
16
16
  import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories/index.js";
17
17
  import { isIntraStateSupply } from "../../../shared/fees/calculator.js";
18
18
  import { resolveStateKey, INDIA_STATES } from "../../../shared/fees/india-states.js";
19
- import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
19
+ import { allocatePointsAcrossGroups, pointsBaseForGroup, computePreOrderDepositAmount, lineTotalFor, resolvePointsTender, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
20
+ /*
21
+ * D46/D47 — points as a checkout TENDER.
22
+ *
23
+ * `storeAcceptsPoints` resolves decision 22's per-store opt-in (and the
24
+ * platform store's always-accepts, which is decided in code); the repository
25
+ * supplies the balance and owns the ledger write. The ARITHMETIC lives in
26
+ * `resolvePointsTender` above, beside every other money rule, so the tender
27
+ * cannot become a sixth disagreeing total (D37/D38/D40).
28
+ */
29
+ import { storeAcceptsPoints } from "../../../shared/features/points/accepts.js";
30
+ import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
20
31
  import { COD_MAX_ORDER_VALUE_FALLBACK } from "../../../../features/admin/schemas/firestore.js";
21
32
  import { CHECKOUT_ERROR_CODES } from "../../../../errors/error-codes.js";
22
33
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository.js";
@@ -31,16 +42,21 @@ import { DEFAULT_STORE_SHIPPING_POLICY, quoteShipping, toStoreShippingPolicy, }
31
42
  */
32
43
  import { paymentCasesRepository } from "../../../../features/payment-cases/repository/payment-cases.repository.js";
33
44
  import { computeProtectionFees } from "../../../shared/features/shipping/protections.js";
45
+ /* S5a — one payer, N destinations. Pure: the rates come in snapshotted, and
46
+ * an UNPRICED arrangement is refused rather than quoted at ₹0 (D44's lesson). */
47
+ import { countDestinations, quoteSharedPurchase, } from "../../../shared/features/shipping/shared-purchase.js";
34
48
  import { resolveCouponCategorySlugs } from "../../../../features/promotions/actions/coupon-actions.js";
35
49
  import { assertCheckoutLane, assertLockedLinesStillValid, finalizeLockedLines, } from "./locked-lines.js";
36
50
  import { activeLane, defaultCheckoutItems, resolveCheckoutItems, laneOf, } from "../../../shared/checkout/lanes.js";
37
51
  import { computeCodHandlingFee, computeGiftWrapFee, computeShipmentProtectionFee, computeSaleProtectionFee, computeCheckoutFees, allocateCheckoutFees, } from "../../../shared/fees/calculator.js";
38
52
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase/index.js";
53
+ import { snapshotDeliveryAddress, } from "../../../shared/features/orders/delivery-snapshot.js";
39
54
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore.js";
40
55
  import { CART_COLLECTION } from "../../../../features/cart/schemas/index.js";
41
56
  // S-SBUNI-RULES 2026-05-13 — bundle cart-line stock fan-out helpers.
42
57
  import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, isMultiMemberLine, } from "./bundle-expansion.js";
43
58
  import { expandCartLineToOrderRows } from "./expand-order-items.js";
59
+ import { freezePartsOnItems } from "./freeze-parts.js";
44
60
  import { OrderStatusValues, PaymentStatusValues, PaymentMethodValues, OutOfStockPolicyValues, } from "../../../../features/orders/schemas/index.js";
45
61
  import { getDefaultCurrency } from "../../../../core/index.js";
46
62
  import { getProviders } from "../../../../contracts/index.js";
@@ -53,10 +69,11 @@ import { verifyPaymentSignature as verifyRazorpayPaymentSignature } from "../../
53
69
  import { resolveKeys } from "../../../../core/integration-keys.js";
54
70
  import { paiseToRupees } from "../../../../core/money.js";
55
71
  import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config.js";
56
- import { checkEmiEligibility, computeBuyerEmiQuote, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
72
+ import { emiTenderFor, resolveEmiChoice } from "../../../shared/features/emi/choice.js";
73
+ import { amountDueNow, emiOrderFields, groupEmiGate, groupEmiQuote, groupEmiSchedule, } from "../../../shared/features/emi/group.js";
57
74
  import { formatShippingAddress } from "./data.js";
58
75
  import { enforceMaxPerUserForCart } from "./prize-bundle-gates.js";
59
- import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, } from "../../../shared/checkout/rules/index.js";
76
+ import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, assertEmiOptInAllowed, } from "../../../shared/checkout/rules/index.js";
60
77
  // The `GroupCouponDiscountResult` type is deliberately NOT imported: every
61
78
  // call site here destructures the result inline, so naming the type would be
62
79
  // an unused import.
@@ -68,7 +85,7 @@ import { sendNotification } from "../../../../features/admin/actions/notificatio
68
85
  import { resolveDisplayedUpiId } from "./upi-resolution.js";
69
86
  import { resolveSupplierIdentity } from "./supplier-identity.js";
70
87
  import { PAYMENT_WINDOW_MS } from "../../../../features/orders/constants/payment-window.js";
71
- import { isCheckoutValueOtpVerified } from "../../../../features/checkout/actions/checkout-value-otp-actions.js";
88
+ import { assertCheckoutValueOtp } from "./value-otp-gate.js";
72
89
  const AUDIT_LOG_FAIL_MSG = "checkout: audit log failed (non-critical)";
73
90
  /**
74
91
  * How long a `gatewayOrderClaims` holder owns the right to place an order
@@ -262,6 +279,10 @@ opts) {
262
279
  shippingPolicy: DEFAULT_STORE_SHIPPING_POLICY,
263
280
  shippingUnavailable: false,
264
281
  storeProtections: { shipping: false, sale: false },
282
+ // No store, so no seller to absorb a redemption.
283
+ acceptsPoints: false,
284
+ // No store, so nobody to pack N parcels.
285
+ sharedPurchaseRates: { allowSharedPurchase: false },
265
286
  // No store, so no seller registration — the caller falls back to the
266
287
  // platform's via resolveSupplierIdentity.
267
288
  storeTaxIdentity: undefined,
@@ -381,6 +402,13 @@ opts) {
381
402
  shipping: store?.supportsShippingProtection === true,
382
403
  sale: store?.supportsSaleProtection === true,
383
404
  },
405
+ acceptsPoints: storeAcceptsPoints(store, storeId),
406
+ // S5a — raw, so the caller can tell "not configured" from ₹0.
407
+ sharedPurchaseRates: {
408
+ allowSharedPurchase: store?.allowSharedPurchase === true,
409
+ sharedPurchaseHandlingFee: store?.sharedPurchaseHandlingFee,
410
+ sharedPurchaseExtraShippingPerDestination: store?.sharedPurchaseExtraShippingPerDestination,
411
+ },
384
412
  /*
385
413
  * The seller's own tax registration, returned from the store read this
386
414
  * function ALREADY does.
@@ -594,6 +622,112 @@ function dispatchOrderConfirmationEmails(emailsToSend) {
594
622
  return;
595
623
  Promise.all(emailsToSend.map((e) => sendOrderConfirmationEmail(e))).catch((err) => serverLogger.error("Order confirmation email error:", err));
596
624
  }
625
+ /**
626
+ * The buyer's points balance, or **0** if it cannot be read.
627
+ *
628
+ * 🛑 Failing OPEN to zero is the safe direction and the only safe direction: a
629
+ * balance we could not read becomes "no discount applied", so the buyer is
630
+ * charged the full amount and their points are untouched. The inverse — guessing
631
+ * a balance — would reduce the charge against points that may not exist, which
632
+ * is the shape of the deleted D46 block (it debited points and reduced nothing;
633
+ * this is the same mistake with the signs swapped).
634
+ *
635
+ * A buyer who opted in and sees no discount can retry. There is no recoverable
636
+ * outcome on the other side.
637
+ */
638
+ async function safePointsBalance(userId) {
639
+ try {
640
+ return await pointsRepository.getBalance(userId);
641
+ }
642
+ catch (err) {
643
+ const normalized = normalizeError(err);
644
+ serverLogger.warn("[checkout] points balance unreadable — charging full amount", {
645
+ userId,
646
+ error: normalized.message,
647
+ });
648
+ return 0;
649
+ }
650
+ }
651
+ /**
652
+ * Debit the points an order was discounted by — the second half of the tender.
653
+ *
654
+ * 🛑 ONE implementation, called from BOTH order-creation paths. They are
655
+ * ~200-line twins that have already drifted once (add-on fees, Root Cause
656
+ * #65), and a money rule reachable by copy-paste gets copied (Root Cause #75).
657
+ *
658
+ * Runs AFTER the order exists and BEFORE the gateway is asked for anything.
659
+ * That ordering is the whole D46 fix: the deleted block ran after capture, so a
660
+ * redemption that succeeded took points from a buyer who had just paid full
661
+ * price while its own comment claimed "the balance intact".
662
+ *
663
+ * 🛑 A REFUSED REDEEM REVERTS THE DISCOUNT; it is never swallowed. `redeem`
664
+ * re-reads the balance inside its own transaction and refuses rather than going
665
+ * negative — so a refusal means the budget was wrong and the order is recording
666
+ * a discount nobody funded. Reverting is the only honest response: the buyer
667
+ * pays full price, which they can see and query, where an unfunded discount is
668
+ * a silent under-collection on every order that hits it.
669
+ *
670
+ * `orderId` is the natural idempotency key — this runs once per created order,
671
+ * and an order is created once.
672
+ */
673
+ async function settlePointsTender(input) {
674
+ if (input.pointsApplied <= 0)
675
+ return;
676
+ try {
677
+ await pointsRepository.redeem({
678
+ userId: input.userId,
679
+ points: input.pointsApplied,
680
+ orderId: input.orderId,
681
+ description: `Points applied to order ${input.orderId}`,
682
+ });
683
+ }
684
+ catch (err) {
685
+ const normalized = normalizeError(err);
686
+ serverLogger.error("[checkout] points redemption refused — reverting the discount", {
687
+ orderId: input.orderId,
688
+ userId: input.userId,
689
+ pointsApplied: input.pointsApplied,
690
+ error: normalized.message,
691
+ });
692
+ // Hand the budget back, so a later group in the same checkout is not
693
+ // charged for a spend that did not happen.
694
+ input.pointsBudget.remaining += input.pointsApplied;
695
+ await unitOfWork.orders
696
+ .update(input.orderId, { pointsApplied: undefined, amountDue: undefined })
697
+ .catch((e) => {
698
+ /*
699
+ * 🛑 The one genuinely bad outcome, so it is reported rather than
700
+ * logged: the order still says `amountDue`, the points were never
701
+ * taken, and the gateway is about to be asked for the discounted
702
+ * figure. A case names the amount and the order; `safeRead`'s silent
703
+ * degrade would be wrong here because nothing downstream can detect it.
704
+ */
705
+ const inner = normalizeError(e);
706
+ serverLogger.error("[checkout] points revert FAILED — order under-collects", {
707
+ orderId: input.orderId,
708
+ amountUnfunded: input.pointsApplied,
709
+ error: inner.message,
710
+ });
711
+ void paymentCasesRepository()
712
+ .openOrObserve({
713
+ kind: "amount_mismatch",
714
+ // The problem is this order's unfunded discount, so the subject is
715
+ // the order — one case however many times it is observed.
716
+ subjectId: input.orderId,
717
+ severity: "error",
718
+ entityType: "order",
719
+ entityId: input.orderId,
720
+ orderIds: [input.orderId],
721
+ title: "Order carries a points discount that was never funded",
722
+ detail: `Redemption of ${input.pointsApplied} points was refused and the ` +
723
+ `revert also failed, so this order will be captured at its ` +
724
+ `discounted amountDue with no matching ledger debit.`,
725
+ amountAtRisk: input.pointsApplied,
726
+ })
727
+ .catch(() => undefined);
728
+ });
729
+ }
730
+ }
597
731
  /**
598
732
  * Places one order for one seller-group of the checkout cart — fee math
599
733
  * (COD deposit/handling, EMI schedule), coupon apportionment, order-doc
@@ -602,7 +736,7 @@ function dispatchOrderConfirmationEmails(emailsToSend) {
602
736
  * so the caller can accumulate the checkout-wide `total`.
603
737
  */
604
738
  async function createOrderForGroup(group, orderType, ctx) {
605
- const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, selectedShippingQuote, } = ctx;
739
+ const { paymentMethod, emiOptIn, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, deliverySnapshot, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, selectedShippingQuote, useAllPoints, pointsBudget, } = ctx;
606
740
  const firstItem = group[0].item;
607
741
  const firstProduct = group[0].product;
608
742
  // Add-ons are this store's choice, not the cart's — see OrderGroupContext.
@@ -636,11 +770,24 @@ async function createOrderForGroup(group, orderType, ctx) {
636
770
  // A multi-member line expands into one row PER MEMBER sharing a groupSlug, so
637
771
  // each row carries its own HSN/GST and can be cancelled independently; the
638
772
  // receipt still collapses them under one header.
639
- const orderItems = group.flatMap(({ item, product }) => {
773
+ const baseOrderItems = group.flatMap(({ item, product }) => {
640
774
  const lt = (product.listingType ?? "standard");
641
775
  const itemRule = getListingRule(lt);
642
776
  return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
643
777
  });
778
+ /*
779
+ * Part 14 — freeze the parts a composed listing was sold as containing.
780
+ *
781
+ * A separate pass because composition lives in `modelCompositions` keyed by
782
+ * the model's CATEGORY leaf, not on the product, so it needs a read — and
783
+ * `expandCartLineToOrderRows` is pure by design.
784
+ *
785
+ * 🛑 Usually free: it returns immediately unless some line's listing type
786
+ * has a composition at all, and then reads only the DISTINCT leaves,
787
+ * capped. It never throws — a parts list we could not read must not fail a
788
+ * payment (see the module header).
789
+ */
790
+ const orderItems = await freezePartsOnItems(baseOrderItems);
644
791
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
645
792
  /*
646
793
  * D44: the group's real goods value, so free-above-threshold can fire.
@@ -650,7 +797,7 @@ async function createOrderForGroup(group, orderType, ctx) {
650
797
  * grouped line the two differ.
651
798
  */
652
799
  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 });
800
+ const { shippingFee: resolvedShippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity, acceptsPoints, sharedPurchaseRates } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupGoodsValue });
654
801
  /*
655
802
  * 🛑 The buyer's quote WINS over the seller's configured rate, and it is
656
803
  * re-rounded here rather than trusted as sent.
@@ -699,14 +846,30 @@ async function createOrderForGroup(group, orderType, ctx) {
699
846
  * simply did not exist yet. The split now sits immediately after
700
847
  * `orderTotal`, which is the only place every term is known.
701
848
  */
702
- let emiSchedule;
703
- if (paymentMethod === "emi" && emiTenureMonths) {
704
- const eligibility = checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings);
705
- if (!eligibility.eligible) {
706
- throw new ValidationError(`EMI is not available for this order (${eligibility.reason}). Minimum order value per seller is ₹${emiSettings.minOrderValue}.`);
707
- }
708
- emiSchedule = computeEmiSchedule(groupTotal, emiTenureMonths, emiSettings);
849
+ /*
850
+ * 🛑 Keyed on `emiOptIn`, NOT on the tender. An EMI order's `paymentMethod`
851
+ * is now whatever the buyer picked to pay the token with — `online`,
852
+ * `upi_manual` or `cash` — so testing `paymentMethod === "emi"` here would
853
+ * build a schedule for nobody and every instalment plan would silently
854
+ * vanish into a full-price order.
855
+ */
856
+ const emiGroupInput = {
857
+ emiOptIn,
858
+ emiTenureMonths,
859
+ groupSubtotal: groupTotal,
860
+ sellerEmiEnabled: storeEmiEnabled,
861
+ };
862
+ /*
863
+ * 🛑 REFUSES on an ineligible EMI order rather than quietly dropping the
864
+ * plan. The preview deliberately does the opposite — see `groupEmiGate`'s
865
+ * docstring. Silently producing a full-price order from an EMI request is
866
+ * money the buyer did not agree to.
867
+ */
868
+ const emiGate = groupEmiGate(emiGroupInput, emiSettings);
869
+ if (emiOptIn && emiTenureMonths && !emiGate.applies) {
870
+ throw new ValidationError(`EMI is not available for this order (${emiGate.reason}). Minimum order value per seller is ₹${emiSettings.minOrderValue}.`);
709
871
  }
872
+ const emiSchedule = groupEmiSchedule(emiGroupInput, emiSettings) ?? undefined;
710
873
  // Handling fee charged to the buyer for choosing COD, on top of the order
711
874
  // total — not deducted from the deposit/remaining split above.
712
875
  const codHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
@@ -818,6 +981,47 @@ async function createOrderForGroup(group, orderType, ctx) {
818
981
  groupCouponCodes.add(discount.code);
819
982
  }
820
983
  }
984
+ /*
985
+ * ── S5a: a shared purchase — one payer, N destinations ────────────────────
986
+ *
987
+ * Computed BEFORE `orderTotal` because it is a term in it. The rates come
988
+ * off the store document and are SNAPSHOTTED onto the order below: a
989
+ * coordinator commits against a quoted total, and re-reading a rate at
990
+ * settle would let a seller raise it afterwards. That is D61's rule, and the
991
+ * precedent is the refund path reading `finalSale` from the order snapshot
992
+ * for exactly this reason.
993
+ *
994
+ * 🛑 `countDestinations` counts DISTINCT addresses and collapses every line
995
+ * with no `destinationAddressId` into one bucket — the order's own address.
996
+ * Counting items instead would charge an ordinary three-item order for two
997
+ * extra legs it never asked for.
998
+ */
999
+ const sharedQuote = quoteSharedPurchase({
1000
+ destinationCount: countDestinations(group.map(({ item }) => item)),
1001
+ rates: sharedPurchaseRates,
1002
+ });
1003
+ /*
1004
+ * 🛑 An UNPRICED arrangement is refused, not given away. The seller opted in
1005
+ * without setting a rate, which is not consent to do the extra packing for
1006
+ * free — and quoting ₹0 is exactly D44: `shippingUnavailable` carried a
1007
+ * docstring saying "the caller MUST act on this rather than trusting
1008
+ * `shippingFee: 0`", had zero consumers, and every `estimate`-mode store
1009
+ * shipped free on every path as a result.
1010
+ */
1011
+ if (sharedQuote.unpriced) {
1012
+ throw new ValidationError("This seller has not set a price for splitting an order across addresses. " +
1013
+ "Send everything to one address, or contact the seller.");
1014
+ }
1015
+ /*
1016
+ * A multi-destination order from a store that has NOT opted in. The quote
1017
+ * prices; it does not authorise — so the refusal is here, where the buyer
1018
+ * can be told something true and specific.
1019
+ */
1020
+ if (!sharedQuote.isShared &&
1021
+ countDestinations(group.map(({ item }) => item)) > 1) {
1022
+ throw new ValidationError("This seller ships one order to one address. Remove the extra delivery addresses, " +
1023
+ "or order the items separately.");
1024
+ }
821
1025
  // The platform commission is now charged on EVERY payment method. It used to
822
1026
  // be added only on the online-payment path, so COD / UPI-manual / cash / EMI
823
1027
  // buyers silently never paid it.
@@ -848,7 +1052,7 @@ async function createOrderForGroup(group, orderType, ctx) {
848
1052
  * compounds nine half-paisa errors instead of one, which is how a total
849
1053
  * drifts from the sum of the lines shown beside it.
850
1054
  */
851
- roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0));
1055
+ roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0) + sharedQuote.total);
852
1056
  /*
853
1057
  * ── D43: the COD deposit/remaining split, on the ORDER TOTAL ──────────────
854
1058
  *
@@ -891,16 +1095,72 @@ async function createOrderForGroup(group, orderType, ctx) {
891
1095
  // Deadline + displayed UPI apply only to methods with a buyer-uploaded
892
1096
  // proof step — never cod (paid on delivery), online (paid before order
893
1097
  // creation), or admin_bypass (already marked paid above).
1098
+ /*
1099
+ * 🛑 `PaymentMethodValues.EMI` was a third arm here and is gone on purpose,
1100
+ * not by oversight. It is unreachable after `resolveEmiChoice` — an EMI
1101
+ * order's tender is `online` / `upi_manual` / `cash` — and leaving it would
1102
+ * assert that "EMI" is a way of paying, which is exactly the conflation 0f
1103
+ * removed. An EMI buyer paying by bank transfer still gets the window,
1104
+ * because their tender IS `upi_manual`; one paying online does not, because
1105
+ * the token is captured before the order exists.
1106
+ */
894
1107
  const hasPaymentWindow = !adminBypass &&
895
1108
  (paymentMethod === PaymentMethodValues.UPI_MANUAL ||
896
- paymentMethod === PaymentMethodValues.CASH ||
897
- paymentMethod === PaymentMethodValues.EMI);
1109
+ paymentMethod === PaymentMethodValues.CASH);
898
1110
  const paymentDeadline = hasPaymentWindow
899
1111
  ? new Date(Date.now() + PAYMENT_WINDOW_MS)
900
1112
  : undefined;
901
1113
  const displayedUpiId = hasPaymentWindow
902
1114
  ? await resolveDisplayedUpiId(firstItem.storeId, siteContactUpiVpa)
903
1115
  : undefined;
1116
+ /*
1117
+ * ── POINTS TENDER (D46/D47) — a STAGE, after `orderTotal` is rounded ──────
1118
+ *
1119
+ * The deleted code redeemed points AFTER the orders existed and after the
1120
+ * gateway had captured `orderTotal`, so a successful redemption took points
1121
+ * from a buyer who had just paid full price — while its own comment claimed
1122
+ * "the balance intact". This is the same feature done as a pipeline stage:
1123
+ * `amountDue` is derived here, and `amountDue` is what the gateway captures
1124
+ * and what the post-capture guard compares.
1125
+ *
1126
+ * 🛑 `useAllPoints` is the only client input and it is a BOOLEAN (decision
1127
+ * 24). The amount comes from the live balance and the 50% cap; a
1128
+ * client-supplied money figure is a client-supplied discount.
1129
+ *
1130
+ * 🛑 Per group, because the ACCEPTING store absorbs the redemption against
1131
+ * its own payout (decision 3). A cart spanning an accepting and a
1132
+ * non-accepting store must not pool the discount — so this reads
1133
+ * `acceptsPoints`, which `resolveShippingCost` resolved from the store
1134
+ * document it had already fetched.
1135
+ *
1136
+ * 🛑 `pointsBudget` is a SHARED MUTABLE REMAINDER, read once per checkout by
1137
+ * the caller and decremented here — the same shape as
1138
+ * `couponUsageAccumulator` and `platformFeeByStore`. Reading the balance
1139
+ * per group would let two groups each spend the whole balance: `redeem`
1140
+ * would then refuse the second one inside its transaction, leaving an order
1141
+ * recorded at a discount that was never funded. One budget, drawn down in
1142
+ * order, is what makes the sum of the groups equal what the buyer has.
1143
+ */
1144
+ const pointsOptedIn = useAllPoints === true;
1145
+ const pointsAccepted = pointsOptedIn && acceptsPoints;
1146
+ const { pointsApplied, amountDue } = resolvePointsTender({
1147
+ orderTotal,
1148
+ balance: pointsAccepted ? pointsBudget.remaining : 0,
1149
+ accepted: pointsAccepted,
1150
+ optedIn: pointsOptedIn,
1151
+ });
1152
+ if (pointsApplied > 0)
1153
+ pointsBudget.remaining -= pointsApplied;
1154
+ /*
1155
+ * What is COLLECTED, composing both tenders that reduce it. On a plain order
1156
+ * this equals `orderTotal`, so the field is omitted below and every
1157
+ * `amountDue ?? totalPrice` reader is unaffected.
1158
+ */
1159
+ const amountDueOnOrder = amountDueNow({
1160
+ orderTotal,
1161
+ pointsApplied,
1162
+ emiRemainingBalance: emiSchedule?.remainingBalance ?? 0,
1163
+ });
904
1164
  // Both creation paths call the SAME builder — two hand-written copies is the
905
1165
  // create-vs-create axis of Root Cause #39, and these two functions have
906
1166
  // already drifted once before (add-on fees, Root Cause #65).
@@ -914,6 +1174,39 @@ async function createOrderForGroup(group, orderType, ctx) {
914
1174
  quantity: totalQuantity,
915
1175
  unitPrice: unitPriceFor(firstItem, firstProduct),
916
1176
  totalPrice: orderTotal,
1177
+ /*
1178
+ * D46/D47 — what the points paid for, and what is left to collect.
1179
+ *
1180
+ * 🛑 `totalPrice` stays the FULL order value and is not reduced. It is the
1181
+ * value of the supply, it is what the GST breakdown above was computed on,
1182
+ * and it is what `refundableBase` measures against — netting the points
1183
+ * off it would silently shrink the refundable base and the taxable value
1184
+ * at once. Points are a DISCOUNT settled against goods (decision 5), so
1185
+ * they reduce the amount COLLECTED, not the amount supplied.
1186
+ *
1187
+ * 🛑 Both are written only when non-zero, so an order placed without
1188
+ * points carries neither field and every reader's `amountDue ?? totalPrice`
1189
+ * fallback resolves to the right number on the 100% of historical orders
1190
+ * that predate this.
1191
+ */
1192
+ pointsApplied: pointsApplied > 0 ? pointsApplied : undefined,
1193
+ /*
1194
+ * 🛑 WHAT IS COLLECTED NOW, and on an EMI order that is the token — not
1195
+ * the whole total.
1196
+ *
1197
+ * This was `pointsApplied > 0 ? amountDue : undefined`, so an EMI order
1198
+ * carried no `amountDue` at all and every reader fell back to
1199
+ * `order.total`. The buyer's manual-payment page therefore asked an EMI
1200
+ * buyer to transfer the FULL order value up front and then showed them a
1201
+ * schedule of monthly instalments: `emiTokenAmount` was computed, stored,
1202
+ * and read by nothing (measured — zero readers across both repos).
1203
+ *
1204
+ * `amountDueNow` subtracts the deferred balance as well as the points, so
1205
+ * the two tenders compose. Written only when it differs from the total, so
1206
+ * every historical order keeps resolving through the same
1207
+ * `amountDue ?? totalPrice` fallback its readers already use.
1208
+ */
1209
+ amountDue: amountDueOnOrder !== orderTotal ? amountDueOnOrder : undefined,
917
1210
  currency: firstItem.currency ?? getDefaultCurrency(),
918
1211
  storeId: firstItem.storeId || undefined,
919
1212
  storeName: firstItem.storeName || undefined,
@@ -946,6 +1239,12 @@ async function createOrderForGroup(group, orderType, ctx) {
946
1239
  displayedUpiId,
947
1240
  adminBypassBy: adminBypassBy,
948
1241
  shippingAddress,
1242
+ /*
1243
+ * 🛑 Spread BEFORE `placeOfSupply`, which now prefers the snapshot's
1244
+ * state. Spreading after would let an absent snapshot overwrite a
1245
+ * state the digital-cart fallback had already resolved.
1246
+ */
1247
+ ...(deliverySnapshot ?? {}),
949
1248
  notes,
950
1249
  shippingFee: shippingFee > 0 ? shippingFee : undefined,
951
1250
  depositAmount: adminBypass ? undefined : depositAmount,
@@ -971,11 +1270,39 @@ async function createOrderForGroup(group, orderType, ctx) {
971
1270
  shipmentProtectionFee: !adminBypass && shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
972
1271
  saleProtectionAddon: !adminBypass && saleProtectionFee > 0 ? true : undefined,
973
1272
  saleProtectionFee: !adminBypass && saleProtectionFee > 0 ? saleProtectionFee : undefined,
1273
+ /*
1274
+ * S5a — the arrangement, snapshotted. Written only when it IS a shared
1275
+ * purchase, so an ordinary order carries no field at all and every reader
1276
+ * can test presence rather than compare a count to 1.
1277
+ *
1278
+ * 🛑 The FEES AS CHARGED, not the store's current rates. That is the whole
1279
+ * point of a snapshot: an order refunded in six months must reverse what
1280
+ * was collected, not what the seller charges by then.
1281
+ */
1282
+ sharedPurchase: sharedQuote.isShared
1283
+ ? {
1284
+ destinationCount: countDestinations(group.map(({ item }) => item)),
1285
+ handlingFee: sharedQuote.handlingFee,
1286
+ extraShippingFee: sharedQuote.extraShippingFee,
1287
+ }
1288
+ : undefined,
974
1289
  taxableAmount: gstBreakdown?.taxableAmount,
975
1290
  /* Exempt goods are a reportable nil-rated supply, kept OUT of
976
1291
  * `taxableAmount` so the taxed base stays the base the tax was charged on,
977
1292
  * and recorded here so the invoice's columns can reconcile. */
978
1293
  exemptAmount: gstBreakdown?.exemptAmount || undefined,
1294
+ /*
1295
+ * Phase 7 — the place of supply, which is the INPUT the CGST/SGST-vs-IGST
1296
+ * split above was computed from. Only the split's RESULT was recorded
1297
+ * before, so an order could not state why it was taxed the way it was, and
1298
+ * GSTR-1's mandatory place-of-supply column had nothing to read.
1299
+ */
1300
+ /*
1301
+ * Part 14 — the snapshot's state is authoritative, because it is the
1302
+ * field the invoice and GSTR-1 actually read. `buyerState` stays as
1303
+ * the fallback for a digital cart, which has no delivery address.
1304
+ */
1305
+ placeOfSupply: deliverySnapshot?.deliveryState || buyerState || undefined,
979
1306
  /* The supplier of record AS IT STANDS NOW. A tax invoice without the
980
1307
  * supplier's GSTIN is not a tax invoice, and the invoice page cannot read
981
1308
  * it from site settings — `gst` is in PRIVATE_SITE_FIELDS. See the schema.
@@ -1013,23 +1340,19 @@ async function createOrderForGroup(group, orderType, ctx) {
1013
1340
  cgst: gstBreakdown?.cgst || undefined,
1014
1341
  sgst: gstBreakdown?.sgst || undefined,
1015
1342
  igst: gstBreakdown?.igst || undefined,
1016
- emiEnabled: !adminBypass && !!emiSchedule ? true : undefined,
1017
- emiTenureMonths: !adminBypass && emiSchedule ? emiTenureMonths : undefined,
1018
- emiTokenAmount: !adminBypass ? emiSchedule?.tokenAmount : undefined,
1019
- emiSurchargeAmount: !adminBypass ? emiSchedule?.surchargeAmount : undefined,
1020
1343
  /*
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.
1344
+ * The eleven `emi*` fields, from one builder shared with the gateway
1345
+ * path — which carried none of them, so every order paid online lost its
1346
+ * instalment plan. D45's note about `emiSurchargeSellerShare` being
1347
+ * computed and discarded now lives on `emiOrderFields` itself.
1348
+ *
1349
+ * 🛑 `adminBypass` nulls the whole group by passing no schedule, rather
1350
+ * than by repeating `!adminBypass &&` eleven times. The bypass marks an
1351
+ * order paid without a payment, so there is no token and no plan — and
1352
+ * `resolveEmiChoice` now refuses that combination outright, making this
1353
+ * a second line of defence rather than the only one.
1026
1354
  */
1027
- emiSurchargeSellerShare: !adminBypass ? emiSchedule?.surchargeSellerShare : undefined,
1028
- emiInstallments: !adminBypass && emiSchedule
1029
- ? emiSchedule.installments.map((i) => ({ index: i.index, dueDate: i.dueDate, amount: i.amount, status: "pending" }))
1030
- : undefined,
1031
- emiRemainingBalance: !adminBypass ? emiSchedule?.remainingBalance : undefined,
1032
- emiComplete: !adminBypass && emiSchedule ? false : undefined,
1355
+ ...emiOrderFields(adminBypass ? null : (emiSchedule ?? null), emiTenureMonths),
1033
1356
  couponCode: appliedDiscounts[0]?.code,
1034
1357
  couponDiscount: couponDiscount > 0 ? couponDiscount : undefined,
1035
1358
  appliedDiscounts: appliedDiscounts.length > 0 ? appliedDiscounts : undefined,
@@ -1039,6 +1362,9 @@ async function createOrderForGroup(group, orderType, ctx) {
1039
1362
  ...extraOrderFields,
1040
1363
  });
1041
1364
  orderIds.push(order.id);
1365
+ // The second half of the points tender — see `settlePointsTender`. Runs
1366
+ // before the caller asks the gateway for anything.
1367
+ await settlePointsTender({ orderId: order.id, userId: uid, pointsApplied, pointsBudget });
1042
1368
  // Close the loop on any locked line this order settled: flip the offer to
1043
1369
  // "paid" (its terminal status had NO server-side writer before 2026-08-21,
1044
1370
  // so an accepted offer could be re-added and re-ordered) and back-link the
@@ -1089,15 +1415,17 @@ async function createOrderForGroup(group, orderType, ctx) {
1089
1415
  * auth + rate-limiting before calling this and supply the resolved user fields.
1090
1416
  */
1091
1417
  export async function createCheckoutOrderAction(input) {
1092
- const { userId: uid, userName, userEmail, addressId, paymentMethod, emiTenureMonths, notes, excludedProductIds = [], adminBypass = false, adminBypassBy, outOfStockPolicy = OutOfStockPolicyValues.SKIP_ITEMS, } = input;
1418
+ const { userId: uid, userName, userEmail, addressId, notes, excludedProductIds = [], adminBypass = false, adminBypassBy, outOfStockPolicy = OutOfStockPolicyValues.SKIP_ITEMS, } = input;
1419
+ /*
1420
+ * The TENDER, resolved before anything else because the COD and manual-UPI
1421
+ * guards below are about the tender and run before the cart read. The
1422
+ * ARRANGEMENT (`emiOptIn` + tenure) lives on the cart and is resolved after
1423
+ * it — see the block below the cart load.
1424
+ */
1425
+ const paymentMethod = emiTenderFor(input.paymentMethod);
1093
1426
  const siteSettings = await siteSettingsRepository.getSingleton();
1094
1427
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1095
1428
  const emiSettings = siteSettings?.emi ?? CHECKOUT_DEFAULT_EMI_SETTINGS;
1096
- if (paymentMethod === "emi") {
1097
- if (!emiTenureMonths || !emiSettings.tenureOptions.includes(emiTenureMonths)) {
1098
- throw new ValidationError(`emiTenureMonths must be one of: ${emiSettings.tenureOptions.join(", ")}`);
1099
- }
1100
- }
1101
1429
  // W1-43 — enforce COD toggle.
1102
1430
  if (!adminBypass &&
1103
1431
  paymentMethod === "cod" &&
@@ -1114,6 +1442,30 @@ export async function createCheckoutOrderAction(input) {
1114
1442
  if (!cart.items || cart.items.length === 0) {
1115
1443
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
1116
1444
  }
1445
+ /*
1446
+ * ── The two payment axes, resolved ONCE, AFTER the cart is loaded ────────
1447
+ *
1448
+ * Everything below reads `paymentMethod` (the tender) and `emiOptIn` (the
1449
+ * arrangement) from here, never from `input`. A second place deciding "is
1450
+ * this an EMI order" is the drift this normaliser exists to prevent, and
1451
+ * the legacy `paymentMethod: "emi"` mapping only holds if exactly one
1452
+ * expression performs it.
1453
+ *
1454
+ * 🛑 It has to sit below the cart read because the plan LIVES on the cart,
1455
+ * not in the request — see `CartDocument.emiPlan`. The cart is loaded here
1456
+ * anyway, so this costs nothing.
1457
+ */
1458
+ const emiChoice = resolveEmiChoice({
1459
+ paymentMethod: input.paymentMethod,
1460
+ cartEmiPlan: cart.emiPlan ?? null,
1461
+ legacyTenureMonths: input.emiTenureMonths,
1462
+ });
1463
+ if (!emiChoice.ok)
1464
+ throw new ValidationError(emiChoice.reason);
1465
+ const { emiOptIn, emiTenureMonths } = emiChoice.choice;
1466
+ if (emiOptIn && (!emiTenureMonths || !emiSettings.tenureOptions.includes(emiTenureMonths))) {
1467
+ throw new ValidationError(`emiTenureMonths must be one of: ${emiSettings.tenureOptions.join(", ")}`);
1468
+ }
1117
1469
  const excludedSet = new Set(excludedProductIds);
1118
1470
  const selectedSet = cart.selectedItemIds?.length
1119
1471
  ? new Set(cart.selectedItemIds)
@@ -1136,16 +1488,13 @@ export async function createCheckoutOrderAction(input) {
1136
1488
  // otherwise a buyer could dodge it by splitting a big cart across
1137
1489
  // sellers. Skipped for COD (physical hand-off at the door is its own
1138
1490
  // verification) and admin-bypass checkouts.
1139
- const otpThreshold = siteSettings?.payment?.otpCheckoutThreshold;
1140
- if (!adminBypass && paymentMethod !== "cod" && typeof otpThreshold === "number" && otpThreshold > 0) {
1141
- const cartTotalForOtpCheck = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
1142
- if (cartTotalForOtpCheck >= otpThreshold) {
1143
- const verified = await isCheckoutValueOtpVerified(uid);
1144
- if (!verified) {
1145
- throw new ApiError(403, "CHECKOUT_VALUE_OTP_REQUIRED");
1146
- }
1147
- }
1148
- }
1491
+ await assertCheckoutValueOtp({
1492
+ uid,
1493
+ amount: cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0),
1494
+ threshold: siteSettings?.payment?.otpCheckoutThreshold,
1495
+ paymentMethod,
1496
+ adminBypass,
1497
+ });
1149
1498
  /*
1150
1499
  * COD value cap — the SERVER half of the gate.
1151
1500
  *
@@ -1181,6 +1530,7 @@ export async function createCheckoutOrderAction(input) {
1181
1530
  }
1182
1531
  const isDigitalCart = cartIsDigitalOnly(cartItems);
1183
1532
  let shippingAddress;
1533
+ let deliverySnapshot;
1184
1534
  let resolvedAddress = null;
1185
1535
  if (!isDigitalCart) {
1186
1536
  if (!addressId) {
@@ -1201,6 +1551,13 @@ export async function createCheckoutOrderAction(input) {
1201
1551
  throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
1202
1552
  }
1203
1553
  shippingAddress = formatShippingAddress(resolvedAddress);
1554
+ /*
1555
+ * 🛑 REVEALED first. `findById` returns this PII encrypted, and
1556
+ * `snapshotDeliveryAddress` demands `AddressPlain` precisely so that
1557
+ * freezing `enc:v1:…` onto an invoice is a compile error rather than
1558
+ * something discovered on a printed label.
1559
+ */
1560
+ deliverySnapshot = snapshotDeliveryAddress(unitOfWork.addresses.revealAddress(resolvedAddress));
1204
1561
  }
1205
1562
  const db = getAdminDb();
1206
1563
  // SB6-C — pre-tx fetch products so we can run the maxPerUser cap check
@@ -1240,6 +1597,19 @@ export async function createCheckoutOrderAction(input) {
1240
1597
  * is the only safe place for this, and the method is decided here.
1241
1598
  */
1242
1599
  assertPaymentMethodAllowed(cartItems, preTxProductById, paymentMethod);
1600
+ /*
1601
+ * 🛑 The second axis needs its own assertion, and omitting it would have
1602
+ * been a silent regression of a rule that already exists.
1603
+ *
1604
+ * `prizeDrawRule.blockedPaymentMethods` lists `"emi"` because winner
1605
+ * assignment waits on confirmed payment. Once EMI stopped being a tender,
1606
+ * that entry stopped matching anything — the gate above would see
1607
+ * `upi_manual` (explicitly ALLOWED for prize draws) and wave through an
1608
+ * instalment plan on a listing type that forbids one. This reads the SAME
1609
+ * declaration rather than a second list, so a rule written once still
1610
+ * governs both axes.
1611
+ */
1612
+ assertEmiOptInAllowed(cartItems, preTxProductById, emiOptIn);
1243
1613
  const preTxPairs = cartItems
1244
1614
  .map((item) => {
1245
1615
  const [firstMember] = getCartItemMemberIds(item);
@@ -1388,8 +1758,27 @@ export async function createCheckoutOrderAction(input) {
1388
1758
  g[0].item.storeId,
1389
1759
  g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
1390
1760
  ]), computeCheckoutFees(cartSubtotal, commissions));
1761
+ /*
1762
+ * ── The checkout's points budget, read ONCE (D46/D47) ─────────────────────
1763
+ *
1764
+ * 🛑 One read for the whole checkout, not one per order group. Rule #6
1765
+ * budgets ~3 sequential Firestore round-trips per request, and a cart
1766
+ * spanning four sellers would otherwise pay four reads to learn one number.
1767
+ *
1768
+ * 🛑 And ZERO when the buyer did not opt in, so an ordinary checkout performs
1769
+ * no points read at all — the common case must not pay for a feature it is
1770
+ * not using.
1771
+ *
1772
+ * 🛑 `adminBypass` is deliberately excluded: that path takes no payment, so
1773
+ * there is nothing for points to reduce, and debiting a buyer's balance for
1774
+ * an order the admin placed free would be a theft with an audit-log entry
1775
+ * saying otherwise.
1776
+ */
1777
+ const useAllPoints = cart.useAllPoints === true && !adminBypass;
1778
+ const pointsBudget = { remaining: useAllPoints ? await safePointsBalance(uid) : 0 };
1391
1779
  const groupCtx = {
1392
1780
  paymentMethod,
1781
+ emiOptIn,
1393
1782
  emiTenureMonths,
1394
1783
  emiSettings,
1395
1784
  commissions,
@@ -1400,6 +1789,7 @@ export async function createCheckoutOrderAction(input) {
1400
1789
  userName,
1401
1790
  userEmail,
1402
1791
  shippingAddress,
1792
+ deliverySnapshot,
1403
1793
  notes,
1404
1794
  adminBypass,
1405
1795
  adminBypassBy,
@@ -1415,6 +1805,8 @@ export async function createCheckoutOrderAction(input) {
1415
1805
  platformFeeByStore,
1416
1806
  selectedShippingQuote: input.selectedShippingQuote,
1417
1807
  productById: checkoutProductById,
1808
+ useAllPoints,
1809
+ pointsBudget,
1418
1810
  };
1419
1811
  for (const { items: group, orderType } of orderGroups) {
1420
1812
  total += await createOrderForGroup(group, orderType, groupCtx);
@@ -1448,18 +1840,26 @@ export async function createCheckoutOrderAction(input) {
1448
1840
  * Deleting it leaves a promise with no mechanism, which is a disappointment.
1449
1841
  * Keeping it left a mechanism that silently takes points, which is a theft.
1450
1842
  *
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.
1843
+ * ## ✅ The real tender was BUILT 2026-10-22 — and it is not here
1844
+ *
1845
+ * 🛑 Do not look for it at this line. Points are a TENDER, so they are a
1846
+ * STAGE inside each order-creation path, immediately after that path's single
1847
+ * `roundRupees` and BEFORE anything is written or captured:
1848
+ *
1849
+ * - `resolvePointsTender` (`_internal/shared/checkout/order-math.ts`) —
1850
+ * the arithmetic, beside every other money rule.
1851
+ * - the stage in `createOrderForGroup` and `createPhonePeGroupOrder`,
1852
+ * which write `pointsApplied` + `amountDue` onto the order.
1853
+ * - `settlePointsTender` — ONE debit implementation shared by both paths,
1854
+ * running after the order exists so the discount and the debit are two
1855
+ * halves of one recorded fact, and reverting the discount if `redeem`
1856
+ * refuses.
1857
+ *
1858
+ * This note stays because the mistake is worth keeping, but a comment saying
1859
+ * a feature is "missing" while it sits 800 lines above is its own trap — it
1860
+ * is how `assertReturnWindowOpen` got reported as absent when my own comment
1861
+ * was shadowing the real answer. The accurate statement is: the tender is
1862
+ * built, and this is the wrong place for it.
1463
1863
  */
1464
1864
  dispatchOrderConfirmationEmails(emailsToSend);
1465
1865
  serverLogger.info(`createCheckoutOrderAction: ${orderIds.length} order(s) placed for uid=${uid}` +
@@ -1489,11 +1889,16 @@ const EMPTY_PRICING_PREVIEW = {
1489
1889
  shipmentProtectionFee: 0,
1490
1890
  saleProtectionFee: 0,
1491
1891
  emiSurcharge: 0,
1892
+ emiDeferred: 0,
1492
1893
  gstAmount: 0,
1493
1894
  couponDiscount: 0,
1494
1895
  platformFee: 0,
1495
1896
  gstOnFee: 0,
1897
+ sharedPurchaseFee: 0,
1496
1898
  total: 0,
1899
+ pointsApplied: 0,
1900
+ amountDue: 0,
1901
+ pointsBalance: 0,
1497
1902
  stores: [],
1498
1903
  };
1499
1904
  /**
@@ -1506,10 +1911,29 @@ const EMPTY_PRICING_PREVIEW = {
1506
1911
  * stock, creates an order, or writes coupon usage.
1507
1912
  */
1508
1913
  export async function previewCheckoutPricing(input) {
1509
- const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, emiTenureMonths, } = input;
1914
+ const { userId: uid, addressId, excludedProductIds = [], lane, } = input;
1915
+ /*
1916
+ * 🛑 Same normaliser as `createCheckoutOrderAction`, and on an INCOMPATIBLE
1917
+ * combination this prices the tender WITHOUT instalments rather than
1918
+ * throwing.
1919
+ *
1920
+ * The preview exists so a buyer can see consequences while they fiddle with
1921
+ * controls; a 500 in the middle of that blanks the Order Summary and reads
1922
+ * as a broken page. The order action still refuses, which is where refusing
1923
+ * belongs — the same split this function already makes for EMI eligibility
1924
+ * two hundred lines below.
1925
+ */
1926
+ const paymentMethod = emiTenderFor(input.paymentMethod);
1510
1927
  const siteSettings = await siteSettingsRepository.getSingleton();
1511
1928
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1512
1929
  const cart = await unitOfWork.carts.getOrCreate(uid);
1930
+ const emiChoice = resolveEmiChoice({
1931
+ paymentMethod: input.paymentMethod,
1932
+ cartEmiPlan: cart.emiPlan ?? null,
1933
+ legacyTenureMonths: input.emiTenureMonths,
1934
+ });
1935
+ const emiOptIn = emiChoice.ok && emiChoice.choice.emiOptIn;
1936
+ const emiTenureMonths = emiChoice.ok ? emiChoice.choice.emiTenureMonths : undefined;
1513
1937
  const excludedSet = new Set(excludedProductIds);
1514
1938
  const selectedSet = cart.selectedItemIds?.length ? new Set(cart.selectedItemIds) : null;
1515
1939
  // An explicit lane wins: the cart page prices whichever tab is open, which may
@@ -1566,7 +1990,7 @@ export async function previewCheckoutPricing(input) {
1566
1990
  * and then the order path (equally unpassed) charged it. Preview and charge
1567
1991
  * agreed, and both were wrong in the same direction.
1568
1992
  */
1569
- const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupTotal });
1993
+ const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled, acceptsPoints, sharedPurchaseRates: previewSharedRates } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupTotal });
1570
1994
  // Add-ons are per store, read off the cart document — the same entry
1571
1995
  // createOrderForGroup will read when the order is actually placed.
1572
1996
  const addons = cart.storeAddons?.[firstItem.storeId] ?? {};
@@ -1597,13 +2021,14 @@ export async function previewCheckoutPricing(input) {
1597
2021
  * ineligible EMI order, which is where refusing belongs. An ineligible
1598
2022
  * cart simply previews no surcharge, because the tenure never applies.
1599
2023
  */
1600
- let groupEmiSurcharge = 0;
1601
- if (paymentMethod === "emi" && emiTenureMonths && siteSettings?.emi) {
1602
- const emiSettings = siteSettings.emi;
1603
- if (checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings).eligible) {
1604
- groupEmiSurcharge = computeBuyerEmiQuote(groupTotal, emiTenureMonths, emiSettings).surchargeAmount;
1605
- }
1606
- }
2024
+ const groupEmiPreviewQuote = groupEmiQuote({ emiOptIn, emiTenureMonths, groupSubtotal: groupTotal, sellerEmiEnabled: storeEmiEnabled }, siteSettings?.emi);
2025
+ const groupEmiSurcharge = groupEmiPreviewQuote?.surchargeAmount ?? 0;
2026
+ /*
2027
+ * What the buyer pays TODAY on an instalment order — the token plus every
2028
+ * additive charge. Surfaced so the Order Summary can show both figures
2029
+ * rather than one number the buyer has to interpret.
2030
+ */
2031
+ const groupEmiDeferred = groupEmiPreviewQuote?.remainingBalance ?? 0;
1607
2032
  const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
1608
2033
  /*
1609
2034
  * C5e — the same three tiers `createOrderForGroup` enforces, so the figure
@@ -1617,6 +2042,28 @@ export async function previewCheckoutPricing(input) {
1617
2042
  * seller cannot flip the switch after the sale).
1618
2043
  */
1619
2044
  const groupHasFinalSale = group.some(({ product }) => product?.finalSale === true);
2045
+ /*
2046
+ * ── S5a in the PREVIEW ───────────────────────────────────────────────────
2047
+ *
2048
+ * 🛑 Here because a coordinator commits against this figure. The plan is
2049
+ * explicit that the fees "must be shown before the coordinator commits" —
2050
+ * a handling fee plus N−1 shipping legs discovered afterwards is exactly
2051
+ * the surprise the arrangement makes expensive.
2052
+ *
2053
+ * The same function and the same `countDestinations` as both order paths,
2054
+ * so the shown figure and the charged one cannot diverge. D37 is the record
2055
+ * of what ONE money term reaching three of four paths costs: the buyer was
2056
+ * shown one number, charged another, and the order recorded the first.
2057
+ *
2058
+ * 🛑 A preview NEVER throws, so an unpriced arrangement contributes 0 here
2059
+ * rather than raising — `quoteSharedPurchase` reports it on `unpriced` and
2060
+ * the ORDER path is what refuses. A preview that threw would render the
2061
+ * whole cart as an error page for a seller's missing config.
2062
+ */
2063
+ const groupSharedPurchaseFee = quoteSharedPurchase({
2064
+ destinationCount: countDestinations(group.map(({ item }) => item)),
2065
+ rates: previewSharedRates,
2066
+ }).total;
1620
2067
  const { shippingProtectionFee: groupShipmentProtectionFee, saleProtectionFee: groupSaleProtectionFee, } = computeProtectionFees({
1621
2068
  subtotal: groupTotal,
1622
2069
  selected: {
@@ -1681,6 +2128,7 @@ export async function previewCheckoutPricing(input) {
1681
2128
  shippingFee: groupShippingFee,
1682
2129
  codHandlingFee: groupCodHandlingFee,
1683
2130
  emiSurcharge: groupEmiSurcharge,
2131
+ emiDeferred: groupEmiDeferred,
1684
2132
  giftWrapFee: groupGiftWrapFee,
1685
2133
  shipmentProtectionFee: groupShipmentProtectionFee,
1686
2134
  saleProtectionFee: groupSaleProtectionFee,
@@ -1701,24 +2149,44 @@ export async function previewCheckoutPricing(input) {
1701
2149
  groupEmiSurcharge +
1702
2150
  groupGiftWrapFee +
1703
2151
  groupShipmentProtectionFee +
1704
- groupSaleProtectionFee,
2152
+ groupSaleProtectionFee +
2153
+ // S5a — a term in the per-store total, so the seller card and the
2154
+ // order it previews agree.
2155
+ groupSharedPurchaseFee,
1705
2156
  supportsShippingProtection: storeProtections.shipping,
1706
2157
  supportsSaleProtection: storeProtections.sale,
1707
2158
  hasFinalSaleItem: groupHasFinalSale,
2159
+ acceptsPoints,
2160
+ sharedPurchaseFee: groupSharedPurchaseFee,
2161
+ // Filled by the tender pass below — it needs each store's share of the
2162
+ // one capped commission, which is only allocatable once every group is
2163
+ // known.
2164
+ pointsApplied: 0,
1708
2165
  });
1709
2166
  }
1710
2167
  const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
1711
2168
  const shippingFee = sum((s) => s.shippingFee);
1712
2169
  const codHandlingFee = sum((s) => s.codHandlingFee);
1713
2170
  const emiSurcharge = sum((s) => s.emiSurcharge);
2171
+ const emiDeferred = sum((s) => s.emiDeferred);
1714
2172
  const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
1715
2173
  const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
1716
2174
  const saleProtectionFeeTotal = sum((s) => s.saleProtectionFee);
2175
+ // S5a — summed from the same per-store figures, so the aggregate cannot
2176
+ // disagree with the seller cards it is built from.
2177
+ const sharedPurchaseFeeTotal = sum((s) => s.sharedPurchaseFee);
1717
2178
  const gstAmount = sum((s) => s.gstAmount);
1718
2179
  const couponDiscount = sum((s) => s.couponDiscount);
1719
2180
  // One capped commission for the whole checkout, on every payment method —
1720
2181
  // deliberately not per store, so it is not added to the per-store rows above.
1721
- const { platformFee, gstOnFee } = computeCheckoutFees(cartSubtotal, commissions);
2182
+ // The whole object is kept (not just the two fields) because the points
2183
+ // tender below re-allocates it per store through `allocateCheckoutFees`,
2184
+ // which takes a `CheckoutFees` — and `totalFee` carries the
2185
+ // `minimumTransactionFee` floor, which is exactly the field D41 found had no
2186
+ // consumer anywhere. Destructuring it away here would be how it loses its
2187
+ // second one.
2188
+ const checkoutFees = computeCheckoutFees(cartSubtotal, commissions);
2189
+ const { platformFee, gstOnFee } = checkoutFees;
1722
2190
  /*
1723
2191
  * 🛑 `gstAmount` is NOT a term, and leaving it in was a shown-vs-charged
1724
2192
  * drift waiting on a settings flag.
@@ -1751,21 +2219,77 @@ export async function previewCheckoutPricing(input) {
1751
2219
  giftWrapFeeTotal +
1752
2220
  shipmentProtectionFeeTotal +
1753
2221
  saleProtectionFeeTotal +
2222
+ // S5a — a real additive charge, so it belongs in the column with the other
2223
+ // fees rather than below the total like the tax-inclusive `gstAmount`.
2224
+ sharedPurchaseFeeTotal +
1754
2225
  platformFee +
1755
2226
  gstOnFee;
2227
+ /*
2228
+ * ── THE POINTS TENDER, PREVIEWED (D46/D47) ────────────────────────────────
2229
+ *
2230
+ * 🛑 Applied PER STORE against the SAME base the order path uses, which is
2231
+ * why this runs here and not inside the loop above: the base includes each
2232
+ * store's share of the one capped commission, and that share is only
2233
+ * allocatable once every group is known.
2234
+ *
2235
+ * 🛑 Per store is not a detail — it is the only shape that can agree with
2236
+ * what gets charged. A cart-level application gives the same answer as the
2237
+ * per-group one whenever every store accepts (the sum of per-group 50% caps
2238
+ * equals 50% of the sum), and a DIFFERENT answer the moment one store does
2239
+ * not: with ₹500 at an accepting store, ₹500 at a declining one and a 600
2240
+ * balance, per-store applies ₹250 and cart-level would promise ₹500. The
2241
+ * buyer would be shown a discount half of which could never be honoured.
2242
+ *
2243
+ * The iteration order matches `orderGroups`, and so does the order-creation
2244
+ * loop, so the budget is drawn down in the same sequence in both — which is
2245
+ * what makes the two sums identical rather than merely close.
2246
+ */
2247
+ const previewUseAllPoints = cart.useAllPoints === true;
2248
+ const previewBalance = previewUseAllPoints ? await safePointsBalance(uid) : 0;
2249
+ if (previewUseAllPoints && previewBalance > 0) {
2250
+ const feeByStore = allocateCheckoutFees(stores.map((s) => [s.storeId, s.subtotal]), checkoutFees);
2251
+ const { perGroup } = allocatePointsAcrossGroups(stores.map((s) => {
2252
+ const share = feeByStore.get(s.storeId);
2253
+ return {
2254
+ // The SAME base `createOrderForGroup` applies the tender to: the
2255
+ // store's charged total plus its share of the one capped commission.
2256
+ // `store.total` deliberately excludes that share (it is a checkout
2257
+ // charge, not a per-store one), so it is added back here rather than
2258
+ // by widening a published field.
2259
+ total: roundRupees(s.total + (share?.platformFee ?? 0) + (share?.gstOnFee ?? 0)),
2260
+ accepted: s.acceptsPoints,
2261
+ };
2262
+ }), previewBalance, true);
2263
+ perGroup.forEach((applied, i) => {
2264
+ stores[i].pointsApplied = applied;
2265
+ });
2266
+ }
2267
+ const pointsApplied = sum((s) => s.pointsApplied);
1756
2268
  return {
1757
2269
  subtotal: cartSubtotal,
1758
2270
  shippingFee,
1759
2271
  codHandlingFee,
1760
2272
  emiSurcharge,
2273
+ emiDeferred,
1761
2274
  giftWrapFee: giftWrapFeeTotal,
1762
2275
  shipmentProtectionFee: shipmentProtectionFeeTotal,
1763
2276
  saleProtectionFee: saleProtectionFeeTotal,
2277
+ sharedPurchaseFee: sharedPurchaseFeeTotal,
1764
2278
  gstAmount,
1765
2279
  couponDiscount,
1766
2280
  platformFee,
1767
2281
  gstOnFee,
1768
2282
  total,
2283
+ pointsApplied,
2284
+ // Equal to `total` when no points apply, so every existing reader of
2285
+ // either figure keeps getting the right number.
2286
+ /*
2287
+ * 🛑 Both tenders, through the SAME helper the two order writers use —
2288
+ * so the figure the buyer is shown and the figure they are charged are
2289
+ * one expression, not two that agree today.
2290
+ */
2291
+ amountDue: amountDueNow({ orderTotal: total, pointsApplied, emiRemainingBalance: emiDeferred }),
2292
+ pointsBalance: previewBalance,
1769
2293
  stores,
1770
2294
  };
1771
2295
  }
@@ -1916,7 +2440,16 @@ async function refundDroppedItemsForPhonePeCheckout(input) {
1916
2440
  * from the inline version this replaced.
1917
2441
  */
1918
2442
  async function createPhonePeGroupOrder(group, orderType, ctx) {
1919
- const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, merchantOrderId, phonepeOrderId, transactionId, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: phonepeProductById, buyerState, } = ctx;
2443
+ const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, merchantOrderId, phonepeOrderId, transactionId, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: phonepeProductById, buyerState, useAllPoints, pointsBudget,
2444
+ /*
2445
+ * 🛑 All three were DECLARED on the params above and never destructured,
2446
+ * so the body referenced them as free names: `emiOptIn`/`emiTenureMonths`
2447
+ * in the schedule call and `deliverySnapshot` in `placeOfSupply`. The
2448
+ * interface promised them and the function could not see them — which is
2449
+ * why appkit's own `tsc` is the gate for this file and not a reading of
2450
+ * the diff.
2451
+ */
2452
+ emiOptIn, emiTenureMonths, deliverySnapshot, } = ctx;
1920
2453
  /*
1921
2454
  * Which online gateway actually took the money, read off the registered
1922
2455
  * provider rather than passed in — exactly one is registered per process
@@ -1932,7 +2465,21 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1932
2465
  // seller group here, duplicated from resolveShippingCost.
1933
2466
  // D44: `groupTotal` is computed just above — pass it so a free-above
1934
2467
  // threshold can fire and an unpriceable store is recorded.
1935
- const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupTotal });
2468
+ const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections, acceptsPoints, sharedPurchaseRates, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId, { goodsValue: groupTotal });
2469
+ /*
2470
+ * The instalment schedule for THIS seller group, through the same helper the
2471
+ * COD/manual writer uses — not a copy of its block. Two hand-written
2472
+ * copies is the create-vs-create axis of Root Cause #39, and these two
2473
+ * functions have already drifted once over add-on fees (#65).
2474
+ *
2475
+ * 🛑 Gate FAILURES are silent here, unlike the manual path, and that is
2476
+ * deliberate rather than sloppy: the money is already captured by the time
2477
+ * this runs, so throwing would produce a charged-with-no-order (the D64
2478
+ * class). `/api/payment/create-order` applies the same gate BEFORE capture,
2479
+ * which is where a refusal belongs; if it declined EMI, it captured the full
2480
+ * total and no schedule is wanted here either.
2481
+ */
2482
+ const phonepeEmiSchedule = groupEmiSchedule({ emiOptIn, emiTenureMonths, groupSubtotal: groupTotal, sellerEmiEnabled: storeEmiEnabled }, siteSettings?.emi);
1936
2483
  /*
1937
2484
  * Same supplier-of-record rule as `createOrderForGroup`, through the same
1938
2485
  * function. This path (PhonePe verify / webhook) is the SECOND place an
@@ -2050,20 +2597,82 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2050
2597
  /* Product GST is not a term — prices are tax-inclusive, so it is already
2051
2598
  * inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
2052
2599
  * stays because that is GST on our fee, charged on top. */
2600
+ /*
2601
+ * ── S5a: the shared-purchase arrangement, matching the COD/manual path ────
2602
+ *
2603
+ * Same function, same inputs, same position — before `orderTotal`, because
2604
+ * it is a term in it. See the fuller 🛑 at the COD/manual site for why the
2605
+ * rates are snapshotted and why an unpriced arrangement is refused rather
2606
+ * than quoted at ₹0.
2607
+ *
2608
+ * 🛑 On THIS path the money is already captured by the time this runs, so a
2609
+ * refusal here means a charged-with-no-order — the D64 class. That is why
2610
+ * `/api/payment/create-order` must apply the same term: the pre-payment
2611
+ * amount and this total have to agree, which is the parity C0c defect 1 was
2612
+ * about and the reason every addition here has to be made in three places.
2613
+ */
2614
+ const phonepeSharedQuote = quoteSharedPurchase({
2615
+ destinationCount: countDestinations(group.map(({ item }) => item)),
2616
+ rates: sharedPurchaseRates,
2617
+ });
2053
2618
  // D42: rounded once on the sum — see the COD/UPI path's 🛑 for why a raw
2054
2619
  // float sum of seven 2dp terms reached Firestore as 1011.8000000000001.
2055
- const orderTotal = roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst);
2620
+ const orderTotal = roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (phonepeEmiSchedule?.surchargeAmount ?? 0) + phonepeSharedQuote.total);
2621
+ /*
2622
+ * ── POINTS TENDER (D46/D47) — the same STAGE as the COD/manual path ───────
2623
+ *
2624
+ * Same four inputs, same function, same point in the sequence: immediately
2625
+ * after `orderTotal`'s single `roundRupees` and before anything is written.
2626
+ * Computing it here rather than in a shared builder is unavoidable — the two
2627
+ * paths derive `orderTotal` separately — but the ARITHMETIC is
2628
+ * `resolvePointsTender` in both, so they cannot disagree about the amount
2629
+ * the way the add-on fees once did (Root Cause #65).
2630
+ *
2631
+ * 🛑 This is the path whose capture matters most: `amountDue` is what the
2632
+ * Razorpay order is created for, so a divergence here is money taken that
2633
+ * the order does not record.
2634
+ */
2635
+ const pointsOptedIn = useAllPoints === true;
2636
+ const pointsAccepted = pointsOptedIn && acceptsPoints;
2637
+ const { pointsApplied } = resolvePointsTender({
2638
+ orderTotal,
2639
+ balance: pointsAccepted ? pointsBudget.remaining : 0,
2640
+ accepted: pointsAccepted,
2641
+ optedIn: pointsOptedIn,
2642
+ });
2643
+ if (pointsApplied > 0)
2644
+ pointsBudget.remaining -= pointsApplied;
2645
+ /*
2646
+ * 🛑 `resolvePointsTender`'s own `amountDue` is DISCARDED here — it knows
2647
+ * only about points, and on an instalment order the deferred balance reduces
2648
+ * the collection too. `amountDueNow` composes both, and is the same
2649
+ * expression the manual writer and the pricing preview use, so the figure
2650
+ * recorded in `paymentRecord.amount` below cannot drift from the figure
2651
+ * `/api/payment/create-order` actually captured.
2652
+ */
2653
+ const amountDue = amountDueNow({
2654
+ orderTotal,
2655
+ pointsApplied,
2656
+ emiRemainingBalance: phonepeEmiSchedule?.remainingBalance ?? 0,
2657
+ });
2056
2658
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
2057
2659
  // Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
2058
2660
  // the price rule (`isBundle ? item.price : product.price`) that — like the
2059
2661
  // two in the consumer's checkout route — silently omitted `lockedPrice`, so
2060
2662
  // an accepted offer paid through PhonePe was RECORDED at the seller's list
2061
2663
  // price. `lineTotalFor` inside the expansion is the single definition.
2062
- const orderItems = group.flatMap(({ item, product }) => {
2664
+ const basePhonepeItems = group.flatMap(({ item, product }) => {
2063
2665
  const lt = (product?.listingType ?? "standard");
2064
2666
  const itemRule = getListingRule(lt);
2065
2667
  return expandCartLineToOrderRows(item, product, phonepeProductById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
2066
2668
  });
2669
+ /*
2670
+ * Part 14 — the parts freeze, on THIS path too. The comment four lines
2671
+ * above records the cost of the last thing added to one path and not the
2672
+ * other: an accepted offer paid through PhonePe was recorded at the
2673
+ * seller's list price.
2674
+ */
2675
+ const orderItems = await freezePartsOnItems(basePhonepeItems);
2067
2676
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
2068
2677
  const imageUrls = [
2069
2678
  ...new Set(group
@@ -2089,6 +2698,16 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2089
2698
  quantity: totalQuantity,
2090
2699
  unitPrice: group[0].product.price,
2091
2700
  totalPrice: orderTotal,
2701
+ // D46/D47 — see the identical pair in `createOrderForGroup`. `totalPrice`
2702
+ // stays the full value of the supply; points reduce what is COLLECTED.
2703
+ pointsApplied: pointsApplied > 0 ? pointsApplied : undefined,
2704
+ /*
2705
+ * Written whenever it differs from the total — points OR instalments. It
2706
+ * was `pointsApplied > 0 ? … : undefined`, so an EMI order carried no
2707
+ * `amountDue` and every reader fell back to the full total.
2708
+ */
2709
+ amountDue: amountDue !== orderTotal ? amountDue : undefined,
2710
+ ...emiOrderFields(phonepeEmiSchedule, emiTenureMonths),
2092
2711
  currency: firstItem.currency ?? getDefaultCurrency(),
2093
2712
  storeId: firstItem.storeId || undefined,
2094
2713
  storeName: firstItem.storeName || undefined,
@@ -2120,7 +2739,15 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2120
2739
  paymentRecord: {
2121
2740
  method: gatewayName === "razorpay" ? "razorpay" : "phonepe",
2122
2741
  transactionId: transactionId ?? merchantOrderId,
2123
- amount: orderTotal,
2742
+ /*
2743
+ * 🛑 What the gateway actually CAPTURED, which is `amountDue` once points
2744
+ * are applied — not the order's value. This record is the evidence a
2745
+ * reconciler compares against the gateway's own figure (Phase 4 R2), so
2746
+ * writing `orderTotal` here would open an `amount_mismatch` case on every
2747
+ * points-funded order: the discount would read as money we failed to
2748
+ * collect.
2749
+ */
2750
+ amount: amountDue,
2124
2751
  paidAt: new Date(),
2125
2752
  verifiedBy: `${gatewayName}-order-status`,
2126
2753
  verificationMethod: "order-status",
@@ -2148,6 +2775,14 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2148
2775
  // Recorded as well as charged — an order that collects a protection fee
2149
2776
  // without a record of it cannot be reconciled against a later claim.
2150
2777
  saleProtectionFee: saleProtectionFee > 0 ? saleProtectionFee : undefined,
2778
+ // S5a — snapshotted, matching the COD/manual path field for field.
2779
+ sharedPurchase: phonepeSharedQuote.isShared
2780
+ ? {
2781
+ destinationCount: countDestinations(group.map(({ item }) => item)),
2782
+ handlingFee: phonepeSharedQuote.handlingFee,
2783
+ extraShippingFee: phonepeSharedQuote.extraShippingFee,
2784
+ }
2785
+ : undefined,
2151
2786
  /*
2152
2787
  * The tax breakdown, matching `createOrderForGroup` field for field.
2153
2788
  * This path recorded NONE of it before C0c defect 1 — so an online order
@@ -2158,6 +2793,15 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2158
2793
  */
2159
2794
  taxableAmount: phonepeGst?.taxableAmount,
2160
2795
  exemptAmount: phonepeGst?.exemptAmount || undefined,
2796
+ // Phase 7 — the place of supply, matching `createOrderForGroup` field for
2797
+ // field. This path recorded NONE of the tax breakdown until C0c defect 1,
2798
+ // which is precisely why every addition here has to be made twice.
2799
+ /*
2800
+ * Part 14 — the snapshot's state is authoritative, because it is the
2801
+ * field the invoice and GSTR-1 actually read. `buyerState` stays as
2802
+ * the fallback for a digital cart, which has no delivery address.
2803
+ */
2804
+ placeOfSupply: deliverySnapshot?.deliveryState || buyerState || undefined,
2161
2805
  // Our registration at the moment of supply — see the schema. Mirrors the
2162
2806
  // COD/UPI path so both produce an invoiceable order.
2163
2807
  supplierGstin: supplier.gstin,
@@ -2177,6 +2821,9 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2177
2821
  ...extraOrderFields,
2178
2822
  });
2179
2823
  orderIds.push(order.id);
2824
+ // The second half of the points tender — one implementation, shared with the
2825
+ // COD/manual path. See `settlePointsTender`.
2826
+ await settlePointsTender({ orderId: order.id, userId: uid, pointsApplied, pointsBudget });
2180
2827
  // Close the loop on any locked line this order settled: flip the offer to
2181
2828
  // "paid" (its terminal status had NO server-side writer before 2026-08-21,
2182
2829
  // so an accepted offer could be re-added and re-ordered) and back-link the
@@ -2426,6 +3073,7 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2426
3073
  await assertLockedLinesStillValid(settleItems, uid);
2427
3074
  const isDigitalCartPp = cartIsDigitalOnly(settleItems);
2428
3075
  let shippingAddress;
3076
+ let deliverySnapshot;
2429
3077
  let resolvedAddressPp = null;
2430
3078
  if (!isDigitalCartPp) {
2431
3079
  if (!addressId) {
@@ -2440,6 +3088,13 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2440
3088
  throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
2441
3089
  }
2442
3090
  shippingAddress = formatShippingAddress(resolvedAddressPp);
3091
+ /*
3092
+ * 🛑 REVEALED first. `findById` returns this PII encrypted, and
3093
+ * `snapshotDeliveryAddress` demands `AddressPlain` precisely so that
3094
+ * freezing `enc:v1:…` onto an invoice is a compile error rather than
3095
+ * something discovered on a printed label.
3096
+ */
3097
+ deliverySnapshot = snapshotDeliveryAddress(unitOfWork.addresses.revealAddress(resolvedAddressPp));
2443
3098
  }
2444
3099
  // SB-UNI-5 2026-05-13 — bundle-aware product fetch + validation. Each
2445
3100
  // cart line gets paired with a "representative" product (first member id
@@ -2538,7 +3193,12 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2538
3193
  // percentages, so the moment the cap landed it would have expected more
2539
3194
  // than was actually collected and rejected every legitimate payment.
2540
3195
  const commissionRates = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
2541
- const { platformFee: expectedPlatformFee, gstOnFee: expectedGstOnFee } = computeCheckoutFees(cartSubtotalRs, commissionRates);
3196
+ // The whole object is kept so the points tender below can re-allocate it
3197
+ // per group — see the same note in `previewCheckoutPricing`. `totalFee`
3198
+ // carries the `minimumTransactionFee` floor, and destructuring it away is
3199
+ // how it lost every consumer once already (D41).
3200
+ const expectedFees = computeCheckoutFees(cartSubtotalRs, commissionRates);
3201
+ const { platformFee: expectedPlatformFee, gstOnFee: expectedGstOnFee } = expectedFees;
2542
3202
  // Same per-seller-group shipping sum /api/payment/create-order charged
2543
3203
  // upfront (and the same resolveShippingCost createPhonePeGroupOrder
2544
3204
  // below will use to build each order's recorded totalPrice) — keeps this
@@ -2584,6 +3244,36 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2584
3244
  goodsValue: g.items.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
2585
3245
  })));
2586
3246
  const expectedShippingFee = expectedShippingFees.reduce((sum, r) => sum + r.shippingFee, 0);
3247
+ /*
3248
+ * ── The instalment terms, which this guard must expect BOTH of ──────────
3249
+ *
3250
+ * 🛑 Two terms, pulling in opposite directions, and omitting either one
3251
+ * breaks the guard in the direction that costs the buyer an order:
3252
+ *
3253
+ * + surcharge — the cost of credit, a real addition to the total
3254
+ * − deferred — the financed principal plus that surcharge, which is NOT
3255
+ * collected today
3256
+ *
3257
+ * With neither term the guard would expect the un-surcharged FULL total
3258
+ * while the gateway captured only the token, trip `paid < expected − 1`,
3259
+ * and throw **after the money was taken** — a charged-with-no-order, the
3260
+ * D64 class, on every single EMI order. That is strictly worse than the
3261
+ * overcharge this guard exists to catch, which is the same trap D44's note
3262
+ * above records one defect over.
3263
+ *
3264
+ * Built from the same per-group goods values and the same `groupEmiQuote`
3265
+ * the capture route and the order writer call, so the only way the three
3266
+ * can disagree is a change made to one and not the others.
3267
+ */
3268
+ const guardEmiPlanTenure = cart.emiPlan?.tenureMonths;
3269
+ const expectedEmiQuotes = expectedShippingGroups.map((g, i) => groupEmiQuote({
3270
+ emiOptIn: guardEmiPlanTenure !== undefined,
3271
+ emiTenureMonths: guardEmiPlanTenure,
3272
+ groupSubtotal: g.items.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
3273
+ sellerEmiEnabled: expectedShippingFees[i]?.storeEmiEnabled === true,
3274
+ }, siteSettings?.emi));
3275
+ const expectedEmiSurcharge = expectedEmiQuotes.reduce((sum, q) => sum + (q?.surchargeAmount ?? 0), 0);
3276
+ const expectedEmiDeferred = expectedEmiQuotes.reduce((sum, q) => sum + (q?.remainingBalance ?? 0), 0);
2587
3277
  // Add-ons are per store, so the expected total sums each group's own
2588
3278
  // selection — a single cart-wide figure would over- or under-count as soon
2589
3279
  // as the buyer picked add-ons for only some sellers.
@@ -2624,11 +3314,70 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2624
3314
  * would make this guard demand ~₹580 more than the buyer owes on a
2625
3315
  * ₹5,547 cart — rejecting every correctly-charged order.
2626
3316
  */
2627
- const expectedPaymentAmountRs = expectedDiscountedSubtotal +
3317
+ /*
3318
+ * ── D46/D47 — the guard must expect the amount NET OF POINTS ─────────────
3319
+ *
3320
+ * 🛑 Omitting this term would make the guard reject every correctly-charged
3321
+ * points order, which is worse than the overcharge it exists to catch: the
3322
+ * money is already captured by the time this runs, so a false rejection is
3323
+ * a charged-with-no-order — the D64 class.
3324
+ *
3325
+ * The bases are rebuilt here from the same terms in the same order, and the
3326
+ * draw-down is the same shared allocator, so the only way this can diverge
3327
+ * from the capture route is a change made to one and not the other. That is
3328
+ * precisely the drift the four-way export comment on
3329
+ * `allocatePointsAcrossGroups` exists to make visible.
3330
+ */
3331
+ const guardUseAllPoints = cart.useAllPoints === true;
3332
+ let expectedPointsApplied = 0;
3333
+ if (guardUseAllPoints) {
3334
+ const guardBalance = await safePointsBalance(uid);
3335
+ if (guardBalance > 0) {
3336
+ const guardSubtotals = expectedShippingGroups.map((g) => [
3337
+ g.items[0].item.storeId,
3338
+ g.items.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0),
3339
+ ]);
3340
+ const guardFeeByGroup = allocateCheckoutFees(guardSubtotals, expectedFees);
3341
+ const guardCouponByGroup = computeCartCouponDiscount(cart.appliedCoupons ?? [], expectedShippingGroups.map((g) => ({
3342
+ storeId: g.items[0].item.storeId,
3343
+ lines: g.items.map(({ item, product }) => ({
3344
+ itemId: item.itemId,
3345
+ lineTotal: lineTotalFor(item, product),
3346
+ })),
3347
+ }))).perGroup;
3348
+ /*
3349
+ * The per-group add-on fee, lifted out of the `map` below so that
3350
+ * callback has no block body. Its statements sat at brace-depth 6
3351
+ * inside `if → if → callback`, which DEEP_NESTING blocks — and the
3352
+ * add-on sum is the one part of this base the sibling money path
3353
+ * (`POST /api/payment/create-order`) computes earlier rather than
3354
+ * inline, so it is also the natural seam.
3355
+ */
3356
+ const guardAddonFee = (storeId, goods) => {
3357
+ const addons = cart.storeAddons?.[storeId] ?? {};
3358
+ return (computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
3359
+ computeShipmentProtectionFee(goods, addons.shipmentProtectionAddon ?? false, commissionRates));
3360
+ };
3361
+ const guardBases = expectedShippingGroups.map((g, i) => pointsBaseForGroup({
3362
+ goods: guardSubtotals[i][1],
3363
+ couponDiscount: guardCouponByGroup[i]?.couponDiscount ?? 0,
3364
+ shippingFee: expectedShippingFees[i]?.shippingFee ?? 0,
3365
+ addonFee: guardAddonFee(g.items[0].item.storeId, guardSubtotals[i][1]),
3366
+ platformFee: guardFeeByGroup.get(g.items[0].item.storeId)?.platformFee ?? 0,
3367
+ gstOnFee: guardFeeByGroup.get(g.items[0].item.storeId)?.gstOnFee ?? 0,
3368
+ acceptsPoints: expectedShippingFees[i]?.acceptsPoints === true,
3369
+ }));
3370
+ expectedPointsApplied = allocatePointsAcrossGroups(guardBases, guardBalance, true).pointsApplied;
3371
+ }
3372
+ }
3373
+ const expectedPaymentAmountRs = roundRupees(Math.max(0, expectedDiscountedSubtotal +
2628
3374
  expectedPlatformFee +
2629
3375
  expectedGstOnFee +
2630
3376
  expectedAddonFees +
2631
- expectedShippingFee;
3377
+ expectedShippingFee +
3378
+ expectedEmiSurcharge -
3379
+ expectedEmiDeferred -
3380
+ expectedPointsApplied));
2632
3381
  // Reuses the order already fetched above to confirm COMPLETED — PhonePe's
2633
3382
  // 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
2634
3383
  const paidAmountRs = paiseToRupees(gatewayOrder.amount);
@@ -2703,6 +3452,19 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2703
3452
  g[0].item.storeId,
2704
3453
  g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
2705
3454
  ]), computeCheckoutFees(cartSubtotal, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS));
3455
+ /*
3456
+ * The points budget for this checkout — one read, zero when not opted in.
3457
+ * See the identical pair in the COD/manual path for why it is a shared
3458
+ * remainder rather than a per-group balance read.
3459
+ *
3460
+ * 🛑 On THIS path the money has already been captured by the time these
3461
+ * orders are created, so the budget must match whatever the pre-payment
3462
+ * amount was computed against. That is why `useAllPoints` is read off the
3463
+ * cart here too and not re-decided: the figure quoted to the gateway and the
3464
+ * figure recorded on the orders come from the same cart flag.
3465
+ */
3466
+ const useAllPoints = cart.useAllPoints === true;
3467
+ const pointsBudget = { remaining: useAllPoints ? await safePointsBalance(uid) : 0 };
2706
3468
  for (const { items: group, orderType } of orderGroups) {
2707
3469
  total += await createPhonePeGroupOrder(group, orderType, {
2708
3470
  appliedCoupons,
@@ -2728,6 +3490,15 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2728
3490
  emailsToSend,
2729
3491
  couponUsageAccumulator,
2730
3492
  productById: productByIdPaid,
3493
+ useAllPoints,
3494
+ pointsBudget,
3495
+ /*
3496
+ * Resolved from the CART, exactly as the capture route did — so the
3497
+ * schedule written onto the order is built from the same tenure the
3498
+ * gateway was asked to capture against.
3499
+ */
3500
+ emiOptIn: cart.emiPlan?.tenureMonths !== undefined,
3501
+ emiTenureMonths: cart.emiPlan?.tenureMonths,
2731
3502
  });
2732
3503
  }
2733
3504
  await flushCouponUsageAccumulator(couponUsageAccumulator, uid);