@mohasinac/appkit 4.55.0-next.0 → 4.56.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 +22 -0
  3. package/dist/_internal/server/features/checkout/actions.js +100 -5
  4. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  5. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  6. package/dist/_internal/shared/config/schema.d.ts +6 -0
  7. package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
  8. package/dist/_internal/shared/features/checkout/config.js +1 -1
  9. package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
  10. package/dist/_internal/shared/features/site-settings/fees.js +1 -1
  11. package/dist/_internal/shared/fees/calculator.d.ts +35 -3
  12. package/dist/_internal/shared/fees/calculator.js +34 -4
  13. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  14. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  15. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  16. package/dist/_internal/shared/shipping/quote.js +124 -0
  17. package/dist/client.d.ts +1 -0
  18. package/dist/client.js +8 -0
  19. package/dist/errors/error-codes.d.ts +15 -0
  20. package/dist/errors/error-codes.js +15 -0
  21. package/dist/errors/index.d.ts +1 -1
  22. package/dist/errors/index.js +1 -1
  23. package/dist/features/account/components/UserBidsView.js +4 -2
  24. package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
  25. package/dist/features/admin/schemas/firestore.d.ts +37 -2
  26. package/dist/features/admin/schemas/firestore.js +12 -1
  27. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  28. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  29. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  30. package/dist/features/checkout/schemas/firestore.d.ts +12 -1
  31. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  32. package/dist/features/homepage/components/HeroCarousel.js +4 -2
  33. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  34. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  35. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  36. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  37. package/dist/features/homepage/lib/section-renderer.js +22 -5
  38. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  39. package/dist/features/homepage/lib/slide-gating.js +98 -0
  40. package/dist/features/layout/NavbarLayout.js +139 -32
  41. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  42. package/dist/features/media/MediaAvatar.js +1 -1
  43. package/dist/features/media/MediaImage.d.ts +18 -1
  44. package/dist/features/media/MediaImage.js +1 -1
  45. package/dist/features/media/messages/en.json +67 -67
  46. package/dist/features/orders/schemas/firestore.d.ts +39 -1
  47. package/dist/features/points/repository/points.repository.d.ts +81 -0
  48. package/dist/features/points/repository/points.repository.js +155 -0
  49. package/dist/features/points/schemas/firestore.d.ts +102 -0
  50. package/dist/features/points/schemas/firestore.js +99 -0
  51. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  52. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  53. package/dist/features/products/components/ProductDetailPageView.js +5 -26
  54. package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
  55. package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
  56. package/dist/features/products/components/ProductsIndexListing.js +35 -5
  57. package/dist/features/products/components/ProductsIndexPageView.js +40 -7
  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/index.d.ts +1 -0
  61. package/dist/index.js +8 -0
  62. package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
  63. package/dist/providers/shipping-shiprocket/index.js +34 -10
  64. package/dist/security/csp.d.ts +1 -16
  65. package/dist/security/csp.js +3 -5
  66. package/dist/seed/carousel-slides-seed-data.js +48 -7
  67. package/dist/seed/faq-seed-data.js +1 -1
  68. package/dist/seed/site-settings-seed-data.js +1 -1
  69. package/dist/seo/json-ld.d.ts +8 -1
  70. package/dist/seo/json-ld.js +39 -2
  71. package/dist/styles.css +419 -542
  72. package/dist/tailwind-utilities.css +1 -1
  73. package/dist/tokens/themes/index.d.ts +1 -3
  74. package/dist/tokens/themes/index.js +1 -11
  75. package/dist/tokens/tokens.css +0 -155
  76. package/dist/ui/components/GatedPrice.style.css +43 -43
  77. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  78. package/dist/ui/components/ImageMosaic.js +28 -5
  79. package/dist/ui/components/ImageMosaic.style.css +37 -5
  80. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  81. package/dist/ui/forms/FormShell.style.css +260 -260
  82. package/package.json +1 -1
  83. package/dist/tokens/themes/technical-dark.d.ts +0 -30
  84. package/dist/tokens/themes/technical-dark.js +0 -103
  85. package/dist/tokens/themes/technical-light.d.ts +0 -22
  86. package/dist/tokens/themes/technical-light.js +0 -89
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.55.0-next.0",
4
- "head": "549abc780f1aa0e87661e86d9c76718bfe3d6cb0",
5
- "branch": "feat/mohasinac-site",
3
+ "version": "4.56.0",
4
+ "head": "a2d85e72b5cac1f1a55ae7386f661b293eccd704",
5
+ "branch": "main",
6
6
  "dirty": true,
7
- "builtAt": "2026-10-20T12:44:38.195Z"
7
+ "builtAt": "2026-10-20T16:58:51.945Z"
8
8
  }
@@ -105,6 +105,18 @@ export interface CheckoutPricingPreviewInput {
105
105
  * is currently gated off.
106
106
  */
107
107
  lane?: CartLane;
108
+ /**
109
+ * The chosen EMI tenure, when `paymentMethod` is `"emi"`.
110
+ *
111
+ * 🛑 REQUIRED for an EMI preview to be truthful. The surcharge is
112
+ * `principalRemaining × surchargePercentPerMonth × tenureMonths`, so it
113
+ * cannot be derived from the cart alone — and without it this function
114
+ * returned a total that `createOrderForGroup` then exceeded by exactly that
115
+ * amount. Unlike the add-ons below, the tenure is NOT on the cart document:
116
+ * it is picked on the payment step and belongs to the order, so it has to
117
+ * travel with the request.
118
+ */
119
+ emiTenureMonths?: number;
108
120
  }
109
121
  /** One store's slice of the preview — what that seller's order will cost. */
110
122
  export interface CheckoutPricingPreviewStore {
@@ -116,6 +128,14 @@ export interface CheckoutPricingPreviewStore {
116
128
  giftWrapFee: number;
117
129
  shipmentProtectionFee: number;
118
130
  saleProtectionFee: number;
131
+ /**
132
+ * EMI surcharge for THIS seller, when the buyer is previewing an EMI order.
133
+ *
134
+ * Per store because `computeEmiSchedule` takes a SELLER subtotal — the
135
+ * financed principal is per order, not per cart, so a cart-wide figure
136
+ * would not match any order written.
137
+ */
138
+ emiSurcharge: number;
119
139
  gstAmount: number;
120
140
  couponDiscount: number;
121
141
  total: number;
@@ -147,6 +167,8 @@ export interface CheckoutPricingPreview {
147
167
  giftWrapFee: number;
148
168
  shipmentProtectionFee: number;
149
169
  saleProtectionFee: number;
170
+ /** Total EMI surcharge across sellers. Zero unless previewing an EMI order. */
171
+ emiSurcharge: number;
150
172
  gstAmount: number;
151
173
  couponDiscount: number;
152
174
  /** Buyer-facing platform commission, capped, charged once for this checkout. */
@@ -15,6 +15,8 @@ import { serverLogger } from "../../../../monitoring/index.js";
15
15
  import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories/index.js";
16
16
  import { isIntraStateSupply } from "../../../shared/fees/calculator.js";
17
17
  import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
18
+ import { COD_MAX_ORDER_VALUE_FALLBACK } from "../../../../features/admin/schemas/firestore.js";
19
+ import { CHECKOUT_ERROR_CODES } from "../../../../errors/error-codes.js";
18
20
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository.js";
19
21
  import { sendOrderConfirmationEmail } from "../../../../features/contact/server.js";
20
22
  import { splitCartIntoOrderGroups } from "../../../../features/orders/index.js";
@@ -42,7 +44,7 @@ import { verifyPaymentSignature as verifyRazorpayPaymentSignature } from "../../
42
44
  import { resolveKeys } from "../../../../core/integration-keys.js";
43
45
  import { paiseToRupees } from "../../../../core/money.js";
44
46
  import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config.js";
45
- import { checkEmiEligibility, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
47
+ import { checkEmiEligibility, computeBuyerEmiQuote, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
46
48
  import { formatShippingAddress } from "./data.js";
47
49
  import { enforceMaxPerUserForCart } from "./prize-bundle-gates.js";
48
50
  import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, } from "../../../shared/checkout/rules/index.js";
@@ -870,6 +872,39 @@ export async function createCheckoutOrderAction(input) {
870
872
  }
871
873
  }
872
874
  }
875
+ /*
876
+ * COD value cap — the SERVER half of the gate.
877
+ *
878
+ * 🛑 The checkout UI already disables the COD option above the cap and says
879
+ * why. That is an affordance, not enforcement: this action is reachable by
880
+ * POST with `paymentMethod: "cod"` regardless of what the page rendered, and
881
+ * the whole point of the cap is the cash exposure on a refusal at the door.
882
+ * A limit enforced only in the browser is not a limit (Root Cause #46 — a
883
+ * field the schema accepts but the handler never verifies).
884
+ *
885
+ * Measured against the WHOLE checkout batch for the same reason the OTP gate
886
+ * above is: per-seller orders are a split of one cart, so capping each one
887
+ * would let a buyer clear a ₹3,000 limit with a ₹9,000 cart across three
888
+ * sellers. Same `cartItems` reduction, deliberately not shared with the OTP
889
+ * block — that one skips COD entirely, so the two can never be one test.
890
+ *
891
+ * `>` not `>=`: the setting is a maximum, so a cart exactly at the cap is
892
+ * allowed. The client comparison is spelled the same way.
893
+ */
894
+ if (!adminBypass && paymentMethod === "cod") {
895
+ const codMax = siteSettings?.payment?.codMaxOrderValue;
896
+ const effectiveCodMax = typeof codMax === "number" ? codMax : COD_MAX_ORDER_VALUE_FALLBACK;
897
+ const cartTotalForCodCheck = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
898
+ if (cartTotalForCodCheck > effectiveCodMax) {
899
+ failedCheckoutRepository
900
+ .logCheckout(uid, "cod_over_limit", "Cart exceeds the COD value cap", {
901
+ addressId: addressId ?? "",
902
+ paymentMethod,
903
+ })
904
+ .catch(() => undefined);
905
+ throw new ApiError(400, CHECKOUT_ERROR_CODES.COD_LIMIT_EXCEEDED);
906
+ }
907
+ }
873
908
  const isDigitalCart = cartIsDigitalOnly(cartItems);
874
909
  let shippingAddress;
875
910
  let resolvedAddress = null;
@@ -1137,6 +1172,7 @@ const EMPTY_PRICING_PREVIEW = {
1137
1172
  giftWrapFee: 0,
1138
1173
  shipmentProtectionFee: 0,
1139
1174
  saleProtectionFee: 0,
1175
+ emiSurcharge: 0,
1140
1176
  gstAmount: 0,
1141
1177
  couponDiscount: 0,
1142
1178
  platformFee: 0,
@@ -1154,7 +1190,7 @@ const EMPTY_PRICING_PREVIEW = {
1154
1190
  * stock, creates an order, or writes coupon usage.
1155
1191
  */
1156
1192
  export async function previewCheckoutPricing(input) {
1157
- const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, } = input;
1193
+ const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, emiTenureMonths, } = input;
1158
1194
  const siteSettings = await siteSettingsRepository.getSingleton();
1159
1195
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
1160
1196
  const cart = await unitOfWork.carts.getOrCreate(uid);
@@ -1207,7 +1243,7 @@ export async function previewCheckoutPricing(input) {
1207
1243
  for (const { items: group } of orderGroups) {
1208
1244
  const firstItem = group[0].item;
1209
1245
  const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
1210
- const { shippingFee: groupShippingFee, storeState, storeProtections } = await resolveShippingCost(firstItem.storeId);
1246
+ const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId);
1211
1247
  // Add-ons are per store, read off the cart document — the same entry
1212
1248
  // createOrderForGroup will read when the order is actually placed.
1213
1249
  const addons = cart.storeAddons?.[firstItem.storeId] ?? {};
@@ -1215,6 +1251,36 @@ export async function previewCheckoutPricing(input) {
1215
1251
  // ever charged for literal "cod" (pay the courier), never upi_manual or
1216
1252
  // cash (both pre-paid via UPI/bank transfer before dispatch).
1217
1253
  const groupCodHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
1254
+ /*
1255
+ * 🛑 The EMI surcharge is a TERM OF THE TOTAL, and it was missing here
1256
+ * while `createOrderForGroup` added it — a shown-vs-charged drift of
1257
+ * exactly the kind this function exists to prevent.
1258
+ *
1259
+ * Measured on the live settings (tokenPercent 10, surchargePercentPerMonth
1260
+ * 1) against the ₹20,000 listing: token ₹2,000, financed ₹18,000, so the
1261
+ * surcharge is ₹360 at the default 2-month tenure and ₹1,080 at 6. The
1262
+ * Order Summary showed ₹20,011.80 either way, and the order recorded up to
1263
+ * ₹1,080 more. Worse, the EMI panel beside it already displayed
1264
+ * instalments computed WITH the surcharge, so the page disagreed with
1265
+ * itself.
1266
+ *
1267
+ * `computeBuyerEmiQuote`, never `computeEmiSchedule`: the latter also
1268
+ * returns the platform/seller split of the surcharge, which is our
1269
+ * commercial arrangement with the seller and has no business reaching a
1270
+ * browser (see § "Buyer-facing fees").
1271
+ *
1272
+ * Eligibility is deliberately NOT enforced here. A preview that threw
1273
+ * would blank the Order Summary; `createOrderForGroup` still refuses an
1274
+ * ineligible EMI order, which is where refusing belongs. An ineligible
1275
+ * cart simply previews no surcharge, because the tenure never applies.
1276
+ */
1277
+ let groupEmiSurcharge = 0;
1278
+ if (paymentMethod === "emi" && emiTenureMonths && siteSettings?.emi) {
1279
+ const emiSettings = siteSettings.emi;
1280
+ if (checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings).eligible) {
1281
+ groupEmiSurcharge = computeBuyerEmiQuote(groupTotal, emiTenureMonths, emiSettings).surchargeAmount;
1282
+ }
1283
+ }
1218
1284
  const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
1219
1285
  /*
1220
1286
  * C5e — the same three tiers `createOrderForGroup` enforces, so the figure
@@ -1267,6 +1333,7 @@ export async function previewCheckoutPricing(input) {
1267
1333
  subtotal: groupTotal,
1268
1334
  shippingFee: groupShippingFee,
1269
1335
  codHandlingFee: groupCodHandlingFee,
1336
+ emiSurcharge: groupEmiSurcharge,
1270
1337
  giftWrapFee: groupGiftWrapFee,
1271
1338
  shipmentProtectionFee: groupShipmentProtectionFee,
1272
1339
  saleProtectionFee: groupSaleProtectionFee,
@@ -1284,6 +1351,7 @@ export async function previewCheckoutPricing(input) {
1284
1351
  total: Math.max(0, groupTotal - groupCouponDiscount) +
1285
1352
  groupShippingFee +
1286
1353
  groupCodHandlingFee +
1354
+ groupEmiSurcharge +
1287
1355
  groupGiftWrapFee +
1288
1356
  groupShipmentProtectionFee +
1289
1357
  groupSaleProtectionFee,
@@ -1295,6 +1363,7 @@ export async function previewCheckoutPricing(input) {
1295
1363
  const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
1296
1364
  const shippingFee = sum((s) => s.shippingFee);
1297
1365
  const codHandlingFee = sum((s) => s.codHandlingFee);
1366
+ const emiSurcharge = sum((s) => s.emiSurcharge);
1298
1367
  const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
1299
1368
  const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
1300
1369
  const saleProtectionFeeTotal = sum((s) => s.saleProtectionFee);
@@ -1303,19 +1372,45 @@ export async function previewCheckoutPricing(input) {
1303
1372
  // One capped commission for the whole checkout, on every payment method —
1304
1373
  // deliberately not per store, so it is not added to the per-store rows above.
1305
1374
  const { platformFee, gstOnFee } = computeCheckoutFees(cartSubtotal, commissions);
1375
+ /*
1376
+ * 🛑 `gstAmount` is NOT a term, and leaving it in was a shown-vs-charged
1377
+ * drift waiting on a settings flag.
1378
+ *
1379
+ * The per-store `total` above already excludes it, and its own field comment
1380
+ * says so in capitals ("Deliberately NOT added to `total` below"). The
1381
+ * CART-level total here added it anyway, so the two disagreed with each
1382
+ * other and the cart-level one disagreed with `createOrderForGroup`, whose
1383
+ * `orderTotal` excludes product GST for the reason set out there: prices are
1384
+ * tax-INCLUSIVE, so the tax is already inside `cartSubtotal` and adding it
1385
+ * charges it twice.
1386
+ *
1387
+ * It has never fired, which is the only reason it survived: `gstAmount` is
1388
+ * computed solely when `siteSettings.gst.enabled` is true, and that is
1389
+ * deliberately false until a real GSTIN is registered (see the seed's note —
1390
+ * `/api/admin/site` refuses to enable it without a valid check digit). So on
1391
+ * the day someone enters a registration, every cart would have quietly
1392
+ * quoted ~5% more than the order it previews. Measured on the live ₹20,000
1393
+ * listing (gstRate 5, HSN 95030020): the preview would have read ₹952.38
1394
+ * over the amount charged.
1395
+ *
1396
+ * `gstOnFee` IS a term, and the asymmetry is the same one `createOrderForGroup`
1397
+ * documents: that is GST on OUR commission, billed on top of the fee, not
1398
+ * tax on the goods.
1399
+ */
1306
1400
  const total = Math.max(0, cartSubtotal - couponDiscount) +
1307
1401
  shippingFee +
1308
1402
  codHandlingFee +
1403
+ emiSurcharge +
1309
1404
  giftWrapFeeTotal +
1310
1405
  shipmentProtectionFeeTotal +
1311
1406
  saleProtectionFeeTotal +
1312
1407
  platformFee +
1313
- gstOnFee +
1314
- gstAmount;
1408
+ gstOnFee;
1315
1409
  return {
1316
1410
  subtotal: cartSubtotal,
1317
1411
  shippingFee,
1318
1412
  codHandlingFee,
1413
+ emiSurcharge,
1319
1414
  giftWrapFee: giftWrapFeeTotal,
1320
1415
  shipmentProtectionFee: shipmentProtectionFeeTotal,
1321
1416
  saleProtectionFee: saleProtectionFeeTotal,
@@ -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>;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Close the loop on one order's shipping: compare what the buyer was charged
3
+ * with what the seller actually spent, and credit the difference as points.
4
+ *
5
+ * Called from the seller's dispatch path, once the real postage is known.
6
+ */
7
+ import { orderRepository } from "../../../../features/orders/repository/orders.repository.js";
8
+ import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
9
+ import { reconcileShipping, } from "../../../shared/shipping/quote.js";
10
+ import { serverLogger } from "../../../../monitoring/index.js";
11
+ /**
12
+ * 🛑 IDEMPOTENT, and the guard is `order.shippingReconciliation` being unset.
13
+ *
14
+ * Crediting points is an APPEND to a ledger. Appends do not deduplicate
15
+ * themselves, so a dispatch action that fired twice — a double-clicked button,
16
+ * a retried job, a webhook delivered again — would pay the buyer twice, and
17
+ * nothing downstream could distinguish that from two genuine savings. The
18
+ * reconciliation record is written in the same step as the credit precisely so
19
+ * it can be the key.
20
+ *
21
+ * It deliberately does NOT use a transaction spanning the order and the
22
+ * ledger: `pointsRepository.append` already runs its own transaction over the
23
+ * ledger and the balance mirror, and nesting would mean reading the order
24
+ * inside it for no added safety. The residual risk is a crash between the
25
+ * credit and the order write, which leaves a credited buyer and an order that
26
+ * looks unreconciled — recoverable, and strictly better than the alternative
27
+ * ordering, which would mark the order done and then fail to pay.
28
+ */
29
+ export async function reconcileOrderShipping(orderId, actual) {
30
+ const order = await orderRepository.findById(orderId);
31
+ if (!order) {
32
+ return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-quote" };
33
+ }
34
+ /*
35
+ * No quote means this order never went through the courier picker — a
36
+ * manually-shipped seller, or an order placed before the feature. There is
37
+ * no charged figure to measure against, so there is nothing to reconcile.
38
+ * Inventing one from the flat `shippingFee` would compare the buyer's spend
39
+ * against a number that was never a courier quote.
40
+ */
41
+ if (!order.selectedShippingQuote) {
42
+ return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-quote" };
43
+ }
44
+ if (order.shippingReconciliation) {
45
+ return {
46
+ outcome: null,
47
+ sellerAbsorbs: order.shippingReconciliation.sellerAbsorbs,
48
+ buyerPoints: order.shippingReconciliation.buyerPoints,
49
+ skipped: "already-reconciled",
50
+ };
51
+ }
52
+ const result = reconcileShipping(order.selectedShippingQuote, actual);
53
+ const buyerUid = order.userId;
54
+ if (result.buyerPoints > 0) {
55
+ if (!buyerUid) {
56
+ /*
57
+ * A credit with nobody to credit. Recorded rather than thrown: the
58
+ * parcel has shipped and the seller's dispatch must not fail because we
59
+ * cannot find the buyer — but this is a real data fault and has to be
60
+ * loud enough to find.
61
+ */
62
+ serverLogger.error("shipping reconciliation has points but no buyer", {
63
+ orderId,
64
+ buyerPoints: result.buyerPoints,
65
+ });
66
+ return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-buyer" };
67
+ }
68
+ await pointsRepository.append({
69
+ userId: buyerUid,
70
+ kind: "shipping_saving",
71
+ amount: result.buyerPoints,
72
+ orderId,
73
+ /*
74
+ * Names both couriers and both figures, because "53 points" on its own
75
+ * is unexplainable six weeks later — and this string is written once and
76
+ * never re-derived, so it stays true of the moment it describes even if
77
+ * the order is edited afterwards.
78
+ */
79
+ description: `Shipping saving on ${orderId}: charged ₹${order.selectedShippingQuote.chargedRate} ` +
80
+ `for ${order.selectedShippingQuote.courierName}, shipped ₹${actual.actualRate} ` +
81
+ `via ${actual.courierName}.`,
82
+ });
83
+ }
84
+ await orderRepository.update(orderId, {
85
+ actualShipmentCost: actual,
86
+ shippingReconciliation: { ...result, reconciledAt: new Date() },
87
+ });
88
+ return {
89
+ outcome: result.buyerPoints > 0 ? "credited" : result.sellerAbsorbs > 0 ? "seller-absorbed" : "exact",
90
+ sellerAbsorbs: result.sellerAbsorbs,
91
+ buyerPoints: result.buyerPoints,
92
+ };
93
+ }
@@ -89,6 +89,12 @@ export interface AppkitBrandConfig {
89
89
  name: string;
90
90
  /** Short / abbreviated name, e.g. "LT" */
91
91
  shortName?: string;
92
+ /**
93
+ * Standalone lockup line under the homepage hero logo, e.g. "Let It Rip
94
+ * India". Not `name`: that one is a sentence ingredient ("Why Buyers Trust
95
+ * {name}") and this one stands alone, so they are spelled differently.
96
+ */
97
+ heroCaption?: string;
92
98
  /** One-line brand description used in meta tags and footers */
93
99
  description?: string;
94
100
  /** Tagline shown in footer / about sections */
@@ -4,7 +4,7 @@ export declare const CHECKOUT_DEFAULT_COMMISSIONS: {
4
4
  readonly minimumTransactionFee: 0;
5
5
  readonly platformFeeMax: 10;
6
6
  readonly codDepositPercent: 10;
7
- readonly codHandlingFeeMin: 200;
7
+ readonly codHandlingFeeCap: 200;
8
8
  readonly codHandlingFeePercent: 10;
9
9
  readonly giftWrapFeeEnabled: true;
10
10
  readonly giftWrapFee: 49;
@@ -8,7 +8,7 @@ export const CHECKOUT_DEFAULT_COMMISSIONS = {
8
8
  minimumTransactionFee: 0,
9
9
  platformFeeMax: 10,
10
10
  codDepositPercent: 10,
11
- codHandlingFeeMin: 200,
11
+ codHandlingFeeCap: 200,
12
12
  codHandlingFeePercent: 10,
13
13
  // Mirrors the SiteSettingsDocument defaults, which enable every add-on.
14
14
  // Consequence of the mirror: if the settings read fails, an add-on already
@@ -35,7 +35,7 @@ export interface BuyerFacingFees {
35
35
  saleProtectionFeeEnabled?: boolean;
36
36
  saleProtectionFeePercent?: number;
37
37
  saleProtectionFeeMin?: number;
38
- codHandlingFeeMin?: number;
38
+ codHandlingFeeCap?: number;
39
39
  codHandlingFeePercent?: number;
40
40
  codDepositPercent?: number;
41
41
  }
@@ -21,7 +21,7 @@ export function toBuyerFacingFees(commissions) {
21
21
  saleProtectionFeeEnabled: commissions.saleProtectionFeeEnabled,
22
22
  saleProtectionFeePercent: commissions.saleProtectionFeePercent,
23
23
  saleProtectionFeeMin: commissions.saleProtectionFeeMin,
24
- codHandlingFeeMin: commissions.codHandlingFeeMin,
24
+ codHandlingFeeCap: commissions.codHandlingFeeCap,
25
25
  codHandlingFeePercent: commissions.codHandlingFeePercent,
26
26
  codDepositPercent: commissions.codDepositPercent,
27
27
  };
@@ -75,12 +75,44 @@ export interface PayoutDeduction {
75
75
  }
76
76
  export declare function computePayoutDeduction(grossAmount: number, commissions: FeeCommissionRates): PayoutDeduction;
77
77
  export interface CodHandlingFeeRates {
78
- /** Optional so existing `siteSettings` documents saved before this field existed don't produce NaN — falls back to ₹200. */
79
- codHandlingFeeMin?: number;
78
+ /**
79
+ * Rupee CEILING on the COD handling fee. Optional so a `siteSettings`
80
+ * document saved before this field existed does not produce NaN — falls
81
+ * back to ₹200.
82
+ */ codHandlingFeeCap?: number;
80
83
  /** Optional for the same reason — falls back to 10%. */
81
84
  codHandlingFeePercent?: number;
82
85
  }
83
- /** COD handling fee charged to the buyer: max(fixed floor, subtotal × percent). */
86
+ /**
87
+ * COD handling fee charged to the buyer: **min(subtotal × percent, cap)**.
88
+ *
89
+ * 🛑 The ₹200 is a CEILING, and it used to be applied as a FLOOR
90
+ * (`Math.max(min, percentFee)`) by a field called `codHandlingFeeMin`. The
91
+ * percentage then ran away on anything expensive: a ₹20,000 listing was
92
+ * charged **₹2,000** of "handling", which is not what handling a parcel costs
93
+ * — it is a tenth of the order. Measured on the live settings document
94
+ * (`codHandlingFeeMin: 200`, `codHandlingFeePercent: 10`) in the cart, and the
95
+ * figure was real: the buyer was quoted ₹22,011.80 on a ₹20,000 item.
96
+ *
97
+ * 🛑 What the fee IS, which is what makes the cap the correct shape: it is a
98
+ * TOKEN covering our cost of having shipped a parcel for free when the buyer
99
+ * does not pay on delivery. That cost is a courier's return leg — it scales
100
+ * with the parcel, not with the price of what is inside it. A ₹20,000 top and
101
+ * a ₹2,000 one cost the same to send back, so a fee that is 10× larger on the
102
+ * first is not recovering a cost, it is a surcharge on being expensive.
103
+ *
104
+ * The percentage survives because it is right at the small end — ₹150 of
105
+ * handling on a ₹300 order would exceed half the order — so the rule is "10%,
106
+ * but never more than ₹200". Below ₹2,000 the percentage binds; above it the
107
+ * cap does.
108
+ *
109
+ * The field was RENAMED rather than reinterpreted. A `…Min` read as a maximum
110
+ * is Root Cause #51's shape — a label describing the reverse of what the code
111
+ * does — and the next person to read the settings document would have had no
112
+ * way to tell. A live document carrying the old key now falls through to
113
+ * `DEFAULT_COD_HANDLING_FEE_CAP`, which is the same ₹200, so the rename
114
+ * changes no number on its own.
115
+ */
84
116
  export declare function computeCodHandlingFee(subtotal: number, rates: CodHandlingFeeRates): number;
85
117
  export interface GiftWrapFeeRates {
86
118
  /** Admin master toggle — the addon isn't charged (even if the buyer selected it) unless this is true. */
@@ -73,14 +73,44 @@ export function computePayoutDeduction(grossAmount, commissions) {
73
73
  const netAmount = Math.max(0, roundRupees(grossAmount - totalDeduction));
74
74
  return { platformFee, gatewayFee, gstOnFee, totalDeduction, netAmount };
75
75
  }
76
- const DEFAULT_COD_HANDLING_FEE_MIN = 200;
76
+ /** Buyer-facing COD handling ceiling, in decimal rupees. */
77
+ const DEFAULT_COD_HANDLING_FEE_CAP = 200;
77
78
  const DEFAULT_COD_HANDLING_FEE_PERCENT = 10;
78
- /** COD handling fee charged to the buyer: max(fixed floor, subtotal × percent). */
79
+ /**
80
+ * COD handling fee charged to the buyer: **min(subtotal × percent, cap)**.
81
+ *
82
+ * 🛑 The ₹200 is a CEILING, and it used to be applied as a FLOOR
83
+ * (`Math.max(min, percentFee)`) by a field called `codHandlingFeeMin`. The
84
+ * percentage then ran away on anything expensive: a ₹20,000 listing was
85
+ * charged **₹2,000** of "handling", which is not what handling a parcel costs
86
+ * — it is a tenth of the order. Measured on the live settings document
87
+ * (`codHandlingFeeMin: 200`, `codHandlingFeePercent: 10`) in the cart, and the
88
+ * figure was real: the buyer was quoted ₹22,011.80 on a ₹20,000 item.
89
+ *
90
+ * 🛑 What the fee IS, which is what makes the cap the correct shape: it is a
91
+ * TOKEN covering our cost of having shipped a parcel for free when the buyer
92
+ * does not pay on delivery. That cost is a courier's return leg — it scales
93
+ * with the parcel, not with the price of what is inside it. A ₹20,000 top and
94
+ * a ₹2,000 one cost the same to send back, so a fee that is 10× larger on the
95
+ * first is not recovering a cost, it is a surcharge on being expensive.
96
+ *
97
+ * The percentage survives because it is right at the small end — ₹150 of
98
+ * handling on a ₹300 order would exceed half the order — so the rule is "10%,
99
+ * but never more than ₹200". Below ₹2,000 the percentage binds; above it the
100
+ * cap does.
101
+ *
102
+ * The field was RENAMED rather than reinterpreted. A `…Min` read as a maximum
103
+ * is Root Cause #51's shape — a label describing the reverse of what the code
104
+ * does — and the next person to read the settings document would have had no
105
+ * way to tell. A live document carrying the old key now falls through to
106
+ * `DEFAULT_COD_HANDLING_FEE_CAP`, which is the same ₹200, so the rename
107
+ * changes no number on its own.
108
+ */
79
109
  export function computeCodHandlingFee(subtotal, rates) {
80
- const min = rates.codHandlingFeeMin ?? DEFAULT_COD_HANDLING_FEE_MIN;
110
+ const cap = rates.codHandlingFeeCap ?? DEFAULT_COD_HANDLING_FEE_CAP;
81
111
  const percent = rates.codHandlingFeePercent ?? DEFAULT_COD_HANDLING_FEE_PERCENT;
82
112
  const percentFee = roundRupees(subtotal * (percent / 100));
83
- return Math.max(min, percentFee);
113
+ return Math.min(percentFee, cap);
84
114
  }
85
115
  const DEFAULT_GIFT_WRAP_FEE = 49;
86
116
  /** Flat gift-wrap addon fee — charged only when the buyer opted in AND the admin has the addon enabled. */
@@ -0,0 +1,56 @@
1
+ /**
2
+ * "Does this href lead somewhere the site has switched off?"
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `listings.listingTypes` already hides a disabled type everywhere the type is
7
+ * named — the nav, the `/products` chips, the admin tabs, the homepage
8
+ * SECTIONS (`HOMEPAGE_SECTION_LISTING_TYPE` in `MarketplaceHomepageView`). But
9
+ * a lot of this site's merchandising is free-text content that names a
10
+ * destination by URL and never mentions a listing type at all: a carousel
11
+ * slide's overlay button, a card's CTA, a promo-grid tile.
12
+ *
13
+ * Measured 2026-10-20 with `auction: false` live: the homepage hero carousel
14
+ * still shipped a "Live auctions / Place a bid before the timer runs out"
15
+ * CARD linking to `/auctions`, and a whole `slide-beyblade-auction` slide
16
+ * whose overlay read "Win it at your price". Both are authored content, so no
17
+ * type-keyed gate could see them — the only thing that identifies them is
18
+ * where they point.
19
+ *
20
+ * ## The rule, and the one case that makes it non-obvious
21
+ *
22
+ * A href is disabled when EVERY listing type that claims its route is
23
+ * disabled. Not "any" — `art` and `stickers` share `/art`, so a site that
24
+ * sells art but not stickers must keep `/art` reachable. Treating one disabled
25
+ * claimant as enough would silently delete a working destination.
26
+ *
27
+ * ## What it deliberately does NOT match
28
+ *
29
+ * Editorial pages that merely have a type's name in the path — `/help/auctions`,
30
+ * `/how-auctions-work`, `/seller-guide/prize-draws`. None of them starts with
31
+ * `/auctions`, so prefix matching excludes them for free, and that is correct:
32
+ * an explainer about auctions is still honest reading when auctions are off.
33
+ * A substring test would have eaten all three.
34
+ *
35
+ * Store tabs (`/stores/{slug}/auctions`) are also out of scope — those are
36
+ * resolved by `resolveStoreTabs`, which owns its own visibility, and a second
37
+ * owner is how a mirror drifts (Root Cause #42).
38
+ */
39
+ import type { ListingType } from "../../../features/products/types/index";
40
+ interface ListingFlagSnapshot {
41
+ listings?: {
42
+ listingTypes?: Partial<Record<ListingType, boolean>>;
43
+ };
44
+ }
45
+ /**
46
+ * `true` when `href` points at a listing type's browse page (or any page
47
+ * beneath it) and every type that browses there is disabled.
48
+ *
49
+ * Fails OPEN on anything it cannot classify: an unknown path, an external URL
50
+ * or a missing settings document all return `false`. A settings read that
51
+ * errored must not strip working destinations out of the homepage — the same
52
+ * reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
53
+ * disabled-routes gate.
54
+ */
55
+ export declare function isDisabledListingHref(href: string | undefined | null, settings: ListingFlagSnapshot | null | undefined): boolean;
56
+ export {};