@mohasinac/appkit 4.56.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 (52) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +31 -0
  3. package/dist/_internal/server/features/checkout/actions.js +117 -5
  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/shared/actions/action-registry.js +53 -0
  8. package/dist/_internal/shared/shipping/quote.js +5 -6
  9. package/dist/client.d.ts +21 -0
  10. package/dist/client.js +2 -0
  11. package/dist/features/about/components/PublicProfileView.js +2 -1
  12. package/dist/features/account/components/UserOrdersView.js +25 -3
  13. package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
  14. package/dist/features/admin/components/OwnerQuickActions.js +53 -6
  15. package/dist/features/blog/components/BlogPostView.js +2 -1
  16. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  17. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  18. package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
  19. package/dist/features/faq/components/FAQPageContent.js +17 -4
  20. package/dist/features/faq/constants/help-guides.d.ts +36 -0
  21. package/dist/features/faq/constants/help-guides.js +64 -0
  22. package/dist/features/homepage/components/HeroCarousel.js +3 -1
  23. package/dist/features/homepage/lib/section-renderer.js +2 -1
  24. package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
  25. package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
  26. package/dist/features/orders/components/index.d.ts +1 -0
  27. package/dist/features/orders/components/index.js +1 -0
  28. package/dist/features/orders/schemas/index.d.ts +2 -2
  29. package/dist/features/orders/types/index.d.ts +15 -0
  30. package/dist/features/points/repository/points.repository.js +5 -5
  31. package/dist/features/points/schemas/firestore.d.ts +16 -0
  32. package/dist/features/points/schemas/firestore.js +17 -0
  33. package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
  34. package/dist/features/stores/components/StoreHeader.js +3 -1
  35. package/dist/next/routing/route-map.d.ts +4 -0
  36. package/dist/next/routing/route-map.js +2 -0
  37. package/dist/schemas/registry.d.ts +2 -2
  38. package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
  39. package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
  40. package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
  41. package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
  42. package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
  43. package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
  44. package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
  45. package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
  46. package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
  47. package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
  48. package/dist/seed/_helpers/category-models.d.ts +9 -2
  49. package/dist/seed/_helpers/category-models.js +19 -1368
  50. package/dist/server.d.ts +3 -1
  51. package/dist/server.js +11 -0
  52. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.56.0",
4
- "head": "a2d85e72b5cac1f1a55ae7386f661b293eccd704",
3
+ "version": "4.57.0",
4
+ "head": "de32aea7a1bbb5fe9970959866c5278c2f9e4e49",
5
5
  "branch": "main",
6
6
  "dirty": true,
7
- "builtAt": "2026-10-20T16:58:51.945Z"
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. */
@@ -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";
@@ -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)` : ""));
@@ -1572,7 +1644,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1572
1644
  // Reuses the same store/seller lookup as the COD/UPI path above instead
1573
1645
  // of re-implementing it inline — was two sequential findById calls per
1574
1646
  // seller group here, duplicated from resolveShippingCost.
1575
- const { shippingFee, storeOwnerId, storeState, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
1647
+ const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections } = await resolveShippingCost(firstItem.storeId);
1576
1648
  /*
1577
1649
  * Same supplier-of-record rule as `createOrderForGroup`, through the same
1578
1650
  * function. This path (PhonePe verify / webhook) is the SECOND place an
@@ -1608,7 +1680,44 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1608
1680
  const addons = storeAddons?.[firstItem.storeId] ?? {};
1609
1681
  const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
1610
1682
  const giftWrapMessage = addons.giftWrapMessage;
1611
- 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
+ });
1612
1721
  /*
1613
1722
  * P-8 GST — now wired through this path. C0c defect 1.
1614
1723
  *
@@ -1641,7 +1750,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1641
1750
  /* Product GST is not a term — prices are tax-inclusive, so it is already
1642
1751
  * inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
1643
1752
  * stays because that is GST on our fee, charged on top. */
1644
- 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;
1645
1754
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
1646
1755
  // Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
1647
1756
  // the price rule (`isBundle ? item.price : product.price`) that — like the
@@ -1730,6 +1839,9 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1730
1839
  giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
1731
1840
  shipmentProtectionAddon: shipmentProtectionFee > 0 ? true : undefined,
1732
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,
1733
1845
  /*
1734
1846
  * The tax breakdown, matching `createOrderForGroup` field for field.
1735
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,
@@ -1039,6 +1039,21 @@ export const ACTIONS = {
1039
1039
  },
1040
1040
  },
1041
1041
  BLOG: {
1042
+ /*
1043
+ * Clone, admin-only, backed by POST /api/admin/blog/[id]/duplicate. A
1044
+ * templated post is the real use case: same structure and category, new
1045
+ * subject. The copy starts as a DRAFT with a freshly derived slug — blog is
1046
+ * a pure-slug resource, so reusing the source slug would overwrite it.
1047
+ */
1048
+ "duplicate-post": {
1049
+ iconKey: "duplicate",
1050
+ id: "blog.duplicate-post",
1051
+ label: "Duplicate",
1052
+ ariaLabel: "Duplicate this post as a draft",
1053
+ description: "Copy this post into a new draft, without its views or featured flag.",
1054
+ kind: "secondary",
1055
+ permissions: ["admin"],
1056
+ },
1042
1057
  "create-post": {
1043
1058
  iconKey: "create",
1044
1059
  id: "blog.create-post",
@@ -1113,6 +1128,23 @@ export const ACTIONS = {
1113
1128
  },
1114
1129
  },
1115
1130
  EVENT: {
1131
+ /*
1132
+ * Clone, admin-only, backed by POST /api/admin/events/[id]/duplicate. A
1133
+ * recurring sale is the real use case: same copy, same prizes, new dates.
1134
+ *
1135
+ * 🛑 The endpoint rebuilds every lottery slot from its prize definition and
1136
+ * resets the booking half — a spread would attribute a real buyer’s
1137
+ * purchased slot to a brand-new event.
1138
+ */
1139
+ duplicate: {
1140
+ iconKey: "duplicate",
1141
+ id: "event.duplicate",
1142
+ label: "Duplicate",
1143
+ ariaLabel: "Duplicate this event as a draft",
1144
+ description: "Copy this event into a new draft, without its entries, winner or slot bookings.",
1145
+ kind: "secondary",
1146
+ permissions: ["admin"],
1147
+ },
1116
1148
  /*
1117
1149
  * This bucket had only register / cancel-registration / draw-raffle-winner
1118
1150
  * — no edit, no delete — so the owner affordance on an event card or detail
@@ -2208,6 +2240,27 @@ export const ACTIONS = {
2208
2240
  kind: "ghost",
2209
2241
  permissions: ["admin"],
2210
2242
  },
2243
+ /*
2244
+ * Homepage sections have no bucket of their own, so their edit leaf lives
2245
+ * here beside the carousel pair for the same reason — both are homepage
2246
+ * chrome an admin edits from a list rather than from a detail page.
2247
+ *
2248
+ * 🛑 There is deliberately NO `delete-section` counterpart. Section `order`
2249
+ * is POSITIONAL, so removing one silently renumbers its neighbours — not
2250
+ * something to offer from the public homepage, where the only confirmation
2251
+ * a person gets is a dialog. `OwnerQuickActions` enforces the absence by
2252
+ * omitting `remove` from the SECTION mapping, so no call site can pass a
2253
+ * delete handler and have it render.
2254
+ */
2255
+ "edit-section": {
2256
+ iconKey: "edit",
2257
+ id: "admin.edit-section",
2258
+ label: "Edit Section",
2259
+ ariaLabel: "Edit this homepage section",
2260
+ description: "Open the homepage sections list, which owns this section's config.",
2261
+ kind: "ghost",
2262
+ permissions: ["admin"],
2263
+ },
2211
2264
  "delete-carousel": {
2212
2265
  iconKey: "delete",
2213
2266
  id: "admin.delete-carousel",
@@ -78,7 +78,7 @@ export function roundShippingUp(rawRate) {
78
78
  export function reconcileShipping(quote, actual) {
79
79
  const charged = Number.isFinite(quote.chargedRate) ? Math.max(0, quote.chargedRate) : 0;
80
80
  const spent = Number.isFinite(actual.actualRate) ? Math.max(0, actual.actualRate) : 0;
81
- const delta = roundPaise(charged - spent);
81
+ const delta = roundMoney(charged - spent);
82
82
  return {
83
83
  sellerAbsorbs: delta < 0 ? Math.abs(delta) : 0,
84
84
  buyerPoints: delta > 0 ? delta : 0,
@@ -88,12 +88,11 @@ export function reconcileShipping(quote, actual) {
88
88
  /**
89
89
  * Two decimal places.
90
90
  *
91
- * Money here is decimal rupees (the paise→rupees migration, Root Cause #31), so
92
- * a subtraction can leave a float artefact — `100 - 47.1` is `52.900000000000006`,
93
- * and that would become a points credit with eleven decimal places on a user's
94
- * balance.
91
+ * Money is stored as decimal rupees (Root Cause #31), so a subtraction can
92
+ * leave a float artefact — `100 - 47.1` is `52.900000000000006`, and that would
93
+ * become a points credit with eleven decimal places on a user's balance.
95
94
  */
96
- function roundPaise(value) {
95
+ function roundMoney(value) {
97
96
  return Math.round(value * 100) / 100;
98
97
  }
99
98
  /**
package/dist/client.d.ts CHANGED
@@ -387,6 +387,8 @@ export type { UserAccountHubViewProps, UserAccountHubViewLabels, UserOrdersViewP
387
387
  export { useOrders, useOrder, OrdersList } from "./features/orders/index";
388
388
  export { OrderStatusTimeline } from "./features/orders/components/OrderStatusTimeline";
389
389
  export type { OrderStatusTimelineProps } from "./features/orders/components/OrderStatusTimeline";
390
+ export { EmiInstallmentsPanel } from "./features/orders/components/EmiInstallmentsPanel";
391
+ export type { EmiInstallmentsPanelProps } from "./features/orders/components/EmiInstallmentsPanel";
390
392
  export { CountdownDisplay } from "./ui/components/CountdownDisplay";
391
393
  export type { CountdownDisplayProps } from "./ui/components/CountdownDisplay";
392
394
  export { OrderAddonBadges } from "./features/orders/components/OrderAddonBadges";
@@ -584,3 +586,22 @@ export { PriceIndexToolView } from "./features/price-index/components/PriceIndex
584
586
  export type { PriceIndexToolViewProps, PriceIndexToolResponse, PriceIndexResultRow, PriceIndexBandRow, PriceIndexConditionRow, } from "./features/price-index/components/PriceIndexToolView";
585
587
  export { COURIER_RATING_MIN_CONFIDENT_SAMPLES, courierRatingDisplay, isValidCourierVote, courierVoteId, rollUpCourierVotes, } from "./_internal/shared/features/shipping/courier-rating";
586
588
  export type { CourierRatingPair, CourierRatingDisplay, CourierRatingVote, CourierStats, } from "./_internal/shared/features/shipping/courier-rating";
589
+ /**
590
+ * The `/api/checkout/pricing-preview` RESPONSE CONTRACT.
591
+ *
592
+ * 🛑 `export type`, from a `_internal/server` module, deliberately. A type-only
593
+ * re-export is fully erased at compile time — `audit-client-server-only-leak`
594
+ * states that explicitly — so this pulls no server chain into the client
595
+ * bundle. It is the one exception worth making for this module.
596
+ *
597
+ * The consumer had been keeping a HAND-WRITTEN copy of both shapes in
598
+ * `src/lib/api/payment-client.ts`, under a comment reading "mirrors
599
+ * `CheckoutPricingPreviewStore` on the server". It drifted exactly as Root
600
+ * Cause #42 predicts: the server gained `emiSurcharge`, the mirror did not, and
601
+ * the preview the cart rendered could not be assigned to the breakdown
602
+ * component that displays it. Four `tsc` errors across the cart and checkout
603
+ * clients, all from one missing field in a second definition of one shape.
604
+ *
605
+ * A response contract has exactly one owner — the handler that produces it.
606
+ */
607
+ export type { CheckoutPricingPreview, CheckoutPricingPreviewStore, } from "./_internal/server/features/checkout/actions";
package/dist/client.js CHANGED
@@ -405,6 +405,8 @@ export { couponDraftToPayload } from "./features/seller/schemas/coupon-form.js";
405
405
  export { UserAccountHubView, UserOrdersView, UserBidsView, OrderDetailView, UserReturnsView, useNotifications } from "./features/account/index.js";
406
406
  export { useOrders, useOrder, OrdersList } from "./features/orders/index.js";
407
407
  export { OrderStatusTimeline } from "./features/orders/components/OrderStatusTimeline.js";
408
+ // Buyer-facing EMI schedule with a pay-any-instalment action.
409
+ export { EmiInstallmentsPanel } from "./features/orders/components/EmiInstallmentsPanel.js";
408
410
  export { CountdownDisplay } from "./ui/components/CountdownDisplay.js";
409
411
  export { OrderAddonBadges } from "./features/orders/components/OrderAddonBadges.js";
410
412
  // Manual-payment (cash / UPI / EMI) shared constants — pure data + predicates,
@@ -16,6 +16,7 @@ import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js"
16
16
  import { normalizeListingType } from "../../products/utils/listing-type.js";
17
17
  import { isAdminUser, isSellerUser } from "../../auth/role-predicates.js";
18
18
  import { User, Star, ShoppingBag, Package, Trophy, Globe, MapPin, ExternalLink } from "lucide-react";
19
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
19
20
  const __P = {
20
21
  p4: "p-[var(--appkit-space-4)]",
21
22
  p6: "p-[var(--appkit-space-6)]",
@@ -120,7 +121,7 @@ export async function PublicProfileView({ userId, tab, }) {
120
121
  const profileHeroCtx = { displayName, photoURL, memberSince, isSeller, storeSlug, flex, page };
121
122
  const statItems = buildProfileStatItems(t, { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller });
122
123
  const activeTab = tab === "catalogue" ? "catalogue" : "overview";
123
- return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
124
+ return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsx(Row, { justify: "end", children: _jsx(OwnerQuickActions, { resource: "USER", placement: "detail", recordLabel: displayName, editHref: String(ROUTES.ADMIN.USER_EDIT(userId)) }) }), _jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
124
125
  }
125
126
  function buildProfileStatItems(t, ctx) {
126
127
  const { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller } = ctx;
@@ -51,6 +51,27 @@ const ORDER_LANE_TABS = [
51
51
  { id: "auction", label: "Auction wins" },
52
52
  { id: "offer", label: "Offer wins" },
53
53
  ];
54
+ /**
55
+ * Payment-method tabs — a THIRD axis, independent of the lane tabs above and
56
+ * the lifecycle scope beside them.
57
+ *
58
+ * 🛑 EMI is not a lane and not a status, which is why it could not join
59
+ * either existing control: `orderType` records how the order was WON
60
+ * (standard / auction / offer) and the scope records where it IS. How it is
61
+ * being PAID is orthogonal to both — an auction win can be on an EMI plan.
62
+ *
63
+ * Every id is exactly a stored `OrderDocument.paymentMethod` value, and
64
+ * `paymentMethod` is `canFilter: true` in the orders SIEVE_FIELDS — checked
65
+ * before wiring this, because an unallowlisted field does not raise: the
66
+ * clause is silently dropped and the chip returns the ENTIRE order list while
67
+ * looking like a filter (Root Cause #62).
68
+ */
69
+ const PAYMENT_METHOD_TABS = [
70
+ { id: "", label: "Any" },
71
+ { id: "emi", label: "EMI plans" },
72
+ { id: "cod", label: "Cash on delivery" },
73
+ { id: "online", label: "Paid online" },
74
+ ];
54
75
  const STATUS_OPTIONS = [
55
76
  { value: "pending", label: "Pending" },
56
77
  { value: "processing", label: "Processing" },
@@ -85,7 +106,7 @@ export function UserOrdersView({ onOrderClick }) {
85
106
  * (CLAUDE.md, Root Cause #35).
86
107
  */
87
108
  const searchParams = useSearchParams();
88
- const hasNarrowingFilter = ["q", "search", "status", "orderType", "filters"].some((k) => Boolean(searchParams.get(k)));
109
+ const hasNarrowingFilter = ["q", "search", "status", "orderType", "paymentMethod", "filters"].some((k) => Boolean(searchParams.get(k)));
89
110
  const emptyMessage = hasNarrowingFilter
90
111
  ? "No orders match your search or filters."
91
112
  : "You haven't placed any orders yet.";
@@ -104,7 +125,7 @@ export function UserOrdersView({ onOrderClick }) {
104
125
  fields: ["id", "productTitle"],
105
126
  },
106
127
  emptyLabel: emptyMessage,
107
- filterKeys: ["status", "orderType"],
128
+ filterKeys: ["status", "orderType", "paymentMethod"],
108
129
  defaultSort: sortBy("createdAt", "DESC"),
109
130
  queryKey: ["user", "orders", "listing"],
110
131
  endpoint: ACCOUNT_ENDPOINTS.ORDERS,
@@ -115,6 +136,7 @@ export function UserOrdersView({ onOrderClick }) {
115
136
  buildFilters: (state) => [
116
137
  state.status ? sieveFilter("status", SIEVE_OP.EQ, state.status) : null,
117
138
  state.orderType ? sieveFilter("orderType", SIEVE_OP.EQ, state.orderType) : null,
139
+ state.paymentMethod ? sieveFilter("paymentMethod", SIEVE_OP.EQ, state.paymentMethod) : null,
118
140
  ]
119
141
  .filter(Boolean)
120
142
  .join(",") || undefined,
@@ -123,7 +145,7 @@ export function UserOrdersView({ onOrderClick }) {
123
145
  // in flight" without those two questions fighting over one control.
124
146
  buildExtraParams: () => orderScope.extraParams,
125
147
  renderAboveContent: orderScope.renderAboveContent,
126
- renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
148
+ renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(FilterChipGroup, { label: "Payment", tabs: PAYMENT_METHOD_TABS, value: pendingFilters.paymentMethod ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, paymentMethod: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
127
149
  renderCards: (rows, _view, _selection, isLoading) => {
128
150
  if (isLoading) {
129
151
  return (_jsx(Stack, { gap: "md", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Div, { className: "h-24 animate-pulse border border-[var(--appkit-color-border)]", rounded: "xl" }, i))) }));
@@ -1,5 +1,5 @@
1
1
  /** Which registry bucket supplies the labels and the confirm copy. */
2
- export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW";
2
+ export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "FRANCHISE" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW" | "STORE" | "USER" | "CAROUSEL" | "SECTION";
3
3
  export interface OwnerQuickActionsProps {
4
4
  resource: OwnerQuickActionsResource;
5
5
  /** Where the pencil goes — that record's dashboard editor. */