@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.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
- package/dist/_internal/server/features/checkout/actions.js +100 -5
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
- package/dist/_internal/shared/features/checkout/config.js +1 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/fees.js +1 -1
- package/dist/_internal/shared/fees/calculator.d.ts +35 -3
- package/dist/_internal/shared/fees/calculator.js +34 -4
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +124 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +8 -0
- package/dist/errors/error-codes.d.ts +15 -0
- package/dist/errors/error-codes.js +15 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/features/account/components/UserBidsView.js +4 -2
- package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
- package/dist/features/admin/schemas/firestore.d.ts +37 -2
- package/dist/features/admin/schemas/firestore.js +12 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/checkout/schemas/firestore.d.ts +12 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/homepage/components/HeroCarousel.js +4 -2
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +22 -5
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/layout/NavbarLayout.js +139 -32
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/media/messages/en.json +67 -67
- package/dist/features/orders/schemas/firestore.d.ts +39 -1
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +102 -0
- package/dist/features/points/schemas/firestore.js +99 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +5 -26
- package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
- package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
- package/dist/features/products/components/ProductsIndexListing.js +35 -5
- package/dist/features/products/components/ProductsIndexPageView.js +40 -7
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
- package/dist/providers/shipping-shiprocket/index.js +34 -10
- package/dist/security/csp.d.ts +1 -16
- package/dist/security/csp.js +3 -5
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/seed/faq-seed-data.js +1 -1
- package/dist/seed/site-settings-seed-data.js +1 -1
- package/dist/seo/json-ld.d.ts +8 -1
- package/dist/seo/json-ld.js +39 -2
- package/dist/styles.css +419 -542
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/index.d.ts +1 -3
- package/dist/tokens/themes/index.js +1 -11
- package/dist/tokens/tokens.css +0 -155
- package/dist/ui/components/GatedPrice.style.css +43 -43
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/dist/ui/components/QuickCreateModal.style.css +78 -78
- package/dist/ui/forms/FormShell.style.css +260 -260
- package/package.json +1 -1
- package/dist/tokens/themes/technical-dark.d.ts +0 -30
- package/dist/tokens/themes/technical-dark.js +0 -103
- package/dist/tokens/themes/technical-light.d.ts +0 -22
- package/dist/tokens/themes/technical-light.js +0 -89
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
5
|
-
"branch": "
|
|
3
|
+
"version": "4.56.0",
|
|
4
|
+
"head": "a2d85e72b5cac1f1a55ae7386f661b293eccd704",
|
|
5
|
+
"branch": "main",
|
|
6
6
|
"dirty": true,
|
|
7
|
-
"builtAt": "2026-10-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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 {};
|