@mohasinac/appkit 4.55.0 → 4.57.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 (86) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/actions.d.ts +53 -0
  3. package/dist/_internal/server/features/checkout/actions.js +156 -8
  4. package/dist/_internal/server/features/orders/actions.d.ts +30 -0
  5. package/dist/_internal/server/features/orders/actions.js +103 -0
  6. package/dist/_internal/server/features/orders/adapters.js +7 -0
  7. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  8. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  9. package/dist/_internal/shared/actions/action-registry.js +53 -0
  10. package/dist/_internal/shared/config/schema.d.ts +6 -0
  11. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  12. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  13. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  14. package/dist/_internal/shared/shipping/quote.js +123 -0
  15. package/dist/client.d.ts +22 -0
  16. package/dist/client.js +10 -0
  17. package/dist/features/about/components/PublicProfileView.js +2 -1
  18. package/dist/features/account/components/UserOrdersView.js +25 -3
  19. package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
  20. package/dist/features/admin/components/OwnerQuickActions.js +53 -6
  21. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  22. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  23. package/dist/features/blog/components/BlogPostView.js +2 -1
  24. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  25. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  26. package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
  27. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  28. package/dist/features/faq/components/FAQPageContent.js +17 -4
  29. package/dist/features/faq/constants/help-guides.d.ts +36 -0
  30. package/dist/features/faq/constants/help-guides.js +64 -0
  31. package/dist/features/homepage/components/HeroCarousel.js +7 -3
  32. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  33. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  34. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  35. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  36. package/dist/features/homepage/lib/section-renderer.js +24 -6
  37. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  38. package/dist/features/homepage/lib/slide-gating.js +98 -0
  39. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  40. package/dist/features/media/MediaAvatar.js +1 -1
  41. package/dist/features/media/MediaImage.d.ts +18 -1
  42. package/dist/features/media/MediaImage.js +1 -1
  43. package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
  44. package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
  45. package/dist/features/orders/components/index.d.ts +1 -0
  46. package/dist/features/orders/components/index.js +1 -0
  47. package/dist/features/orders/schemas/firestore.d.ts +38 -0
  48. package/dist/features/orders/schemas/index.d.ts +2 -2
  49. package/dist/features/orders/types/index.d.ts +15 -0
  50. package/dist/features/points/repository/points.repository.d.ts +81 -0
  51. package/dist/features/points/repository/points.repository.js +155 -0
  52. package/dist/features/points/schemas/firestore.d.ts +118 -0
  53. package/dist/features/points/schemas/firestore.js +116 -0
  54. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  55. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  56. package/dist/features/products/components/ProductDetailPageView.js +3 -1
  57. package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
  58. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  59. package/dist/features/seller/actions/seller-actions.js +33 -0
  60. package/dist/features/stores/components/StoreHeader.js +3 -1
  61. package/dist/index.d.ts +1 -0
  62. package/dist/index.js +8 -0
  63. package/dist/next/routing/route-map.d.ts +4 -0
  64. package/dist/next/routing/route-map.js +2 -0
  65. package/dist/schemas/registry.d.ts +2 -2
  66. package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
  67. package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
  68. package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
  69. package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
  70. package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
  71. package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
  72. package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
  73. package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
  74. package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
  75. package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
  76. package/dist/seed/_helpers/category-models.d.ts +9 -2
  77. package/dist/seed/_helpers/category-models.js +19 -1368
  78. package/dist/seed/carousel-slides-seed-data.js +48 -7
  79. package/dist/server.d.ts +3 -1
  80. package/dist/server.js +11 -0
  81. package/dist/styles.css +38 -6
  82. package/dist/tailwind-utilities.css +1 -1
  83. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  84. package/dist/ui/components/ImageMosaic.js +28 -5
  85. package/dist/ui/components/ImageMosaic.style.css +37 -5
  86. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.55.0",
4
- "head": "e683e7b08996d66d9b6bef65cf4712b8e63454ea",
3
+ "version": "4.57.0",
4
+ "head": "de32aea7a1bbb5fe9970959866c5278c2f9e4e49",
5
5
  "branch": "main",
6
- "dirty": false,
7
- "builtAt": "2026-10-20T15:42:52.181Z"
6
+ "dirty": true,
7
+ "builtAt": "2026-10-20T18:02:32.256Z"
8
8
  }
@@ -1,3 +1,4 @@
1
+ import { type SelectedShippingQuote } from "../../../shared/shipping/quote";
1
2
  import { type StoreShippingPolicy } from "../../../shared/features/shipping/mode";
2
3
  import { type CartLane } from "../../../shared/checkout/lanes";
3
4
  import type { OutOfStockPolicy } from "../../../../features/orders/schemas/index";
@@ -12,6 +13,36 @@ export interface CreateCheckoutOrderInput {
12
13
  paymentMethod: CheckoutPaymentMethod;
13
14
  /** Required when paymentMethod === "emi" — the buyer's chosen tenure (2–6 months). */
14
15
  emiTenureMonths?: number;
16
+ /**
17
+ * The courier the buyer picked at checkout, with the ₹50-rounded rate they
18
+ * were shown.
19
+ *
20
+ * 🛑 OPTIONAL, and its absence is meaningful: a cart whose carrier quote
21
+ * failed, a digital-only cart, or any caller written before the picker
22
+ * existed falls back to `resolveShippingCost` exactly as before. Absence is
23
+ * what makes reconciliation skip the order as "no-quote" rather than
24
+ * measuring a seller's spend against a figure nobody was charged.
25
+ *
26
+ * 🛑 `chargedRate` is TRUSTED from the client, and that is a deliberate,
27
+ * bounded decision rather than an oversight. It is re-derived server-side
28
+ * through `roundShippingUp` before it is billed, so a tampered value can
29
+ * only ever be a different carrier rate rounded the same way — the gap
30
+ * worth closing is re-quoting the carrier at order time, which costs an
31
+ * outbound call on the payment path and is tracked separately.
32
+ */
33
+ selectedShippingQuote?: SelectedShippingQuote;
34
+ /**
35
+ * Points the buyer chose to spend on this checkout. 1 point = ₹1.
36
+ *
37
+ * 🛑 Requested, never trusted. The server re-reads the balance, re-applies
38
+ * the 50% cap against the real cart total, and spends the LESSER of the two
39
+ * — a client that asks for more than it holds gets its request trimmed, not
40
+ * an error, because the honest reading of "spend 500 when you have 300" is a
41
+ * stale page rather than an attack. `pointsRepository.redeem` then re-checks
42
+ * the balance a third time inside its own transaction, which is the check
43
+ * that actually prevents a negative balance under concurrency.
44
+ */
45
+ pointsToRedeem?: number;
15
46
  notes?: string;
16
47
  excludedProductIds?: string[];
17
48
  /** When true this is an admin test order — OTP consent is pre-granted and the order is created paid/processing. */
@@ -105,6 +136,18 @@ export interface CheckoutPricingPreviewInput {
105
136
  * is currently gated off.
106
137
  */
107
138
  lane?: CartLane;
139
+ /**
140
+ * The chosen EMI tenure, when `paymentMethod` is `"emi"`.
141
+ *
142
+ * 🛑 REQUIRED for an EMI preview to be truthful. The surcharge is
143
+ * `principalRemaining × surchargePercentPerMonth × tenureMonths`, so it
144
+ * cannot be derived from the cart alone — and without it this function
145
+ * returned a total that `createOrderForGroup` then exceeded by exactly that
146
+ * amount. Unlike the add-ons below, the tenure is NOT on the cart document:
147
+ * it is picked on the payment step and belongs to the order, so it has to
148
+ * travel with the request.
149
+ */
150
+ emiTenureMonths?: number;
108
151
  }
109
152
  /** One store's slice of the preview — what that seller's order will cost. */
110
153
  export interface CheckoutPricingPreviewStore {
@@ -116,6 +159,14 @@ export interface CheckoutPricingPreviewStore {
116
159
  giftWrapFee: number;
117
160
  shipmentProtectionFee: number;
118
161
  saleProtectionFee: number;
162
+ /**
163
+ * EMI surcharge for THIS seller, when the buyer is previewing an EMI order.
164
+ *
165
+ * Per store because `computeEmiSchedule` takes a SELLER subtotal — the
166
+ * financed principal is per order, not per cart, so a cart-wide figure
167
+ * would not match any order written.
168
+ */
169
+ emiSurcharge: number;
119
170
  gstAmount: number;
120
171
  couponDiscount: number;
121
172
  total: number;
@@ -147,6 +198,8 @@ export interface CheckoutPricingPreview {
147
198
  giftWrapFee: number;
148
199
  shipmentProtectionFee: number;
149
200
  saleProtectionFee: number;
201
+ /** Total EMI surcharge across sellers. Zero unless previewing an EMI order. */
202
+ emiSurcharge: number;
150
203
  gstAmount: number;
151
204
  couponDiscount: number;
152
205
  /** Buyer-facing platform commission, capped, charged once for this checkout. */
@@ -12,6 +12,9 @@ import { roundRupees } from "../../../../utils/number.formatter.js";
12
12
  import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../../../errors/index.js";
13
13
  import { ORDER_FIELDS } from "../../../../constants/field-names.js";
14
14
  import { serverLogger } from "../../../../monitoring/index.js";
15
+ import { roundShippingUp, } from "../../../shared/shipping/quote.js";
16
+ import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
17
+ import { maxRedeemablePoints } from "../../../../features/points/schemas/firestore.js";
15
18
  import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories/index.js";
16
19
  import { isIntraStateSupply } from "../../../shared/fees/calculator.js";
17
20
  import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
@@ -44,7 +47,7 @@ import { verifyPaymentSignature as verifyRazorpayPaymentSignature } from "../../
44
47
  import { resolveKeys } from "../../../../core/integration-keys.js";
45
48
  import { paiseToRupees } from "../../../../core/money.js";
46
49
  import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config.js";
47
- import { checkEmiEligibility, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
50
+ import { checkEmiEligibility, computeBuyerEmiQuote, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
48
51
  import { formatShippingAddress } from "./data.js";
49
52
  import { enforceMaxPerUserForCart } from "./prize-bundle-gates.js";
50
53
  import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, } from "../../../shared/checkout/rules/index.js";
@@ -490,7 +493,7 @@ function dispatchOrderConfirmationEmails(emailsToSend) {
490
493
  * so the caller can accumulate the checkout-wide `total`.
491
494
  */
492
495
  async function createOrderForGroup(group, orderType, ctx) {
493
- const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, } = ctx;
496
+ 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;
494
497
  const firstItem = group[0].item;
495
498
  const firstProduct = group[0].product;
496
499
  // Add-ons are this store's choice, not the cart's — see OrderGroupContext.
@@ -507,7 +510,25 @@ async function createOrderForGroup(group, orderType, ctx) {
507
510
  return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
508
511
  });
509
512
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
510
- const { shippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
513
+ const { shippingFee: resolvedShippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
514
+ /*
515
+ * 🛑 The buyer's quote WINS over the seller's configured rate, and it is
516
+ * re-rounded here rather than trusted as sent.
517
+ *
518
+ * `roundShippingUp` is idempotent, so re-applying it to an honest value is a
519
+ * no-op; applied to a tampered one it forces the result back onto a ₹50 step.
520
+ * That bounds what a forged `chargedRate` can be to "some carrier rate,
521
+ * correctly rounded" rather than an arbitrary number.
522
+ *
523
+ * Applied to the FIRST group only — see the field's note on OrderGroupContext.
524
+ * A cart spanning sellers must not be charged the same parcel rate twice.
525
+ */
526
+ const isFirstGroup = ctx.orderIds.length === 0;
527
+ const shippingFee = selectedShippingQuote && isFirstGroup
528
+ ? roundShippingUp(selectedShippingQuote.chargedRate)
529
+ : selectedShippingQuote
530
+ ? 0
531
+ : resolvedShippingFee;
511
532
  /*
512
533
  * Who issues this invoice — the seller if they have their own GSTIN, the
513
534
  * platform otherwise. ONE function decides, and it is the only place that
@@ -678,6 +699,16 @@ async function createOrderForGroup(group, orderType, ctx) {
678
699
  // are "auction". Written ONCE here and never recomputed: the auction and
679
700
  // offer documents it summarises are mutable and eventually archived.
680
701
  sourceContext,
702
+ /*
703
+ * Frozen onto the order so reconciliation has a charged figure to measure
704
+ * the seller's real postage against. Only on the group that actually
705
+ * carries the shipping fee — a later group in a multi-seller cart is
706
+ * charged ₹0 shipping, so recording a quote there would invite a credit
707
+ * against money that group never collected.
708
+ */
709
+ selectedShippingQuote: selectedShippingQuote && isFirstGroup
710
+ ? { ...selectedShippingQuote, chargedRate: shippingFee }
711
+ : undefined,
681
712
  offerId: firstItem.offerId ?? undefined,
682
713
  // Distinguishes a Buy Now purchase from a settled auction win. Both are
683
714
  // orderType "auction"; only this one gets the 24h admin-review window.
@@ -1139,12 +1170,53 @@ export async function createCheckoutOrderAction(input) {
1139
1170
  siteContactUpiVpa: siteSettings?.contact?.upiVpa,
1140
1171
  storeAddons: cart.storeAddons,
1141
1172
  platformFeeByStore,
1173
+ selectedShippingQuote: input.selectedShippingQuote,
1142
1174
  productById: checkoutProductById,
1143
1175
  };
1144
1176
  for (const { items: group, orderType } of orderGroups) {
1145
1177
  total += await createOrderForGroup(group, orderType, groupCtx);
1146
1178
  }
1147
1179
  await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
1180
+ /*
1181
+ * Spend points AFTER the orders exist.
1182
+ *
1183
+ * 🛑 This ordering is the whole safety argument. Redeeming first and then
1184
+ * failing to create an order would debit a buyer for something they never
1185
+ * received, and points have no refund path that does not need a human. This
1186
+ * way the worst case is an order placed at full price with the balance
1187
+ * intact — recoverable, and visible to the buyer, who still has their
1188
+ * points.
1189
+ *
1190
+ * The amount is re-derived server-side against the real total rather than
1191
+ * taken from the request; `maxRedeemablePoints` applies the 50% cap, and
1192
+ * `redeem` re-checks the balance inside its own transaction.
1193
+ */
1194
+ if (input.pointsToRedeem && input.pointsToRedeem > 0 && orderIds.length > 0) {
1195
+ try {
1196
+ const balance = await pointsRepository.getBalance(uid);
1197
+ const spend = maxRedeemablePoints(total, Math.min(balance, input.pointsToRedeem));
1198
+ if (spend > 0) {
1199
+ await pointsRepository.redeem({
1200
+ userId: uid,
1201
+ points: spend,
1202
+ orderId: orderIds[0],
1203
+ description: `Redeemed on ${orderIds[0]}`,
1204
+ });
1205
+ }
1206
+ }
1207
+ catch (err) {
1208
+ /*
1209
+ * A failed redemption must not fail a placed order. The buyer keeps the
1210
+ * points and paid full price — the strictly safer side of this trade.
1211
+ */
1212
+ const normalized = normalizeError(err);
1213
+ serverLogger.error("points redemption failed after order creation", {
1214
+ uid,
1215
+ orderId: orderIds[0],
1216
+ message: normalized.message,
1217
+ });
1218
+ }
1219
+ }
1148
1220
  dispatchOrderConfirmationEmails(emailsToSend);
1149
1221
  serverLogger.info(`createCheckoutOrderAction: ${orderIds.length} order(s) placed for uid=${uid}` +
1150
1222
  (unavailable.length > 0 ? ` (${unavailable.length} item(s) unavailable)` : ""));
@@ -1172,6 +1244,7 @@ const EMPTY_PRICING_PREVIEW = {
1172
1244
  giftWrapFee: 0,
1173
1245
  shipmentProtectionFee: 0,
1174
1246
  saleProtectionFee: 0,
1247
+ emiSurcharge: 0,
1175
1248
  gstAmount: 0,
1176
1249
  couponDiscount: 0,
1177
1250
  platformFee: 0,
@@ -1189,7 +1262,7 @@ const EMPTY_PRICING_PREVIEW = {
1189
1262
  * stock, creates an order, or writes coupon usage.
1190
1263
  */
1191
1264
  export async function previewCheckoutPricing(input) {
1192
- const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, } = input;
1265
+ const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, emiTenureMonths, } = input;
1193
1266
  const siteSettings = await siteSettingsRepository.getSingleton();
1194
1267
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1195
1268
  const cart = await unitOfWork.carts.getOrCreate(uid);
@@ -1242,7 +1315,7 @@ export async function previewCheckoutPricing(input) {
1242
1315
  for (const { items: group } of orderGroups) {
1243
1316
  const firstItem = group[0].item;
1244
1317
  const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
1245
- const { shippingFee: groupShippingFee, storeState, storeProtections } = await resolveShippingCost(firstItem.storeId);
1318
+ const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId);
1246
1319
  // Add-ons are per store, read off the cart document — the same entry
1247
1320
  // createOrderForGroup will read when the order is actually placed.
1248
1321
  const addons = cart.storeAddons?.[firstItem.storeId] ?? {};
@@ -1250,6 +1323,36 @@ export async function previewCheckoutPricing(input) {
1250
1323
  // ever charged for literal "cod" (pay the courier), never upi_manual or
1251
1324
  // cash (both pre-paid via UPI/bank transfer before dispatch).
1252
1325
  const groupCodHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
1326
+ /*
1327
+ * 🛑 The EMI surcharge is a TERM OF THE TOTAL, and it was missing here
1328
+ * while `createOrderForGroup` added it — a shown-vs-charged drift of
1329
+ * exactly the kind this function exists to prevent.
1330
+ *
1331
+ * Measured on the live settings (tokenPercent 10, surchargePercentPerMonth
1332
+ * 1) against the ₹20,000 listing: token ₹2,000, financed ₹18,000, so the
1333
+ * surcharge is ₹360 at the default 2-month tenure and ₹1,080 at 6. The
1334
+ * Order Summary showed ₹20,011.80 either way, and the order recorded up to
1335
+ * ₹1,080 more. Worse, the EMI panel beside it already displayed
1336
+ * instalments computed WITH the surcharge, so the page disagreed with
1337
+ * itself.
1338
+ *
1339
+ * `computeBuyerEmiQuote`, never `computeEmiSchedule`: the latter also
1340
+ * returns the platform/seller split of the surcharge, which is our
1341
+ * commercial arrangement with the seller and has no business reaching a
1342
+ * browser (see § "Buyer-facing fees").
1343
+ *
1344
+ * Eligibility is deliberately NOT enforced here. A preview that threw
1345
+ * would blank the Order Summary; `createOrderForGroup` still refuses an
1346
+ * ineligible EMI order, which is where refusing belongs. An ineligible
1347
+ * cart simply previews no surcharge, because the tenure never applies.
1348
+ */
1349
+ let groupEmiSurcharge = 0;
1350
+ if (paymentMethod === "emi" && emiTenureMonths && siteSettings?.emi) {
1351
+ const emiSettings = siteSettings.emi;
1352
+ if (checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings).eligible) {
1353
+ groupEmiSurcharge = computeBuyerEmiQuote(groupTotal, emiTenureMonths, emiSettings).surchargeAmount;
1354
+ }
1355
+ }
1253
1356
  const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
1254
1357
  /*
1255
1358
  * C5e — the same three tiers `createOrderForGroup` enforces, so the figure
@@ -1302,6 +1405,7 @@ export async function previewCheckoutPricing(input) {
1302
1405
  subtotal: groupTotal,
1303
1406
  shippingFee: groupShippingFee,
1304
1407
  codHandlingFee: groupCodHandlingFee,
1408
+ emiSurcharge: groupEmiSurcharge,
1305
1409
  giftWrapFee: groupGiftWrapFee,
1306
1410
  shipmentProtectionFee: groupShipmentProtectionFee,
1307
1411
  saleProtectionFee: groupSaleProtectionFee,
@@ -1319,6 +1423,7 @@ export async function previewCheckoutPricing(input) {
1319
1423
  total: Math.max(0, groupTotal - groupCouponDiscount) +
1320
1424
  groupShippingFee +
1321
1425
  groupCodHandlingFee +
1426
+ groupEmiSurcharge +
1322
1427
  groupGiftWrapFee +
1323
1428
  groupShipmentProtectionFee +
1324
1429
  groupSaleProtectionFee,
@@ -1330,6 +1435,7 @@ export async function previewCheckoutPricing(input) {
1330
1435
  const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
1331
1436
  const shippingFee = sum((s) => s.shippingFee);
1332
1437
  const codHandlingFee = sum((s) => s.codHandlingFee);
1438
+ const emiSurcharge = sum((s) => s.emiSurcharge);
1333
1439
  const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
1334
1440
  const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
1335
1441
  const saleProtectionFeeTotal = sum((s) => s.saleProtectionFee);
@@ -1366,6 +1472,7 @@ export async function previewCheckoutPricing(input) {
1366
1472
  const total = Math.max(0, cartSubtotal - couponDiscount) +
1367
1473
  shippingFee +
1368
1474
  codHandlingFee +
1475
+ emiSurcharge +
1369
1476
  giftWrapFeeTotal +
1370
1477
  shipmentProtectionFeeTotal +
1371
1478
  saleProtectionFeeTotal +
@@ -1375,6 +1482,7 @@ export async function previewCheckoutPricing(input) {
1375
1482
  subtotal: cartSubtotal,
1376
1483
  shippingFee,
1377
1484
  codHandlingFee,
1485
+ emiSurcharge,
1378
1486
  giftWrapFee: giftWrapFeeTotal,
1379
1487
  shipmentProtectionFee: shipmentProtectionFeeTotal,
1380
1488
  saleProtectionFee: saleProtectionFeeTotal,
@@ -1536,7 +1644,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1536
1644
  // Reuses the same store/seller lookup as the COD/UPI path above instead
1537
1645
  // of re-implementing it inline — was two sequential findById calls per
1538
1646
  // seller group here, duplicated from resolveShippingCost.
1539
- const { shippingFee, storeOwnerId, storeState, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
1647
+ const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections } = await resolveShippingCost(firstItem.storeId);
1540
1648
  /*
1541
1649
  * Same supplier-of-record rule as `createOrderForGroup`, through the same
1542
1650
  * function. This path (PhonePe verify / webhook) is the SECOND place an
@@ -1572,7 +1680,44 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1572
1680
  const addons = storeAddons?.[firstItem.storeId] ?? {};
1573
1681
  const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
1574
1682
  const giftWrapMessage = addons.giftWrapMessage;
1575
- const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, addons.shipmentProtectionAddon ?? false, commissionRates);
1683
+ /*
1684
+ * 🛑 `computeProtectionFees`, not a bare `computeShipmentProtectionFee`.
1685
+ *
1686
+ * This path hand-rolled the shipment fee and computed NO sale-protection fee
1687
+ * at all, which was wrong twice over:
1688
+ *
1689
+ * 1. **Sale protection was never charged here.** Razorpay's create-order
1690
+ * route DOES charge it (it calls `computeProtectionFees`), so the amount
1691
+ * captured and the amount this function records on the order disagreed
1692
+ * by the fee — and the platform absorbs the unconditional refund that
1693
+ * fee exists to pay for. See `computeSaleProtectionFee`.
1694
+ * 2. **The C5e tiers were skipped.** The bare helper tests only "did the
1695
+ * buyer tick it" and the site-wide master switch, so shipment protection
1696
+ * could be billed on a seller who never agreed to back it, or on a
1697
+ * final-sale item where it cannot apply.
1698
+ *
1699
+ * Latent rather than live when written — `payment.phonepeEnabled` is false
1700
+ * and the one live store has `supportsSaleProtection` unset — but both of
1701
+ * those are a settings toggle away, and an under-charge that appears the
1702
+ * moment a flag flips is worse than one that is visible now.
1703
+ */
1704
+ const groupHasFinalSale = group.some(({ product }) => product?.finalSale === true);
1705
+ const { shippingProtectionFee: shipmentProtectionFee, saleProtectionFee } = computeProtectionFees({
1706
+ subtotal: groupTotal,
1707
+ selected: {
1708
+ shipping: addons.shipmentProtectionAddon ?? false,
1709
+ sale: addons.saleProtectionAddon ?? false,
1710
+ },
1711
+ tiers: {
1712
+ platformShipping: commissionRates.shipmentProtectionFeeEnabled === true,
1713
+ platformSale: commissionRates.saleProtectionFeeEnabled === true,
1714
+ sellerShipping: storeProtections.shipping,
1715
+ sellerSale: storeProtections.sale,
1716
+ finalSale: groupHasFinalSale,
1717
+ },
1718
+ shippingFee: (s, sel) => computeShipmentProtectionFee(s, sel, commissionRates),
1719
+ saleFee: (s, sel) => computeSaleProtectionFee(s, sel, commissionRates),
1720
+ });
1576
1721
  /*
1577
1722
  * P-8 GST — now wired through this path. C0c defect 1.
1578
1723
  *
@@ -1605,7 +1750,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1605
1750
  /* Product GST is not a term — prices are tax-inclusive, so it is already
1606
1751
  * inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
1607
1752
  * stays because that is GST on our fee, charged on top. */
1608
- const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst;
1753
+ const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst;
1609
1754
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
1610
1755
  // Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
1611
1756
  // the price rule (`isBundle ? item.price : product.price`) that — like the
@@ -1694,6 +1839,9 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1694
1839
  giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
1695
1840
  shipmentProtectionAddon: shipmentProtectionFee > 0 ? true : undefined,
1696
1841
  shipmentProtectionFee: shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
1842
+ // Recorded as well as charged — an order that collects a protection fee
1843
+ // without a record of it cannot be reconciled against a later claim.
1844
+ saleProtectionFee: saleProtectionFee > 0 ? saleProtectionFee : undefined,
1697
1845
  /*
1698
1846
  * The tax breakdown, matching `createOrderForGroup` field for field.
1699
1847
  * This path recorded NONE of it before C0c defect 1 — so an online order
@@ -46,3 +46,33 @@ export declare function adminRejectPaymentAsFraudAction(orderId: string, note: s
46
46
  * payment/order status — surfaces to the admin queue for investigation.
47
47
  */
48
48
  export declare function raiseOrderDisputeAction(orderId: string, reason: string): Promise<ActionResult<void>>;
49
+ /**
50
+ * A buyer submits payment for ONE of their own EMI instalments.
51
+ *
52
+ * 🛑 Why this exists at all: there was no buyer-side route. The only writer
53
+ * was the seller's `markEmiInstallmentPaid`, so a buyer on an EMI plan could
54
+ * not pay an instalment — they waited for the seller to record it. The
55
+ * schema had anticipated them all along (`EmiInstallment.transactionId` and
56
+ * `.proofUrl` are documented as "buyer-uploaded"), which is the tell that the
57
+ * surface, not the model, was missing.
58
+ *
59
+ * 🛑 **ANY unpaid instalment, at ANY time — deliberately unbounded.** There is
60
+ * no due-date check and none should be added. A buyer who wants to clear the
61
+ * plan early is reducing our exposure, not breaking a rule, and the dueDate is
62
+ * the date we will CHASE them on, never the first date they may pay. Paying
63
+ * ahead also keeps `emiComplete` honest: that flag is what
64
+ * `assertEmiShippable` reads, so early settlement should be able to release a
65
+ * shipment early rather than waiting out a calendar.
66
+ *
67
+ * It mirrors the manual-payment model rather than inventing a second one: the
68
+ * buyer supplies a UTR and a proof, the seller or an admin confirms. The
69
+ * status therefore moves to `pending` review here, NOT to `paid` — a buyer
70
+ * must never be able to mark their own money received. `markEmiInstallmentPaid`
71
+ * stays the only writer of `paid`, and so stays the only way `emiComplete`
72
+ * can flip.
73
+ */
74
+ export declare function submitEmiInstallmentPaymentAction(orderId: string, input: {
75
+ installmentIndex: number;
76
+ transactionId: string;
77
+ proofUrl?: string;
78
+ }): Promise<ActionResult<void>>;
@@ -412,3 +412,106 @@ export async function raiseOrderDisputeAction(orderId, reason) {
412
412
  });
413
413
  });
414
414
  }
415
+ /**
416
+ * A buyer submits payment for ONE of their own EMI instalments.
417
+ *
418
+ * 🛑 Why this exists at all: there was no buyer-side route. The only writer
419
+ * was the seller's `markEmiInstallmentPaid`, so a buyer on an EMI plan could
420
+ * not pay an instalment — they waited for the seller to record it. The
421
+ * schema had anticipated them all along (`EmiInstallment.transactionId` and
422
+ * `.proofUrl` are documented as "buyer-uploaded"), which is the tell that the
423
+ * surface, not the model, was missing.
424
+ *
425
+ * 🛑 **ANY unpaid instalment, at ANY time — deliberately unbounded.** There is
426
+ * no due-date check and none should be added. A buyer who wants to clear the
427
+ * plan early is reducing our exposure, not breaking a rule, and the dueDate is
428
+ * the date we will CHASE them on, never the first date they may pay. Paying
429
+ * ahead also keeps `emiComplete` honest: that flag is what
430
+ * `assertEmiShippable` reads, so early settlement should be able to release a
431
+ * shipment early rather than waiting out a calendar.
432
+ *
433
+ * It mirrors the manual-payment model rather than inventing a second one: the
434
+ * buyer supplies a UTR and a proof, the seller or an admin confirms. The
435
+ * status therefore moves to `pending` review here, NOT to `paid` — a buyer
436
+ * must never be able to mark their own money received. `markEmiInstallmentPaid`
437
+ * stays the only writer of `paid`, and so stays the only way `emiComplete`
438
+ * can flip.
439
+ */
440
+ export async function submitEmiInstallmentPaymentAction(orderId, input) {
441
+ return wrapAction(async () => {
442
+ const user = await requireRoleUser(["user", "buyer", "seller", "admin"]);
443
+ const order = await orderRepository.findById(orderId);
444
+ if (!order)
445
+ throw new OrderNotFoundError(orderId);
446
+ if (!isAdminUser(user) && order.userId !== user.uid)
447
+ throw new OrderOwnershipError(orderId);
448
+ if (!order.emiEnabled)
449
+ throw new ValidationError("This order is not on an EMI plan");
450
+ const installments = order.emiInstallments ?? [];
451
+ const target = installments.find((i) => i.index === input.installmentIndex);
452
+ if (!target) {
453
+ throw new ValidationError(`Installment ${input.installmentIndex} not found on this order`);
454
+ }
455
+ if (target.status === "paid") {
456
+ throw new ValidationError("This installment is already paid");
457
+ }
458
+ // A second submission would overwrite the reference the reviewer is
459
+ // currently looking at, which reads to them as the buyer changing their
460
+ // story. Re-submitting needs the reviewer to reject the first one.
461
+ if (target.transactionId) {
462
+ throw new ValidationError("A payment reference is already under review for this installment");
463
+ }
464
+ /*
465
+ * 🛑 `status` is NOT written here, and writing it would be a lie dressed
466
+ * as a state change.
467
+ *
468
+ * A new instalment is created `status: "pending"` (see
469
+ * `createOrderForGroup`), so setting `"pending"` again changes nothing
470
+ * while reading like a transition — and it would leave "nobody has paid"
471
+ * and "the buyer has reported paying" indistinguishable, which is exactly
472
+ * the state a reviewer needs to tell apart.
473
+ *
474
+ * "Awaiting review" is DERIVED: `transactionId` set and `status !== "paid"`.
475
+ * Same rule as the manual-payment queue, which derives its four states
476
+ * from `paymentProofUrl` + `paymentReviewOutcome` rather than storing a
477
+ * mirror field that any write path could forget to update (Root Cause
478
+ * #42). Only `markEmiInstallmentPaid` writes `paid`, so a buyer can never
479
+ * mark their own money received, and `emiComplete` keeps its single writer.
480
+ */
481
+ const updatedInstallments = installments.map((i) => i.index === input.installmentIndex
482
+ ? { ...i, transactionId: input.transactionId, proofUrl: input.proofUrl }
483
+ : i);
484
+ await orderRepository.update(orderId, { emiInstallments: updatedInstallments });
485
+ /*
486
+ * Tell the seller there is something to confirm. Best-effort: a
487
+ * notification that fails must not lose the buyer's submitted reference,
488
+ * which is the only record that they paid.
489
+ */
490
+ const store = order.storeId ? await storeRepository.findById(order.storeId) : null;
491
+ if (store?.ownerId) {
492
+ await sendNotification({
493
+ userId: store.ownerId,
494
+ type: "payment_review",
495
+ /*
496
+ * `high`, matching the other `payment_review` send in this file. The
497
+ * two differ in urgency — that one has a 15-minute re-upload window
498
+ * and this one has no deadline — but a single notification TYPE whose
499
+ * priority disagrees between its call sites is the drift this codebase
500
+ * keeps paying for, and unconfirmed money is a seller action item
501
+ * either way.
502
+ */
503
+ priority: "high",
504
+ title: "EMI instalment payment reported",
505
+ message: `A buyer has submitted payment for instalment ${input.installmentIndex} on order ${orderId}.`,
506
+ relatedId: orderId,
507
+ relatedType: "order",
508
+ }).catch((err) => {
509
+ void normalizeError(err);
510
+ serverLogger.error("Failed to notify seller of EMI instalment payment (non-fatal)", {
511
+ orderId,
512
+ installmentIndex: input.installmentIndex,
513
+ });
514
+ });
515
+ }
516
+ });
517
+ }
@@ -170,6 +170,13 @@ export function orderDocumentToOrder(doc) {
170
170
  // the packer needs giftWrapMessage in hand. An
171
171
  // adapter that drops them makes every downstream surface render its
172
172
  // "not applicable" fallback instead (Root Cause #57).
173
+ // EMI, so the buyer can see and pay their own schedule. Mapped with the
174
+ // add-ons above for the same reason recorded there: an adapter that drops
175
+ // a field makes every downstream surface render its not-applicable
176
+ // fallback (Root Cause #57).
177
+ emiEnabled: doc.emiEnabled,
178
+ emiInstallments: doc.emiInstallments,
179
+ emiRemainingBalance: doc.emiRemainingBalance,
173
180
  giftWrapAddon: doc.giftWrapAddon,
174
181
  giftWrapFee: doc.giftWrapFee,
175
182
  giftWrapMessage: doc.giftWrapMessage,
@@ -0,0 +1,28 @@
1
+ import { type ActualShipmentCost } from "../../../shared/shipping/quote";
2
+ export interface ReconcileShippingResult {
3
+ /** `null` when there was nothing to reconcile — see the reasons below. */
4
+ outcome: "credited" | "seller-absorbed" | "exact" | null;
5
+ sellerAbsorbs: number;
6
+ buyerPoints: number;
7
+ /** Why nothing happened, when `outcome` is null. For logs and the UI. */
8
+ skipped?: "no-quote" | "already-reconciled" | "no-buyer";
9
+ }
10
+ /**
11
+ * 🛑 IDEMPOTENT, and the guard is `order.shippingReconciliation` being unset.
12
+ *
13
+ * Crediting points is an APPEND to a ledger. Appends do not deduplicate
14
+ * themselves, so a dispatch action that fired twice — a double-clicked button,
15
+ * a retried job, a webhook delivered again — would pay the buyer twice, and
16
+ * nothing downstream could distinguish that from two genuine savings. The
17
+ * reconciliation record is written in the same step as the credit precisely so
18
+ * it can be the key.
19
+ *
20
+ * It deliberately does NOT use a transaction spanning the order and the
21
+ * ledger: `pointsRepository.append` already runs its own transaction over the
22
+ * ledger and the balance mirror, and nesting would mean reading the order
23
+ * inside it for no added safety. The residual risk is a crash between the
24
+ * credit and the order write, which leaves a credited buyer and an order that
25
+ * looks unreconciled — recoverable, and strictly better than the alternative
26
+ * ordering, which would mark the order done and then fail to pay.
27
+ */
28
+ export declare function reconcileOrderShipping(orderId: string, actual: ActualShipmentCost): Promise<ReconcileShippingResult>;