@mohasinac/appkit 4.62.1 → 4.63.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) 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 +51 -11
  8. package/dist/_internal/server/features/checkout/actions.js +335 -80
  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/offers/destinations.d.ts +40 -0
  22. package/dist/_internal/server/features/offers/destinations.js +65 -0
  23. package/dist/_internal/server/features/orders/adapters.js +21 -0
  24. package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
  25. package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
  26. package/dist/_internal/server/features/orders/balance.d.ts +89 -0
  27. package/dist/_internal/server/features/orders/balance.js +256 -0
  28. package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
  29. package/dist/_internal/server/features/pricing/actions.js +144 -0
  30. package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
  31. package/dist/_internal/server/features/shipping/book-split.js +130 -0
  32. package/dist/_internal/server/features/shipping/book.d.ts +61 -0
  33. package/dist/_internal/server/features/shipping/book.js +176 -0
  34. package/dist/_internal/server/features/shipping/courier-block.js +26 -1
  35. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  36. package/dist/_internal/server/features/site-settings/adapters.js +14 -0
  37. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  38. package/dist/_internal/server/features/stores/adapters.js +14 -0
  39. package/dist/_internal/server/functions/scheduled.d.ts +2 -1
  40. package/dist/_internal/server/functions/scheduled.js +57 -1
  41. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +2 -0
  42. package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
  43. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +4 -1
  44. package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
  45. package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
  46. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +15 -0
  47. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  48. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  49. package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
  50. package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
  51. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  52. package/dist/_internal/shared/actions/action-registry.js +268 -0
  53. package/dist/_internal/shared/checkout/order-math.d.ts +31 -2
  54. package/dist/_internal/shared/checkout/order-math.js +22 -36
  55. package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
  56. package/dist/_internal/shared/checkout/rules/index.js +1 -1
  57. package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
  58. package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
  59. package/dist/_internal/shared/checkout/split.d.ts +0 -12
  60. package/dist/_internal/shared/checkout/split.js +22 -2
  61. package/dist/_internal/shared/content/disclaimers.js +7 -7
  62. package/dist/_internal/shared/content/published-values.d.ts +6 -1
  63. package/dist/_internal/shared/content/published-values.js +8 -0
  64. package/dist/_internal/shared/features/admin/integrations.d.ts +32 -2
  65. package/dist/_internal/shared/features/admin/integrations.js +19 -9
  66. package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
  67. package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
  68. package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
  69. package/dist/_internal/shared/features/emi/choice.js +69 -0
  70. package/dist/_internal/shared/features/emi/group.d.ts +84 -0
  71. package/dist/_internal/shared/features/emi/group.js +126 -0
  72. package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
  73. package/dist/_internal/shared/features/ledger/posting.js +165 -0
  74. package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
  75. package/dist/_internal/shared/features/ledger/reports.js +127 -0
  76. package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
  77. package/dist/_internal/shared/features/ledger/rules.js +221 -0
  78. package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
  79. package/dist/_internal/shared/features/offers/coordination.js +80 -0
  80. package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
  81. package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
  82. package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
  83. package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
  84. package/dist/_internal/shared/features/points/accepts.d.ts +1 -8
  85. package/dist/_internal/shared/features/points/accepts.js +12 -6
  86. package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
  87. package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
  88. package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
  89. package/dist/_internal/shared/features/shipping/parcel.js +139 -0
  90. package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
  91. package/dist/_internal/shared/features/site-settings/fees.js +12 -8
  92. package/dist/_internal/shared/fees/explain.d.ts +81 -0
  93. package/dist/_internal/shared/fees/explain.js +101 -0
  94. package/dist/_internal/shared/history/index.d.ts +1 -1
  95. package/dist/_internal/shared/history/index.js +1 -1
  96. package/dist/_internal/shared/history/types.d.ts +7 -1
  97. package/dist/_internal/shared/history/types.js +25 -0
  98. package/dist/client.d.ts +11 -1
  99. package/dist/client.js +13 -0
  100. package/dist/constants/api-endpoints.d.ts +72 -0
  101. package/dist/constants/api-endpoints.js +41 -0
  102. package/dist/constants/field-names.d.ts +41 -0
  103. package/dist/constants/field-names.js +47 -0
  104. package/dist/features/account/components/UserOrdersView.js +28 -2
  105. package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
  106. package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
  107. package/dist/features/admin/components/AdminOrderEditorView.d.ts +16 -1
  108. package/dist/features/admin/components/AdminOrderEditorView.js +72 -3
  109. package/dist/features/admin/components/AdminOrdersView.js +20 -1
  110. package/dist/features/admin/schemas/firestore.d.ts +32 -0
  111. package/dist/features/ads/schemas/booking-write.d.ts +157 -0
  112. package/dist/features/ads/schemas/booking-write.js +88 -0
  113. package/dist/features/ads/schemas/creative-form.d.ts +18 -0
  114. package/dist/features/ads/schemas/creative-form.js +51 -0
  115. package/dist/features/ads/schemas/firestore.d.ts +140 -0
  116. package/dist/features/ads/schemas/firestore.js +109 -0
  117. package/dist/features/auctions/repository/bid.repository.js +3 -1
  118. package/dist/features/audit-log/schemas/firestore.d.ts +110 -0
  119. package/dist/features/audit-log/schemas/firestore.js +74 -0
  120. package/dist/features/auth/actions/profile-actions.d.ts +69 -1
  121. package/dist/features/auth/actions/profile-actions.js +62 -1
  122. package/dist/features/auth/schemas/firestore.d.ts +16 -2
  123. package/dist/features/auth/schemas/firestore.js +2 -1
  124. package/dist/features/blog/actions/blog-actions.d.ts +6 -0
  125. package/dist/features/blog/actions/blog-actions.js +17 -2
  126. package/dist/features/blog/api/route.js +4 -1
  127. package/dist/features/blog/columns/index.js +5 -0
  128. package/dist/features/blog/components/BlogIndexPageView.js +2 -1
  129. package/dist/features/blog/repository/blog.repository.d.ts +4 -0
  130. package/dist/features/blog/repository/blog.repository.js +5 -0
  131. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  132. package/dist/features/blog/schemas/blog-form.js +1 -0
  133. package/dist/features/blog/schemas/firestore.d.ts +38 -1
  134. package/dist/features/blog/schemas/firestore.js +23 -0
  135. package/dist/features/blog/schemas/index.d.ts +6 -0
  136. package/dist/features/blog/schemas/index.js +2 -0
  137. package/dist/features/blog/types/index.d.ts +2 -0
  138. package/dist/features/cart/components/CartPriceBreakdown.d.ts +12 -1
  139. package/dist/features/cart/components/CartPriceBreakdown.js +24 -5
  140. package/dist/features/cart/repository/cart.repository.d.ts +14 -0
  141. package/dist/features/cart/repository/cart.repository.js +20 -0
  142. package/dist/features/cart/schemas/firestore.d.ts +24 -0
  143. package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
  144. package/dist/features/dashboard/components/DashboardHubView.js +56 -0
  145. package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
  146. package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
  147. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
  148. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
  149. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +24 -0
  150. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +28 -0
  151. package/dist/features/dispute-reasons/schemas/firestore.d.ts +3 -5
  152. package/dist/features/events/components/EventSaleBanner.js +1 -1
  153. package/dist/features/faq/components/FAQPageContent.js +2 -2
  154. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  155. package/dist/features/homepage/schemas/index.d.ts +8 -8
  156. package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
  157. package/dist/features/inventory/schemas/stock-reservation.js +103 -0
  158. package/dist/features/layout/AppLayoutShell.d.ts +9 -1
  159. package/dist/features/layout/AppLayoutShell.js +12 -4
  160. package/dist/features/layout/BottomActions.js +2 -1
  161. package/dist/features/layout/BottomNavbar.d.ts +10 -2
  162. package/dist/features/layout/SideActionDock.js +52 -2
  163. package/dist/features/layout/TitleBarLayout.js +13 -4
  164. package/dist/features/layout/TitleBarLayout.style.css +59 -0
  165. package/dist/features/layout/useCtaReveal.d.ts +16 -1
  166. package/dist/features/layout/useCtaReveal.js +17 -26
  167. package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
  168. package/dist/features/ledger/repository/journal.repository.js +312 -0
  169. package/dist/features/ledger/schemas/expenses.d.ts +87 -0
  170. package/dist/features/ledger/schemas/expenses.js +27 -0
  171. package/dist/features/ledger/schemas/firestore.d.ts +183 -0
  172. package/dist/features/ledger/schemas/firestore.js +69 -0
  173. package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
  174. package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
  175. package/dist/features/orders/components/RaiseDisputeForm.js +19 -11
  176. package/dist/features/orders/repository/orders.repository.d.ts +28 -1
  177. package/dist/features/orders/repository/orders.repository.js +60 -6
  178. package/dist/features/orders/schemas/balance.d.ts +40 -0
  179. package/dist/features/orders/schemas/balance.js +47 -0
  180. package/dist/features/orders/schemas/firestore.d.ts +202 -0
  181. package/dist/features/orders/schemas/firestore.js +43 -0
  182. package/dist/features/orders/schemas/index.d.ts +2 -2
  183. package/dist/features/orders/types/index.d.ts +21 -0
  184. package/dist/features/payments/repository/payout.repository.js +3 -2
  185. package/dist/features/payments/schemas/firestore.d.ts +92 -1
  186. package/dist/features/payments/schemas/firestore.js +23 -0
  187. package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
  188. package/dist/features/pricing/components/AdminPricingView.js +157 -0
  189. package/dist/features/products/schemas/firestore.d.ts +26 -1
  190. package/dist/features/products/schemas/firestore.js +2 -0
  191. package/dist/features/products/schemas/index.d.ts +2 -2
  192. package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
  193. package/dist/features/reviews/repository/reviews.repository.js +16 -0
  194. package/dist/features/scams/repository/scammer.repository.js +2 -1
  195. package/dist/features/scams/schemas/firestore.d.ts +2 -1
  196. package/dist/features/scams/schemas/firestore.js +2 -1
  197. package/dist/features/seller/actions/offer-actions.js +56 -0
  198. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  199. package/dist/features/seller/actions/seller-actions.js +4 -0
  200. package/dist/features/seller/components/DispatchForm.d.ts +11 -0
  201. package/dist/features/seller/components/DispatchForm.js +190 -0
  202. package/dist/features/seller/components/SellerOrdersView.js +94 -2
  203. package/dist/features/seller/components/index.d.ts +1 -0
  204. package/dist/features/seller/components/index.js +1 -0
  205. package/dist/features/seller/repository/offer.repository.js +4 -3
  206. package/dist/features/seller/schemas/firestore.d.ts +23 -2
  207. package/dist/features/seller/schemas/firestore.js +3 -2
  208. package/dist/features/seller/schemas/index.d.ts +4 -4
  209. package/dist/features/seller/schemas/order-forms.d.ts +61 -4
  210. package/dist/features/seller/schemas/order-forms.js +41 -8
  211. package/dist/features/shipments/schemas/firestore.d.ts +25 -0
  212. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +39 -0
  213. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +55 -0
  214. package/dist/features/shipping-boxes/schemas/firestore.d.ts +2 -4
  215. package/dist/features/stores/repository/store.repository.d.ts +18 -0
  216. package/dist/features/stores/repository/store.repository.js +27 -1
  217. package/dist/features/stores/schemas/firestore.d.ts +41 -0
  218. package/dist/features/stores/schemas/firestore.js +20 -0
  219. package/dist/features/stores/schemas/index.d.ts +6 -6
  220. package/dist/features/support/repository/support.repository.js +72 -6
  221. package/dist/features/support/schemas/firestore.d.ts +17 -0
  222. package/dist/features/support/schemas/index.d.ts +2 -2
  223. package/dist/index.d.ts +5 -0
  224. package/dist/index.js +19 -0
  225. package/dist/next/routing/route-map.d.ts +92 -0
  226. package/dist/next/routing/route-map.js +53 -0
  227. package/dist/react/contexts/SessionContext.d.ts +17 -1
  228. package/dist/schemas/registry.d.ts +22 -16
  229. package/dist/security/pii-schemas.d.ts +1 -1
  230. package/dist/security/pii-schemas.js +45 -0
  231. package/dist/server-entry.d.ts +1 -1
  232. package/dist/server-entry.js +10 -1
  233. package/dist/server.d.ts +29 -1
  234. package/dist/server.js +158 -0
  235. package/dist/styles.css +349 -1
  236. package/dist/tailwind-utilities.css +1 -1
  237. package/dist/ui/components/Button.d.ts +10 -1
  238. package/dist/ui/components/Button.js +25 -3
  239. package/dist/ui/components/Chart.d.ts +63 -0
  240. package/dist/ui/components/Chart.js +169 -0
  241. package/dist/ui/components/Chart.style.css +214 -0
  242. package/dist/ui/components/Disclaimer.js +10 -1
  243. package/dist/ui/components/OverflowRow.js +54 -2
  244. package/dist/ui/components/OverflowRow.style.css +38 -0
  245. package/dist/ui/components/RichTextEditor.js +99 -8
  246. package/dist/ui/components/chart-spec.d.ts +68 -0
  247. package/dist/ui/components/chart-spec.js +140 -0
  248. package/dist/ui/components/index.style.css +1 -0
  249. package/dist/ui/components/surface-tokens.d.ts +1 -1
  250. package/dist/ui/components/surface-tokens.js +9 -0
  251. package/dist/ui/components/toolbar-collapse.d.ts +13 -0
  252. package/dist/ui/components/toolbar-collapse.js +10 -1
  253. package/dist/ui/index.d.ts +6 -0
  254. package/dist/ui/index.js +3 -0
  255. package/dist/ui/rich-text/RichText.d.ts +10 -1
  256. package/dist/ui/rich-text/RichText.js +33 -2
  257. package/dist/ui/rich-text/RichText.style.css +35 -0
  258. package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
  259. package/dist/ui/rich-text/RichTextRenderer.js +34 -2
  260. package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
  261. package/dist/ui/rich-text/chart-blocks.js +87 -0
  262. package/package.json +1 -1
  263. package/scripts/seed-cli.mjs +3 -0
@@ -16,7 +16,7 @@ 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 { allocatePointsAcrossGroups, computePreOrderDepositAmount, lineTotalFor, resolvePointsTender, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
19
+ import { allocatePointsAcrossGroups, pointsBaseForGroup, computePreOrderDepositAmount, lineTotalFor, resolvePointsTender, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
20
20
  /*
21
21
  * D46/D47 — points as a checkout TENDER.
22
22
  *
@@ -50,11 +50,13 @@ import { assertCheckoutLane, assertLockedLinesStillValid, finalizeLockedLines, }
50
50
  import { activeLane, defaultCheckoutItems, resolveCheckoutItems, laneOf, } from "../../../shared/checkout/lanes.js";
51
51
  import { computeCodHandlingFee, computeGiftWrapFee, computeShipmentProtectionFee, computeSaleProtectionFee, computeCheckoutFees, allocateCheckoutFees, } from "../../../shared/fees/calculator.js";
52
52
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase/index.js";
53
+ import { snapshotDeliveryAddress, } from "../../../shared/features/orders/delivery-snapshot.js";
53
54
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore.js";
54
55
  import { CART_COLLECTION } from "../../../../features/cart/schemas/index.js";
55
56
  // S-SBUNI-RULES 2026-05-13 — bundle cart-line stock fan-out helpers.
56
57
  import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, isMultiMemberLine, } from "./bundle-expansion.js";
57
58
  import { expandCartLineToOrderRows } from "./expand-order-items.js";
59
+ import { freezePartsOnItems } from "./freeze-parts.js";
58
60
  import { OrderStatusValues, PaymentStatusValues, PaymentMethodValues, OutOfStockPolicyValues, } from "../../../../features/orders/schemas/index.js";
59
61
  import { getDefaultCurrency } from "../../../../core/index.js";
60
62
  import { getProviders } from "../../../../contracts/index.js";
@@ -67,10 +69,11 @@ import { verifyPaymentSignature as verifyRazorpayPaymentSignature } from "../../
67
69
  import { resolveKeys } from "../../../../core/integration-keys.js";
68
70
  import { paiseToRupees } from "../../../../core/money.js";
69
71
  import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config.js";
70
- 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";
71
74
  import { formatShippingAddress } from "./data.js";
72
75
  import { enforceMaxPerUserForCart } from "./prize-bundle-gates.js";
73
- import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, } from "../../../shared/checkout/rules/index.js";
76
+ import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, assertEmiOptInAllowed, } from "../../../shared/checkout/rules/index.js";
74
77
  // The `GroupCouponDiscountResult` type is deliberately NOT imported: every
75
78
  // call site here destructures the result inline, so naming the type would be
76
79
  // an unused import.
@@ -82,7 +85,7 @@ import { sendNotification } from "../../../../features/admin/actions/notificatio
82
85
  import { resolveDisplayedUpiId } from "./upi-resolution.js";
83
86
  import { resolveSupplierIdentity } from "./supplier-identity.js";
84
87
  import { PAYMENT_WINDOW_MS } from "../../../../features/orders/constants/payment-window.js";
85
- import { isCheckoutValueOtpVerified } from "../../../../features/checkout/actions/checkout-value-otp-actions.js";
88
+ import { assertCheckoutValueOtp } from "./value-otp-gate.js";
86
89
  const AUDIT_LOG_FAIL_MSG = "checkout: audit log failed (non-critical)";
87
90
  /**
88
91
  * How long a `gatewayOrderClaims` holder owns the right to place an order
@@ -733,7 +736,7 @@ async function settlePointsTender(input) {
733
736
  * so the caller can accumulate the checkout-wide `total`.
734
737
  */
735
738
  async function createOrderForGroup(group, orderType, ctx) {
736
- 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, useAllPoints, pointsBudget, } = 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;
737
740
  const firstItem = group[0].item;
738
741
  const firstProduct = group[0].product;
739
742
  // Add-ons are this store's choice, not the cart's — see OrderGroupContext.
@@ -767,11 +770,24 @@ async function createOrderForGroup(group, orderType, ctx) {
767
770
  // A multi-member line expands into one row PER MEMBER sharing a groupSlug, so
768
771
  // each row carries its own HSN/GST and can be cancelled independently; the
769
772
  // receipt still collapses them under one header.
770
- const orderItems = group.flatMap(({ item, product }) => {
773
+ const baseOrderItems = group.flatMap(({ item, product }) => {
771
774
  const lt = (product.listingType ?? "standard");
772
775
  const itemRule = getListingRule(lt);
773
776
  return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
774
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);
775
791
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
776
792
  /*
777
793
  * D44: the group's real goods value, so free-above-threshold can fire.
@@ -830,14 +846,30 @@ async function createOrderForGroup(group, orderType, ctx) {
830
846
  * simply did not exist yet. The split now sits immediately after
831
847
  * `orderTotal`, which is the only place every term is known.
832
848
  */
833
- let emiSchedule;
834
- if (paymentMethod === "emi" && emiTenureMonths) {
835
- const eligibility = checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings);
836
- if (!eligibility.eligible) {
837
- throw new ValidationError(`EMI is not available for this order (${eligibility.reason}). Minimum order value per seller is ₹${emiSettings.minOrderValue}.`);
838
- }
839
- 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}.`);
840
871
  }
872
+ const emiSchedule = groupEmiSchedule(emiGroupInput, emiSettings) ?? undefined;
841
873
  // Handling fee charged to the buyer for choosing COD, on top of the order
842
874
  // total — not deducted from the deposit/remaining split above.
843
875
  const codHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
@@ -1063,10 +1095,18 @@ async function createOrderForGroup(group, orderType, ctx) {
1063
1095
  // Deadline + displayed UPI apply only to methods with a buyer-uploaded
1064
1096
  // proof step — never cod (paid on delivery), online (paid before order
1065
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
+ */
1066
1107
  const hasPaymentWindow = !adminBypass &&
1067
1108
  (paymentMethod === PaymentMethodValues.UPI_MANUAL ||
1068
- paymentMethod === PaymentMethodValues.CASH ||
1069
- paymentMethod === PaymentMethodValues.EMI);
1109
+ paymentMethod === PaymentMethodValues.CASH);
1070
1110
  const paymentDeadline = hasPaymentWindow
1071
1111
  ? new Date(Date.now() + PAYMENT_WINDOW_MS)
1072
1112
  : undefined;
@@ -1111,6 +1151,16 @@ async function createOrderForGroup(group, orderType, ctx) {
1111
1151
  });
1112
1152
  if (pointsApplied > 0)
1113
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
+ });
1114
1164
  // Both creation paths call the SAME builder — two hand-written copies is the
1115
1165
  // create-vs-create axis of Root Cause #39, and these two functions have
1116
1166
  // already drifted once before (add-on fees, Root Cause #65).
@@ -1140,7 +1190,23 @@ async function createOrderForGroup(group, orderType, ctx) {
1140
1190
  * that predate this.
1141
1191
  */
1142
1192
  pointsApplied: pointsApplied > 0 ? pointsApplied : undefined,
1143
- amountDue: pointsApplied > 0 ? amountDue : 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,
1144
1210
  currency: firstItem.currency ?? getDefaultCurrency(),
1145
1211
  storeId: firstItem.storeId || undefined,
1146
1212
  storeName: firstItem.storeName || undefined,
@@ -1173,6 +1239,12 @@ async function createOrderForGroup(group, orderType, ctx) {
1173
1239
  displayedUpiId,
1174
1240
  adminBypassBy: adminBypassBy,
1175
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 ?? {}),
1176
1248
  notes,
1177
1249
  shippingFee: shippingFee > 0 ? shippingFee : undefined,
1178
1250
  depositAmount: adminBypass ? undefined : depositAmount,
@@ -1225,7 +1297,12 @@ async function createOrderForGroup(group, orderType, ctx) {
1225
1297
  * before, so an order could not state why it was taxed the way it was, and
1226
1298
  * GSTR-1's mandatory place-of-supply column had nothing to read.
1227
1299
  */
1228
- placeOfSupply: buyerState || undefined,
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,
1229
1306
  /* The supplier of record AS IT STANDS NOW. A tax invoice without the
1230
1307
  * supplier's GSTIN is not a tax invoice, and the invoice page cannot read
1231
1308
  * it from site settings — `gst` is in PRIVATE_SITE_FIELDS. See the schema.
@@ -1263,23 +1340,19 @@ async function createOrderForGroup(group, orderType, ctx) {
1263
1340
  cgst: gstBreakdown?.cgst || undefined,
1264
1341
  sgst: gstBreakdown?.sgst || undefined,
1265
1342
  igst: gstBreakdown?.igst || undefined,
1266
- emiEnabled: !adminBypass && !!emiSchedule ? true : undefined,
1267
- emiTenureMonths: !adminBypass && emiSchedule ? emiTenureMonths : undefined,
1268
- emiTokenAmount: !adminBypass ? emiSchedule?.tokenAmount : undefined,
1269
- emiSurchargeAmount: !adminBypass ? emiSchedule?.surchargeAmount : undefined,
1270
1343
  /*
1271
- * D45: the seller's share of the surcharge, recorded for the first time.
1272
- * `computeEmiSchedule` has always returned it and nothing ever stored it,
1273
- * so a split the admin had configured (default 50%) was computed and
1274
- * discarded on every EMI order. Persisting it moves no money — see the
1275
- * 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.
1276
1354
  */
1277
- emiSurchargeSellerShare: !adminBypass ? emiSchedule?.surchargeSellerShare : undefined,
1278
- emiInstallments: !adminBypass && emiSchedule
1279
- ? emiSchedule.installments.map((i) => ({ index: i.index, dueDate: i.dueDate, amount: i.amount, status: "pending" }))
1280
- : undefined,
1281
- emiRemainingBalance: !adminBypass ? emiSchedule?.remainingBalance : undefined,
1282
- emiComplete: !adminBypass && emiSchedule ? false : undefined,
1355
+ ...emiOrderFields(adminBypass ? null : (emiSchedule ?? null), emiTenureMonths),
1283
1356
  couponCode: appliedDiscounts[0]?.code,
1284
1357
  couponDiscount: couponDiscount > 0 ? couponDiscount : undefined,
1285
1358
  appliedDiscounts: appliedDiscounts.length > 0 ? appliedDiscounts : undefined,
@@ -1342,15 +1415,17 @@ async function createOrderForGroup(group, orderType, ctx) {
1342
1415
  * auth + rate-limiting before calling this and supply the resolved user fields.
1343
1416
  */
1344
1417
  export async function createCheckoutOrderAction(input) {
1345
- 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);
1346
1426
  const siteSettings = await siteSettingsRepository.getSingleton();
1347
1427
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1348
1428
  const emiSettings = siteSettings?.emi ?? CHECKOUT_DEFAULT_EMI_SETTINGS;
1349
- if (paymentMethod === "emi") {
1350
- if (!emiTenureMonths || !emiSettings.tenureOptions.includes(emiTenureMonths)) {
1351
- throw new ValidationError(`emiTenureMonths must be one of: ${emiSettings.tenureOptions.join(", ")}`);
1352
- }
1353
- }
1354
1429
  // W1-43 — enforce COD toggle.
1355
1430
  if (!adminBypass &&
1356
1431
  paymentMethod === "cod" &&
@@ -1367,6 +1442,30 @@ export async function createCheckoutOrderAction(input) {
1367
1442
  if (!cart.items || cart.items.length === 0) {
1368
1443
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
1369
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
+ }
1370
1469
  const excludedSet = new Set(excludedProductIds);
1371
1470
  const selectedSet = cart.selectedItemIds?.length
1372
1471
  ? new Set(cart.selectedItemIds)
@@ -1389,16 +1488,13 @@ export async function createCheckoutOrderAction(input) {
1389
1488
  // otherwise a buyer could dodge it by splitting a big cart across
1390
1489
  // sellers. Skipped for COD (physical hand-off at the door is its own
1391
1490
  // verification) and admin-bypass checkouts.
1392
- const otpThreshold = siteSettings?.payment?.otpCheckoutThreshold;
1393
- if (!adminBypass && paymentMethod !== "cod" && typeof otpThreshold === "number" && otpThreshold > 0) {
1394
- const cartTotalForOtpCheck = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
1395
- if (cartTotalForOtpCheck >= otpThreshold) {
1396
- const verified = await isCheckoutValueOtpVerified(uid);
1397
- if (!verified) {
1398
- throw new ApiError(403, "CHECKOUT_VALUE_OTP_REQUIRED");
1399
- }
1400
- }
1401
- }
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
+ });
1402
1498
  /*
1403
1499
  * COD value cap — the SERVER half of the gate.
1404
1500
  *
@@ -1434,6 +1530,7 @@ export async function createCheckoutOrderAction(input) {
1434
1530
  }
1435
1531
  const isDigitalCart = cartIsDigitalOnly(cartItems);
1436
1532
  let shippingAddress;
1533
+ let deliverySnapshot;
1437
1534
  let resolvedAddress = null;
1438
1535
  if (!isDigitalCart) {
1439
1536
  if (!addressId) {
@@ -1454,6 +1551,13 @@ export async function createCheckoutOrderAction(input) {
1454
1551
  throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
1455
1552
  }
1456
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));
1457
1561
  }
1458
1562
  const db = getAdminDb();
1459
1563
  // SB6-C — pre-tx fetch products so we can run the maxPerUser cap check
@@ -1493,6 +1597,19 @@ export async function createCheckoutOrderAction(input) {
1493
1597
  * is the only safe place for this, and the method is decided here.
1494
1598
  */
1495
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);
1496
1613
  const preTxPairs = cartItems
1497
1614
  .map((item) => {
1498
1615
  const [firstMember] = getCartItemMemberIds(item);
@@ -1661,6 +1778,7 @@ export async function createCheckoutOrderAction(input) {
1661
1778
  const pointsBudget = { remaining: useAllPoints ? await safePointsBalance(uid) : 0 };
1662
1779
  const groupCtx = {
1663
1780
  paymentMethod,
1781
+ emiOptIn,
1664
1782
  emiTenureMonths,
1665
1783
  emiSettings,
1666
1784
  commissions,
@@ -1671,6 +1789,7 @@ export async function createCheckoutOrderAction(input) {
1671
1789
  userName,
1672
1790
  userEmail,
1673
1791
  shippingAddress,
1792
+ deliverySnapshot,
1674
1793
  notes,
1675
1794
  adminBypass,
1676
1795
  adminBypassBy,
@@ -1770,6 +1889,7 @@ const EMPTY_PRICING_PREVIEW = {
1770
1889
  shipmentProtectionFee: 0,
1771
1890
  saleProtectionFee: 0,
1772
1891
  emiSurcharge: 0,
1892
+ emiDeferred: 0,
1773
1893
  gstAmount: 0,
1774
1894
  couponDiscount: 0,
1775
1895
  platformFee: 0,
@@ -1791,10 +1911,29 @@ const EMPTY_PRICING_PREVIEW = {
1791
1911
  * stock, creates an order, or writes coupon usage.
1792
1912
  */
1793
1913
  export async function previewCheckoutPricing(input) {
1794
- 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);
1795
1927
  const siteSettings = await siteSettingsRepository.getSingleton();
1796
1928
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1797
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;
1798
1937
  const excludedSet = new Set(excludedProductIds);
1799
1938
  const selectedSet = cart.selectedItemIds?.length ? new Set(cart.selectedItemIds) : null;
1800
1939
  // An explicit lane wins: the cart page prices whichever tab is open, which may
@@ -1882,13 +2021,14 @@ export async function previewCheckoutPricing(input) {
1882
2021
  * ineligible EMI order, which is where refusing belongs. An ineligible
1883
2022
  * cart simply previews no surcharge, because the tenure never applies.
1884
2023
  */
1885
- let groupEmiSurcharge = 0;
1886
- if (paymentMethod === "emi" && emiTenureMonths && siteSettings?.emi) {
1887
- const emiSettings = siteSettings.emi;
1888
- if (checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings).eligible) {
1889
- groupEmiSurcharge = computeBuyerEmiQuote(groupTotal, emiTenureMonths, emiSettings).surchargeAmount;
1890
- }
1891
- }
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;
1892
2032
  const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
1893
2033
  /*
1894
2034
  * C5e — the same three tiers `createOrderForGroup` enforces, so the figure
@@ -1988,6 +2128,7 @@ export async function previewCheckoutPricing(input) {
1988
2128
  shippingFee: groupShippingFee,
1989
2129
  codHandlingFee: groupCodHandlingFee,
1990
2130
  emiSurcharge: groupEmiSurcharge,
2131
+ emiDeferred: groupEmiDeferred,
1991
2132
  giftWrapFee: groupGiftWrapFee,
1992
2133
  shipmentProtectionFee: groupShipmentProtectionFee,
1993
2134
  saleProtectionFee: groupSaleProtectionFee,
@@ -2027,6 +2168,7 @@ export async function previewCheckoutPricing(input) {
2027
2168
  const shippingFee = sum((s) => s.shippingFee);
2028
2169
  const codHandlingFee = sum((s) => s.codHandlingFee);
2029
2170
  const emiSurcharge = sum((s) => s.emiSurcharge);
2171
+ const emiDeferred = sum((s) => s.emiDeferred);
2030
2172
  const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
2031
2173
  const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
2032
2174
  const saleProtectionFeeTotal = sum((s) => s.saleProtectionFee);
@@ -2128,6 +2270,7 @@ export async function previewCheckoutPricing(input) {
2128
2270
  shippingFee,
2129
2271
  codHandlingFee,
2130
2272
  emiSurcharge,
2273
+ emiDeferred,
2131
2274
  giftWrapFee: giftWrapFeeTotal,
2132
2275
  shipmentProtectionFee: shipmentProtectionFeeTotal,
2133
2276
  saleProtectionFee: saleProtectionFeeTotal,
@@ -2140,7 +2283,12 @@ export async function previewCheckoutPricing(input) {
2140
2283
  pointsApplied,
2141
2284
  // Equal to `total` when no points apply, so every existing reader of
2142
2285
  // either figure keeps getting the right number.
2143
- amountDue: roundRupees(total - pointsApplied),
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 }),
2144
2292
  pointsBalance: previewBalance,
2145
2293
  stores,
2146
2294
  };
@@ -2292,7 +2440,16 @@ async function refundDroppedItemsForPhonePeCheckout(input) {
2292
2440
  * from the inline version this replaced.
2293
2441
  */
2294
2442
  async function createPhonePeGroupOrder(group, orderType, ctx) {
2295
- const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, merchantOrderId, phonepeOrderId, transactionId, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: phonepeProductById, buyerState, useAllPoints, pointsBudget, } = 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;
2296
2453
  /*
2297
2454
  * Which online gateway actually took the money, read off the registered
2298
2455
  * provider rather than passed in — exactly one is registered per process
@@ -2308,7 +2465,21 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2308
2465
  // seller group here, duplicated from resolveShippingCost.
2309
2466
  // D44: `groupTotal` is computed just above — pass it so a free-above
2310
2467
  // threshold can fire and an unpriceable store is recorded.
2311
- const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections, acceptsPoints, sharedPurchaseRates } = 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);
2312
2483
  /*
2313
2484
  * Same supplier-of-record rule as `createOrderForGroup`, through the same
2314
2485
  * function. This path (PhonePe verify / webhook) is the SECOND place an
@@ -2446,7 +2617,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2446
2617
  });
2447
2618
  // D42: rounded once on the sum — see the COD/UPI path's 🛑 for why a raw
2448
2619
  // float sum of seven 2dp terms reached Firestore as 1011.8000000000001.
2449
- const orderTotal = roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + phonepeSharedQuote.total);
2620
+ const orderTotal = roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (phonepeEmiSchedule?.surchargeAmount ?? 0) + phonepeSharedQuote.total);
2450
2621
  /*
2451
2622
  * ── POINTS TENDER (D46/D47) — the same STAGE as the COD/manual path ───────
2452
2623
  *
@@ -2463,7 +2634,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2463
2634
  */
2464
2635
  const pointsOptedIn = useAllPoints === true;
2465
2636
  const pointsAccepted = pointsOptedIn && acceptsPoints;
2466
- const { pointsApplied, amountDue } = resolvePointsTender({
2637
+ const { pointsApplied } = resolvePointsTender({
2467
2638
  orderTotal,
2468
2639
  balance: pointsAccepted ? pointsBudget.remaining : 0,
2469
2640
  accepted: pointsAccepted,
@@ -2471,17 +2642,37 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2471
2642
  });
2472
2643
  if (pointsApplied > 0)
2473
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
+ });
2474
2658
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
2475
2659
  // Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
2476
2660
  // the price rule (`isBundle ? item.price : product.price`) that — like the
2477
2661
  // two in the consumer's checkout route — silently omitted `lockedPrice`, so
2478
2662
  // an accepted offer paid through PhonePe was RECORDED at the seller's list
2479
2663
  // price. `lineTotalFor` inside the expansion is the single definition.
2480
- const orderItems = group.flatMap(({ item, product }) => {
2664
+ const basePhonepeItems = group.flatMap(({ item, product }) => {
2481
2665
  const lt = (product?.listingType ?? "standard");
2482
2666
  const itemRule = getListingRule(lt);
2483
2667
  return expandCartLineToOrderRows(item, product, phonepeProductById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
2484
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);
2485
2676
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
2486
2677
  const imageUrls = [
2487
2678
  ...new Set(group
@@ -2510,7 +2701,13 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2510
2701
  // D46/D47 — see the identical pair in `createOrderForGroup`. `totalPrice`
2511
2702
  // stays the full value of the supply; points reduce what is COLLECTED.
2512
2703
  pointsApplied: pointsApplied > 0 ? pointsApplied : undefined,
2513
- amountDue: pointsApplied > 0 ? amountDue : 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),
2514
2711
  currency: firstItem.currency ?? getDefaultCurrency(),
2515
2712
  storeId: firstItem.storeId || undefined,
2516
2713
  storeName: firstItem.storeName || undefined,
@@ -2599,7 +2796,12 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2599
2796
  // Phase 7 — the place of supply, matching `createOrderForGroup` field for
2600
2797
  // field. This path recorded NONE of the tax breakdown until C0c defect 1,
2601
2798
  // which is precisely why every addition here has to be made twice.
2602
- placeOfSupply: buyerState || undefined,
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,
2603
2805
  // Our registration at the moment of supply — see the schema. Mirrors the
2604
2806
  // COD/UPI path so both produce an invoiceable order.
2605
2807
  supplierGstin: supplier.gstin,
@@ -2871,6 +3073,7 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2871
3073
  await assertLockedLinesStillValid(settleItems, uid);
2872
3074
  const isDigitalCartPp = cartIsDigitalOnly(settleItems);
2873
3075
  let shippingAddress;
3076
+ let deliverySnapshot;
2874
3077
  let resolvedAddressPp = null;
2875
3078
  if (!isDigitalCartPp) {
2876
3079
  if (!addressId) {
@@ -2885,6 +3088,13 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2885
3088
  throw new NotFoundError(ERROR_MESSAGES.CHECKOUT.ADDRESS_REQUIRED);
2886
3089
  }
2887
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));
2888
3098
  }
2889
3099
  // SB-UNI-5 2026-05-13 — bundle-aware product fetch + validation. Each
2890
3100
  // cart line gets paired with a "representative" product (first member id
@@ -3034,6 +3244,36 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3034
3244
  goodsValue: g.items.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
3035
3245
  })));
3036
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);
3037
3277
  // Add-ons are per store, so the expected total sums each group's own
3038
3278
  // selection — a single cart-wide figure would over- or under-count as soon
3039
3279
  // as the buyer picked add-ons for only some sellers.
@@ -3105,22 +3345,28 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3105
3345
  lineTotal: lineTotalFor(item, product),
3106
3346
  })),
3107
3347
  }))).perGroup;
3108
- const guardBases = expectedShippingGroups.map((g, i) => {
3109
- const storeId = g.items[0].item.storeId;
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) => {
3110
3357
  const addons = cart.storeAddons?.[storeId] ?? {};
3111
- const goods = guardSubtotals[i][1];
3112
- const share = guardFeeByGroup.get(storeId);
3113
- const groupAddons = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
3114
- computeShipmentProtectionFee(goods, addons.shipmentProtectionAddon ?? false, commissionRates);
3115
- return {
3116
- total: roundRupees(Math.max(0, goods - (guardCouponByGroup[i]?.couponDiscount ?? 0)) +
3117
- (expectedShippingFees[i]?.shippingFee ?? 0) +
3118
- groupAddons +
3119
- (share?.platformFee ?? 0) +
3120
- (share?.gstOnFee ?? 0)),
3121
- accepted: expectedShippingFees[i]?.acceptsPoints === true,
3122
- };
3123
- });
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
+ }));
3124
3370
  expectedPointsApplied = allocatePointsAcrossGroups(guardBases, guardBalance, true).pointsApplied;
3125
3371
  }
3126
3372
  }
@@ -3128,7 +3374,9 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3128
3374
  expectedPlatformFee +
3129
3375
  expectedGstOnFee +
3130
3376
  expectedAddonFees +
3131
- expectedShippingFee -
3377
+ expectedShippingFee +
3378
+ expectedEmiSurcharge -
3379
+ expectedEmiDeferred -
3132
3380
  expectedPointsApplied));
3133
3381
  // Reuses the order already fetched above to confirm COMPLETED — PhonePe's
3134
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
@@ -3244,6 +3492,13 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3244
3492
  productById: productByIdPaid,
3245
3493
  useAllPoints,
3246
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,
3247
3502
  });
3248
3503
  }
3249
3504
  await flushCouponUsageAccumulator(couponUsageAccumulator, uid);