@mohasinac/appkit 4.23.0 → 4.25.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/_internal/server/features/bundles/actions.js +7 -0
- package/dist/_internal/server/features/checkout/actions.js +30 -21
- package/dist/_internal/server/features/checkout/bundle-expansion.d.ts +4 -6
- package/dist/_internal/server/features/checkout/bundle-expansion.js +19 -17
- package/dist/_internal/server/features/checkout/stock-restore.js +6 -5
- package/dist/_internal/server/features/products/group-members.d.ts +55 -0
- package/dist/_internal/server/features/products/group-members.js +41 -0
- package/dist/_internal/shared/actions/action-registry.js +41 -0
- package/dist/_internal/shared/checkout/line-members.d.ts +60 -0
- package/dist/_internal/shared/checkout/line-members.js +68 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
- package/dist/_internal/shared/checkout/order-math.js +29 -6
- package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
- package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
- package/dist/_internal/shared/checkout/rules/_registry.js +8 -2
- package/dist/_internal/shared/features/cart/config.d.ts +2 -1
- package/dist/_internal/shared/features/cart/config.js +2 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
- package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
- package/dist/_internal/shared/features/notifications/action-url.js +111 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/api-endpoints.d.ts +27 -0
- package/dist/constants/api-endpoints.js +9 -0
- package/dist/constants/notification-types.d.ts +38 -10
- package/dist/constants/notification-types.js +39 -28
- package/dist/features/account/types/index.d.ts +1 -10
- package/dist/features/admin/actions/notification-actions.d.ts +12 -0
- package/dist/features/admin/actions/notification-actions.js +149 -4
- package/dist/features/admin/schemas/firestore.d.ts +24 -29
- package/dist/features/admin/schemas/firestore.js +89 -26
- package/dist/features/cart/actions/cart-actions.d.ts +47 -1
- package/dist/features/cart/actions/cart-actions.js +189 -12
- package/dist/features/cart/components/CartDrawer.js +2 -1
- package/dist/features/cart/repository/cart.repository.d.ts +33 -1
- package/dist/features/cart/repository/cart.repository.js +146 -2
- package/dist/features/cart/schemas/firestore.d.ts +132 -8
- package/dist/features/cart/schemas/firestore.js +22 -0
- package/dist/features/email/index.d.ts +1 -1
- package/dist/features/email/index.js +5 -1
- package/dist/features/email/notification-templates.d.ts +35 -0
- package/dist/features/email/notification-templates.js +96 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/products/components/GroupMemberPicker.d.ts +46 -0
- package/dist/features/products/components/GroupMemberPicker.js +108 -0
- package/dist/features/products/components/ShowGroupSection.js +7 -9
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +30 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +7 -1
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/factories/notification.factory.d.ts +2 -1
- package/dist/seed/notifications-seed-data.js +225 -0
- package/dist/seed/payouts-seed-data.js +47 -0
- package/dist/seed/scammers-seed-data.js +34 -0
- package/dist/seed/stores-seed-data.js +34 -0
- package/dist/seed/support-tickets-seed-data.js +34 -0
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +5 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +10 -1
- package/dist/styles.css +58 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/QuantityStepper.d.ts +38 -0
- package/dist/ui/components/QuantityStepper.js +20 -0
- package/dist/ui/components/QuantityStepper.style.css +56 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { NotFoundError, ValidationError } from "../../../../errors";
|
|
16
16
|
import { categoriesRepository } from "../../../../repositories";
|
|
17
17
|
import { cartRepository } from "../../../../features/cart/repository/cart.repository";
|
|
18
|
+
import { assertCanAddNewItems } from "../../../../features/cart/actions/cart-actions";
|
|
18
19
|
import { getDefaultCurrency } from "../../../../core/baseline-resolver";
|
|
19
20
|
export async function addBundleToCartAction(userId, bundleSlug) {
|
|
20
21
|
if (!userId)
|
|
@@ -32,6 +33,12 @@ export async function addBundleToCartAction(userId, bundleSlug) {
|
|
|
32
33
|
if (!bundle.bundlePrice || bundle.bundlePrice < 1) {
|
|
33
34
|
throw new ValidationError("Bundle price is not configured");
|
|
34
35
|
}
|
|
36
|
+
// Bundles bypass `addItemToCart`'s capability gate deliberately (a bundle's
|
|
37
|
+
// `listingType: "standard"` is a lie told to satisfy it — `bundleRule` is
|
|
38
|
+
// `cartEligible: false`). The LANE gate is not optional though, and this path
|
|
39
|
+
// silently skipped it: a buyer with an unpaid auction win could keep shopping
|
|
40
|
+
// by adding bundles.
|
|
41
|
+
await assertCanAddNewItems(userId);
|
|
35
42
|
await cartRepository.addItem(userId, {
|
|
36
43
|
productId: bundle.id,
|
|
37
44
|
productTitle: bundle.name,
|
|
@@ -14,7 +14,7 @@ import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
|
14
14
|
import { serverLogger } from "../../../../monitoring";
|
|
15
15
|
import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
16
16
|
import { calculateGst } from "../../../shared/fees/calculator";
|
|
17
|
-
import { computePreOrderDepositAmount, unitPriceFor } from "../../../shared/checkout/order-math";
|
|
17
|
+
import { computePreOrderDepositAmount, lineTotalFor, unitPriceFor } from "../../../shared/checkout/order-math";
|
|
18
18
|
import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
|
|
19
19
|
import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
|
|
20
20
|
import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
|
|
@@ -26,7 +26,7 @@ import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../provide
|
|
|
26
26
|
import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore";
|
|
27
27
|
import { CART_COLLECTION } from "../../../../features/cart/schemas/index";
|
|
28
28
|
// S-SBUNI-RULES 2026-05-13 — bundle cart-line stock fan-out helpers.
|
|
29
|
-
import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, } from "./bundle-expansion";
|
|
29
|
+
import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, isMultiMemberLine, } from "./bundle-expansion";
|
|
30
30
|
import { OrderStatusValues, PaymentStatusValues, PaymentMethodValues, OutOfStockPolicyValues, } from "../../../../features/orders/schemas/index";
|
|
31
31
|
import { getDefaultCurrency } from "../../../../core/index";
|
|
32
32
|
import { verifyPaymentSignatureWithKeys, fetchRazorpayOrder, paiseToRupees, } from "../../../../providers/payment-razorpay/index";
|
|
@@ -374,7 +374,7 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
374
374
|
// Add-ons are this store's choice, not the cart's — see OrderGroupContext.
|
|
375
375
|
const { whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, } = storeAddons?.[firstItem.storeId] ?? {};
|
|
376
376
|
const { platformFee = 0, gstOnFee: platformFeeGst = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
|
|
377
|
-
const groupTotal = group.reduce((sum, { item, product }) => sum +
|
|
377
|
+
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
378
378
|
// S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
|
|
379
379
|
const orderItems = group.map(({ item, product }) => {
|
|
380
380
|
const lt = (product.listingType ?? "standard");
|
|
@@ -415,7 +415,7 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
415
415
|
let taxableAmount = 0;
|
|
416
416
|
let gstAmount = 0;
|
|
417
417
|
for (const { item, product } of group) {
|
|
418
|
-
const lineTotal =
|
|
418
|
+
const lineTotal = lineTotalFor(item, product);
|
|
419
419
|
const rate = product.gstRate ?? 0;
|
|
420
420
|
if (rate > 0) {
|
|
421
421
|
taxableAmount += lineTotal;
|
|
@@ -756,8 +756,9 @@ export async function createCheckoutOrderAction(input) {
|
|
|
756
756
|
const availableItems = [];
|
|
757
757
|
for (const { item, product } of bucketed.available) {
|
|
758
758
|
// S-SBUNI-RULES 2026-05-13 — sync preflight (prize-pool cap, pre-order
|
|
759
|
-
// quota) via rule registry.
|
|
760
|
-
|
|
759
|
+
// quota) via rule registry. Multi-member lines skip: their listingType
|
|
760
|
+
// describes the wrapper, not the members.
|
|
761
|
+
if (!isMultiMemberLine(item)) {
|
|
761
762
|
runSyncPreflight([{ item, product }], paymentMethod);
|
|
762
763
|
}
|
|
763
764
|
availableItems.push({ item, product });
|
|
@@ -774,8 +775,16 @@ export async function createCheckoutOrderAction(input) {
|
|
|
774
775
|
}
|
|
775
776
|
}
|
|
776
777
|
if (availableItems.length > 0) {
|
|
777
|
-
|
|
778
|
-
|
|
778
|
+
// Clear by LINE identity, not by productId. `itemId` is what uniquely
|
|
779
|
+
// identifies a line; `productId` is not unique across lines and is not
|
|
780
|
+
// even a product id on a bundle/group line (it points at the bundle
|
|
781
|
+
// category, or at the group parent — which is itself purchasable). So
|
|
782
|
+
// matching on it deleted every OTHER line that happened to share the
|
|
783
|
+
// value: a loose "Set" parent sitting beside a group line built from
|
|
784
|
+
// that same group, or two lines of one product, both vanished when only
|
|
785
|
+
// one was ordered.
|
|
786
|
+
const orderedItemIds = new Set(availableItems.map((a) => a.item.itemId));
|
|
787
|
+
const remainingCartItems = cart.items.filter((ci) => !orderedItemIds.has(ci.itemId));
|
|
779
788
|
const cartRef = db.collection(CART_COLLECTION).doc(uid);
|
|
780
789
|
tx.set(cartRef, {
|
|
781
790
|
items: remainingCartItems,
|
|
@@ -818,13 +827,13 @@ export async function createCheckoutOrderAction(input) {
|
|
|
818
827
|
const orderIds = [];
|
|
819
828
|
let total = 0;
|
|
820
829
|
const emailsToSend = [];
|
|
821
|
-
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs +
|
|
830
|
+
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
|
|
822
831
|
const couponUsageAccumulator = new Map();
|
|
823
832
|
// One commission on one transaction, capped, then apportioned across the
|
|
824
833
|
// orders this cart splits into — see allocateCheckoutFees.
|
|
825
834
|
const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
|
|
826
835
|
g[0].item.storeId,
|
|
827
|
-
g.reduce((sum, { item, product }) => sum +
|
|
836
|
+
g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
|
|
828
837
|
]), computeCheckoutFees(cartSubtotal, commissions));
|
|
829
838
|
const groupCtx = {
|
|
830
839
|
paymentMethod,
|
|
@@ -922,7 +931,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
922
931
|
}
|
|
923
932
|
const checks = cartItems.map((item) => ({ item, product: productById.get(item.productId) ?? null }));
|
|
924
933
|
const orderGroups = splitCartIntoOrderGroups(checks);
|
|
925
|
-
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs +
|
|
934
|
+
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
|
|
926
935
|
// Same re-validation the real order placement runs, so the preview never
|
|
927
936
|
// shows a discount the order won't actually honour.
|
|
928
937
|
const { coupons: appliedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], checks, uid);
|
|
@@ -932,7 +941,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
932
941
|
const stores = [];
|
|
933
942
|
for (const { items: group } of orderGroups) {
|
|
934
943
|
const firstItem = group[0].item;
|
|
935
|
-
const groupTotal = group.reduce((sum, { item, product }) => sum +
|
|
944
|
+
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
936
945
|
const { shippingFee: groupShippingFee, storeState } = await resolveShippingCost(firstItem.storeId);
|
|
937
946
|
// Add-ons are per store, read off the cart document — the same entry
|
|
938
947
|
// createOrderForGroup will read when the order is actually placed.
|
|
@@ -948,13 +957,13 @@ export async function previewCheckoutPricing(input) {
|
|
|
948
957
|
if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
|
|
949
958
|
const intraState = !!storeState && storeState === resolvedAddress.state;
|
|
950
959
|
for (const { item, product } of group) {
|
|
951
|
-
const lineTotal =
|
|
960
|
+
const lineTotal = lineTotalFor(item, product);
|
|
952
961
|
const rate = product?.gstRate ?? 0;
|
|
953
962
|
if (rate > 0)
|
|
954
963
|
groupGstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
|
|
955
964
|
}
|
|
956
965
|
}
|
|
957
|
-
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal:
|
|
966
|
+
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
|
|
958
967
|
const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
959
968
|
stores.push({
|
|
960
969
|
storeId: firstItem.storeId,
|
|
@@ -1131,12 +1140,12 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
|
|
|
1131
1140
|
async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
1132
1141
|
const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, } = ctx;
|
|
1133
1142
|
const firstItem = group[0].item;
|
|
1134
|
-
const groupTotal = group.reduce((sum, { item, product }) => sum +
|
|
1143
|
+
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
1135
1144
|
// Reuses the same store/seller lookup as the COD/UPI path above instead
|
|
1136
1145
|
// of re-implementing it inline — was two sequential findById calls per
|
|
1137
1146
|
// seller group here, duplicated from resolveShippingCost.
|
|
1138
1147
|
const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
|
|
1139
|
-
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal:
|
|
1148
|
+
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
|
|
1140
1149
|
const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
1141
1150
|
// Mirrors createOrderForGroup — without this the online-payment path wrote
|
|
1142
1151
|
// the discount onto the order but never incremented usage.currentUsage or
|
|
@@ -1398,8 +1407,8 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1398
1407
|
.map(({ item, product }) => ({ item, product })),
|
|
1399
1408
|
});
|
|
1400
1409
|
// S-SBUNI-RULES 2026-05-13 — sync preflight via rule registry.
|
|
1401
|
-
//
|
|
1402
|
-
const preflightPairs = productChecks.filter((p) => p.product !== null && p.product !== undefined && !p.item
|
|
1410
|
+
// Multi-member lines skip the prize-pool cap, same reasoning as the COD path.
|
|
1411
|
+
const preflightPairs = productChecks.filter((p) => p.product !== null && p.product !== undefined && !isMultiMemberLine(p.item));
|
|
1403
1412
|
// This whole action is the Razorpay path — paymentMethod is always "online".
|
|
1404
1413
|
runSyncPreflight(preflightPairs, "online");
|
|
1405
1414
|
// SB-UNI-O 2026-05-15 — Live-item jurisdiction guard.
|
|
@@ -1469,7 +1478,7 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1469
1478
|
const expectedAddonFees = expectedShippingGroups.reduce((sum, g) => {
|
|
1470
1479
|
const storeId = g.items[0].item.storeId;
|
|
1471
1480
|
const addons = cart.storeAddons?.[storeId] ?? {};
|
|
1472
|
-
const groupSubtotal = g.items.reduce((gs, { item, product }) => gs +
|
|
1481
|
+
const groupSubtotal = g.items.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0);
|
|
1473
1482
|
return (sum +
|
|
1474
1483
|
computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissionRates) +
|
|
1475
1484
|
computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
|
|
@@ -1501,12 +1510,12 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1501
1510
|
let total = 0;
|
|
1502
1511
|
const emailsToSend = [];
|
|
1503
1512
|
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s +
|
|
1504
|
-
g.reduce((gs, { item, product }) => gs +
|
|
1513
|
+
g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
|
|
1505
1514
|
const couponUsageAccumulator = new Map();
|
|
1506
1515
|
// Same single capped commission + pro-rata split as the COD/UPI path.
|
|
1507
1516
|
const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
|
|
1508
1517
|
g[0].item.storeId,
|
|
1509
|
-
g.reduce((sum, { item, product }) => sum +
|
|
1518
|
+
g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
|
|
1510
1519
|
]), computeCheckoutFees(cartSubtotal, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS));
|
|
1511
1520
|
for (const { items: group, orderType } of orderGroups) {
|
|
1512
1521
|
total += await createRazorpayGroupOrder(group, orderType, {
|
|
@@ -22,19 +22,17 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { CartItemDocument } from "../../../../features/cart/schemas";
|
|
24
24
|
import type { ProductDocument } from "../../../../features/products/schemas/firestore";
|
|
25
|
+
export { getCartLineMembers, getCartItemMemberIds, isMultiMemberLine, type CartLineMemberRef, } from "../../../shared/checkout/line-members";
|
|
25
26
|
export interface BundleExpansion {
|
|
26
27
|
/** Unique product ids referenced by any cart item (regular or bundle member). */
|
|
27
28
|
productIds: string[];
|
|
28
29
|
/**
|
|
29
|
-
* Sum of requested quantities per product id
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Sum of requested quantities per product id, weighted by BOTH the number of
|
|
31
|
+
* copies of the line and each member's per-copy quantity — so 2 copies of a
|
|
32
|
+
* selection containing 3× member X decrement X by 6.
|
|
32
33
|
*/
|
|
33
34
|
decrements: Map<string, number>;
|
|
34
35
|
}
|
|
35
|
-
/** Resolve the member-product ids for a cart item. Bundle items expand to
|
|
36
|
-
* `bundleProductIds[]`; regular items collapse to `[productId]`. */
|
|
37
|
-
export declare function getCartItemMemberIds(item: CartItemDocument): string[];
|
|
38
36
|
/** Build the unique-product-id + per-product decrement map for a cart. */
|
|
39
37
|
export declare function getExpandedDecrements(cartItems: CartItemDocument[]): BundleExpansion;
|
|
40
38
|
export interface StockShortfall {
|
|
@@ -21,21 +21,21 @@
|
|
|
21
21
|
* the first's decrement before re-running its own validation.
|
|
22
22
|
*/
|
|
23
23
|
import { ProductStatusValues } from "../../../../features/products/schemas/firestore";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
return [item.productId];
|
|
31
|
-
}
|
|
24
|
+
import { getCartLineMembers } from "../../../shared/checkout/line-members";
|
|
25
|
+
// Re-exported here so the many existing importers of this module keep working;
|
|
26
|
+
// the definitions live in shared/ because `rules/_registry.ts` and
|
|
27
|
+
// `order-math.ts` need them too and cannot import from server/.
|
|
28
|
+
export { getCartLineMembers, getCartItemMemberIds, isMultiMemberLine, } from "../../../shared/checkout/line-members";
|
|
32
29
|
/** Build the unique-product-id + per-product decrement map for a cart. */
|
|
33
30
|
export function getExpandedDecrements(cartItems) {
|
|
34
31
|
const decrements = new Map();
|
|
35
32
|
for (const item of cartItems) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
for (const member of getCartLineMembers(item)) {
|
|
34
|
+
// item.quantity = copies of the line, member.quantity = units per copy.
|
|
35
|
+
// Multiplying is the whole point: this used to add `item.quantity` alone,
|
|
36
|
+
// which silently assumed exactly one of each member.
|
|
37
|
+
const needed = item.quantity * member.quantity;
|
|
38
|
+
decrements.set(member.productId, (decrements.get(member.productId) ?? 0) + needed);
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
return {
|
|
@@ -55,15 +55,17 @@ export function getExpandedDecrements(cartItems) {
|
|
|
55
55
|
* that product, not just this item's slice.
|
|
56
56
|
*/
|
|
57
57
|
export function validateCartItemStock(item, productById, decrements) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
const product = productById.get(pid);
|
|
58
|
+
for (const member of getCartLineMembers(item)) {
|
|
59
|
+
const product = productById.get(member.productId);
|
|
61
60
|
if (!product || product.status !== ProductStatusValues.PUBLISHED) {
|
|
62
|
-
return { productId:
|
|
61
|
+
return { productId: member.productId, availableQty: product?.availableQuantity ?? 0 };
|
|
63
62
|
}
|
|
64
|
-
|
|
63
|
+
// The fallback must be member-weighted too. Left as a bare `item.quantity`
|
|
64
|
+
// it would validate a line needing 3× of a member against demand for 1
|
|
65
|
+
// whenever the cumulative map didn't happen to carry that product.
|
|
66
|
+
const totalNeeded = decrements.get(member.productId) ?? item.quantity * member.quantity;
|
|
65
67
|
if (product.availableQuantity < totalNeeded) {
|
|
66
|
-
return { productId:
|
|
68
|
+
return { productId: member.productId, availableQty: product.availableQuantity };
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
return null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ORDER_COLLECTION } from "../../../../features/orders/schemas/firestore";
|
|
2
2
|
import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
|
|
3
3
|
import { getListingRule } from "../../../shared/checkout/rules";
|
|
4
|
+
import { getOrderItemMemberRefs } from "../../../shared/checkout/line-members";
|
|
4
5
|
/**
|
|
5
6
|
* Per-product quantity to restore for one order — the inverse of
|
|
6
7
|
* `getExpandedDecrements` (bundle-expansion.ts), but reads from
|
|
@@ -15,11 +16,11 @@ function getOrderRestoreMap(order) {
|
|
|
15
16
|
const remainingQty = item.quantity - (item.cancelledQuantity ?? 0);
|
|
16
17
|
if (remainingQty <= 0)
|
|
17
18
|
continue;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
map.set(pid, (map.get(pid) ?? 0) + remainingQty);
|
|
19
|
+
// Must mirror `getExpandedDecrements` exactly, including the per-member
|
|
20
|
+
// weighting — restoring fewer units than were taken silently leaks stock.
|
|
21
|
+
for (const member of getOrderItemMemberRefs(item)) {
|
|
22
|
+
const pid = member.productId;
|
|
23
|
+
map.set(pid, (map.get(pid) ?? 0) + remainingQty * member.quantity);
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
return map;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public projection for a product-group member.
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/products/group/[groupId]` previously returned
|
|
5
|
+
* `sanitizeProductsForPublic(items)`, which is a DELETE-LIST of four
|
|
6
|
+
* seller-identity keys — every other field of `ProductDocument` went to the
|
|
7
|
+
* browser, including fields nobody had thought about. Per § "Public Data
|
|
8
|
+
* Projections", a public payload is built by naming what goes out, never by
|
|
9
|
+
* spreading a document and removing keys: a denial keyed on today's field list
|
|
10
|
+
* is structurally blind to the field added tomorrow.
|
|
11
|
+
*
|
|
12
|
+
* The member picker needs more than the old response carried (stock, store,
|
|
13
|
+
* sold state, gstRate), so widening the payload was required either way —
|
|
14
|
+
* which made this the moment to make it an allow-list rather than a wider
|
|
15
|
+
* deny-list.
|
|
16
|
+
*/
|
|
17
|
+
import type { ProductDocument } from "../../../../features/products/schemas/firestore";
|
|
18
|
+
import type { ListingType } from "../../../../features/products/types/index";
|
|
19
|
+
/**
|
|
20
|
+
* Every field the group picker and the group table render. If a field is not
|
|
21
|
+
* here it does not reach the browser.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately absent, with reasons:
|
|
24
|
+
* - `sellerId` / `ownerId` / `sellerName` / `sellerEmail` — seller identity;
|
|
25
|
+
* public clients see store-scoped identity only.
|
|
26
|
+
* - `costPrice`, `profitMargin`, `supplier*`, `adminNotes` — seller-private
|
|
27
|
+
* commercial data.
|
|
28
|
+
* - `stockQuantity` — the internal figure; `availableQuantity` is the one the
|
|
29
|
+
* buyer may act on.
|
|
30
|
+
*/
|
|
31
|
+
export interface PublicGroupMember {
|
|
32
|
+
id: string;
|
|
33
|
+
slug?: string;
|
|
34
|
+
title: string;
|
|
35
|
+
price: number;
|
|
36
|
+
currency?: string;
|
|
37
|
+
/** First image only — the picker renders a thumbnail, never a gallery. */
|
|
38
|
+
image?: string;
|
|
39
|
+
condition?: string;
|
|
40
|
+
listingType: ListingType;
|
|
41
|
+
isGroupParent?: boolean;
|
|
42
|
+
groupTitle?: string;
|
|
43
|
+
/** Drives the stepper's `max`. */
|
|
44
|
+
availableQuantity: number;
|
|
45
|
+
storeId: string;
|
|
46
|
+
storeName?: string;
|
|
47
|
+
status: string;
|
|
48
|
+
isSold?: boolean;
|
|
49
|
+
/** Needed so the picker can show a tax-inclusive hint and the cart line can
|
|
50
|
+
* snapshot it for per-member GST proration at checkout. */
|
|
51
|
+
gstRate?: 0 | 5 | 12 | 18 | 28;
|
|
52
|
+
hsnCode?: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function toPublicGroupMember(doc: ProductDocument): PublicGroupMember;
|
|
55
|
+
export declare function toPublicGroupMembers(docs: readonly ProductDocument[]): PublicGroupMember[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public projection for a product-group member.
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/products/group/[groupId]` previously returned
|
|
5
|
+
* `sanitizeProductsForPublic(items)`, which is a DELETE-LIST of four
|
|
6
|
+
* seller-identity keys — every other field of `ProductDocument` went to the
|
|
7
|
+
* browser, including fields nobody had thought about. Per § "Public Data
|
|
8
|
+
* Projections", a public payload is built by naming what goes out, never by
|
|
9
|
+
* spreading a document and removing keys: a denial keyed on today's field list
|
|
10
|
+
* is structurally blind to the field added tomorrow.
|
|
11
|
+
*
|
|
12
|
+
* The member picker needs more than the old response carried (stock, store,
|
|
13
|
+
* sold state, gstRate), so widening the payload was required either way —
|
|
14
|
+
* which made this the moment to make it an allow-list rather than a wider
|
|
15
|
+
* deny-list.
|
|
16
|
+
*/
|
|
17
|
+
import { normalizeListingType } from "../../../../features/products/utils/listing-type";
|
|
18
|
+
export function toPublicGroupMember(doc) {
|
|
19
|
+
return {
|
|
20
|
+
id: doc.id,
|
|
21
|
+
slug: doc.slug,
|
|
22
|
+
title: doc.title,
|
|
23
|
+
price: doc.price,
|
|
24
|
+
currency: doc.currency,
|
|
25
|
+
image: doc.images?.[0] ?? doc.mainImage,
|
|
26
|
+
condition: doc.condition,
|
|
27
|
+
listingType: normalizeListingType(doc),
|
|
28
|
+
isGroupParent: doc.isGroupParent,
|
|
29
|
+
groupTitle: doc.groupTitle,
|
|
30
|
+
availableQuantity: doc.availableQuantity ?? 0,
|
|
31
|
+
storeId: doc.storeId,
|
|
32
|
+
storeName: doc.storeName,
|
|
33
|
+
status: doc.status,
|
|
34
|
+
isSold: doc.isSold,
|
|
35
|
+
gstRate: doc.gstRate,
|
|
36
|
+
hsnCode: doc.hsnCode,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function toPublicGroupMembers(docs) {
|
|
40
|
+
return docs.map(toPublicGroupMember);
|
|
41
|
+
}
|
|
@@ -2412,6 +2412,47 @@ export const ACTIONS = {
|
|
|
2412
2412
|
description: "Navigate back from the cart to the product catalogue.",
|
|
2413
2413
|
kind: "ghost",
|
|
2414
2414
|
},
|
|
2415
|
+
"add-group-to-cart": {
|
|
2416
|
+
iconKey: "cart",
|
|
2417
|
+
id: "cart.add-group-to-cart",
|
|
2418
|
+
label: "Add selected to cart",
|
|
2419
|
+
ariaLabel: "Add the selected group items to the cart as one line",
|
|
2420
|
+
description: "Add a buyer-assembled selection of group members to the cart as a SINGLE line, rather than one line per product.",
|
|
2421
|
+
kind: "primary",
|
|
2422
|
+
},
|
|
2423
|
+
"add-bundle-to-cart": {
|
|
2424
|
+
iconKey: "cart",
|
|
2425
|
+
id: "cart.add-bundle-to-cart",
|
|
2426
|
+
label: "Add to cart",
|
|
2427
|
+
ariaLabel: "Add this bundle to the cart",
|
|
2428
|
+
description: "Add a whole bundle to the cart. Bundles are all-or-nothing — the quantity is the number of COPIES of the bundle.",
|
|
2429
|
+
kind: "primary",
|
|
2430
|
+
},
|
|
2431
|
+
"remove-group-member": {
|
|
2432
|
+
iconKey: "delete",
|
|
2433
|
+
id: "cart.remove-group-member",
|
|
2434
|
+
label: "Remove",
|
|
2435
|
+
ariaLabel: "Remove this item from the group line",
|
|
2436
|
+
description: "Drop one member out of a grouped cart line. Ghost rather than danger on purpose: it reuses the cart's existing undo affordance, and Rule #7.4's mandatory-confirmation requirement applies to destructive actions.",
|
|
2437
|
+
kind: "ghost",
|
|
2438
|
+
},
|
|
2439
|
+
// No iconKey — these two exist only to keep the stepper's accessible names
|
|
2440
|
+
// in the registry rather than hard-coded in the primitive. The glyphs are
|
|
2441
|
+
// the literal "+" / "−" characters.
|
|
2442
|
+
"increase-quantity": {
|
|
2443
|
+
id: "cart.increase-quantity",
|
|
2444
|
+
label: "+",
|
|
2445
|
+
ariaLabel: "Increase quantity",
|
|
2446
|
+
description: "Default accessible name for QuantityStepper's increment control.",
|
|
2447
|
+
kind: "ghost",
|
|
2448
|
+
},
|
|
2449
|
+
"decrease-quantity": {
|
|
2450
|
+
id: "cart.decrease-quantity",
|
|
2451
|
+
label: "−",
|
|
2452
|
+
ariaLabel: "Decrease quantity",
|
|
2453
|
+
description: "Default accessible name for QuantityStepper's decrement control.",
|
|
2454
|
+
kind: "ghost",
|
|
2455
|
+
},
|
|
2415
2456
|
},
|
|
2416
2457
|
CHECKOUT: {
|
|
2417
2458
|
"place-order": {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "What products does this cart line actually consume?" — the one answer.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `shared/` rather than `server/features/checkout/` because three
|
|
5
|
+
* different layers need it and two of them are not server code:
|
|
6
|
+
* - `order-math.ts` (pricing + tax, shared)
|
|
7
|
+
* - `rules/_registry.ts` (preflight skip, shared)
|
|
8
|
+
* - `bundle-expansion.ts` (stock fan-out, server)
|
|
9
|
+
* - `stock-restore.ts` (restore fan-out, server)
|
|
10
|
+
*
|
|
11
|
+
* Pure functions, no Firestore, no framework imports.
|
|
12
|
+
*/
|
|
13
|
+
import type { CartItemDocument } from "../../../features/cart/schemas/firestore";
|
|
14
|
+
/** One member of a cart line, with the units required per COPY of that line. */
|
|
15
|
+
export interface CartLineMemberRef {
|
|
16
|
+
productId: string;
|
|
17
|
+
quantity: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a line's members. Three sources, in priority order — never read
|
|
21
|
+
* `bundleProductIds` directly at a call site:
|
|
22
|
+
*
|
|
23
|
+
* 1. `groupMembers` — authoritative, and the only shape that can express
|
|
24
|
+
* per-member quantities.
|
|
25
|
+
* 2. `bundleProductIds` — legacy bundle lines written before `groupMembers`
|
|
26
|
+
* existed, resolved at quantity 1 each. This fallback is what makes carts
|
|
27
|
+
* that were already in flight behave byte-identically, with no migration.
|
|
28
|
+
* 3. the line's own `productId` — an ordinary single-product line.
|
|
29
|
+
*
|
|
30
|
+
* Keeping this a derivation rather than storing a second array is deliberate: a
|
|
31
|
+
* hand-maintained mirror drifts the first time one write path forgets it
|
|
32
|
+
* (Recurrent Root Cause #42).
|
|
33
|
+
*/
|
|
34
|
+
export declare function getCartLineMembers(item: CartItemDocument): CartLineMemberRef[];
|
|
35
|
+
/**
|
|
36
|
+
* True when the line stands for several products rather than one.
|
|
37
|
+
*
|
|
38
|
+
* Replaces the scattered `item.bundleProductIds?.length` tests, which each
|
|
39
|
+
* meant "is this a bundle" and would all have silently answered "no" for a
|
|
40
|
+
* `groupMembers`-backed line.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isMultiMemberLine(item: CartItemDocument): boolean;
|
|
43
|
+
/** Just the product ids — see `getCartLineMembers` for the resolution order. */
|
|
44
|
+
export declare function getCartItemMemberIds(item: CartItemDocument): string[];
|
|
45
|
+
/** The order-side shape this module needs. Structural on purpose — it matches
|
|
46
|
+
* `OrderDocumentItem` without dragging the orders schema into shared checkout. */
|
|
47
|
+
export interface OrderItemMemberSource {
|
|
48
|
+
productId: string;
|
|
49
|
+
bundleProductIds?: string[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The order-side twin of `getCartLineMembers`, used by stock restore.
|
|
53
|
+
*
|
|
54
|
+
* A LEGACY order row is collapsed: one row standing for a whole bundle, with
|
|
55
|
+
* its members listed in `bundleProductIds` at one unit each. Rows written after
|
|
56
|
+
* grouped lines were expanded carry no `bundleProductIds` at all — each row is
|
|
57
|
+
* already exactly one product — so they fall through to the single-member
|
|
58
|
+
* branch and need no special case.
|
|
59
|
+
*/
|
|
60
|
+
export declare function getOrderItemMemberRefs(item: OrderItemMemberSource): CartLineMemberRef[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "What products does this cart line actually consume?" — the one answer.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `shared/` rather than `server/features/checkout/` because three
|
|
5
|
+
* different layers need it and two of them are not server code:
|
|
6
|
+
* - `order-math.ts` (pricing + tax, shared)
|
|
7
|
+
* - `rules/_registry.ts` (preflight skip, shared)
|
|
8
|
+
* - `bundle-expansion.ts` (stock fan-out, server)
|
|
9
|
+
* - `stock-restore.ts` (restore fan-out, server)
|
|
10
|
+
*
|
|
11
|
+
* Pure functions, no Firestore, no framework imports.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a line's members. Three sources, in priority order — never read
|
|
15
|
+
* `bundleProductIds` directly at a call site:
|
|
16
|
+
*
|
|
17
|
+
* 1. `groupMembers` — authoritative, and the only shape that can express
|
|
18
|
+
* per-member quantities.
|
|
19
|
+
* 2. `bundleProductIds` — legacy bundle lines written before `groupMembers`
|
|
20
|
+
* existed, resolved at quantity 1 each. This fallback is what makes carts
|
|
21
|
+
* that were already in flight behave byte-identically, with no migration.
|
|
22
|
+
* 3. the line's own `productId` — an ordinary single-product line.
|
|
23
|
+
*
|
|
24
|
+
* Keeping this a derivation rather than storing a second array is deliberate: a
|
|
25
|
+
* hand-maintained mirror drifts the first time one write path forgets it
|
|
26
|
+
* (Recurrent Root Cause #42).
|
|
27
|
+
*/
|
|
28
|
+
export function getCartLineMembers(item) {
|
|
29
|
+
if (item.groupMembers?.length) {
|
|
30
|
+
return item.groupMembers.map((m) => ({
|
|
31
|
+
productId: m.productId,
|
|
32
|
+
quantity: m.quantity,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
if (item.bundleProductIds?.length) {
|
|
36
|
+
return item.bundleProductIds.map((productId) => ({ productId, quantity: 1 }));
|
|
37
|
+
}
|
|
38
|
+
return [{ productId: item.productId, quantity: 1 }];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* True when the line stands for several products rather than one.
|
|
42
|
+
*
|
|
43
|
+
* Replaces the scattered `item.bundleProductIds?.length` tests, which each
|
|
44
|
+
* meant "is this a bundle" and would all have silently answered "no" for a
|
|
45
|
+
* `groupMembers`-backed line.
|
|
46
|
+
*/
|
|
47
|
+
export function isMultiMemberLine(item) {
|
|
48
|
+
return Boolean(item.groupMembers?.length || item.bundleProductIds?.length);
|
|
49
|
+
}
|
|
50
|
+
/** Just the product ids — see `getCartLineMembers` for the resolution order. */
|
|
51
|
+
export function getCartItemMemberIds(item) {
|
|
52
|
+
return getCartLineMembers(item).map((m) => m.productId);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The order-side twin of `getCartLineMembers`, used by stock restore.
|
|
56
|
+
*
|
|
57
|
+
* A LEGACY order row is collapsed: one row standing for a whole bundle, with
|
|
58
|
+
* its members listed in `bundleProductIds` at one unit each. Rows written after
|
|
59
|
+
* grouped lines were expanded carry no `bundleProductIds` at all — each row is
|
|
60
|
+
* already exactly one product — so they fall through to the single-member
|
|
61
|
+
* branch and need no special case.
|
|
62
|
+
*/
|
|
63
|
+
export function getOrderItemMemberRefs(item) {
|
|
64
|
+
if (item.bundleProductIds?.length) {
|
|
65
|
+
return item.bundleProductIds.map((productId) => ({ productId, quantity: 1 }));
|
|
66
|
+
}
|
|
67
|
+
return [{ productId: item.productId, quantity: 1 }];
|
|
68
|
+
}
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
import type { CartItemDocument } from "../../../features/cart/schemas/firestore";
|
|
8
8
|
import type { ProductDocument } from "../../../features/products/schemas/firestore";
|
|
9
9
|
/**
|
|
10
|
-
* The single source of truth for "what do we charge for this line".
|
|
10
|
+
* The single source of truth for "what do we charge for ONE COPY of this line".
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Four cases, in priority order:
|
|
13
13
|
*
|
|
14
14
|
* 1. **A locked price** — an accepted offer or a won auction. `lockedPrice` is
|
|
15
15
|
* the negotiated/won amount and it OVERRIDES the current listing price, which
|
|
@@ -17,15 +17,33 @@ import type { ProductDocument } from "../../../features/products/schemas/firesto
|
|
|
17
17
|
* copies of this function looked at `lockedPrice`, so an accepted offer was
|
|
18
18
|
* billed at the seller's listed price while the cart UI displayed the agreed
|
|
19
19
|
* one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
|
|
20
|
-
* 2. **A
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* 2. **A multi-member line** — the sum of its members' own snapshotted prices,
|
|
21
|
+
* weighted by each member's per-copy quantity. A `"group"` line pins
|
|
22
|
+
* `item.quantity` to 1, so this is both the unit price and the line total;
|
|
23
|
+
* see the invariant on `CartLineMember`.
|
|
24
|
+
* 3. **A legacy bundle line** (SB-UNI-5 2026-05-13) — written before
|
|
25
|
+
* `groupMembers` existed. `item.price` is the bundle price locked at
|
|
26
|
+
* add-time.
|
|
27
|
+
* 4. **Everything else** — the live Firestore price, so a stale cart-cached
|
|
23
28
|
* price is never charged on a COD/UPI order.
|
|
24
29
|
*
|
|
25
30
|
* `product` may be null for a locked line whose listing has since been archived;
|
|
26
31
|
* the locked price is still owed, so that case must not dereference `product`.
|
|
32
|
+
*
|
|
33
|
+
* 🛑 Do not hand-roll this rule at a call site. It has been re-derived by hand
|
|
34
|
+
* six times, and the two copies that lived in the consumer repo
|
|
35
|
+
* (`src/app/api/payment/create-order/route.ts`) each reproduced the bundle
|
|
36
|
+
* branch while silently omitting case 1 — so Razorpay captured the LIST price
|
|
37
|
+
* for an accepted offer while the cart displayed the negotiated one. Call
|
|
38
|
+
* `lineTotalFor` and let this function stay the only definition.
|
|
27
39
|
*/
|
|
28
40
|
export declare function unitPriceFor(item: CartItemDocument, product: ProductDocument | null): number;
|
|
41
|
+
/**
|
|
42
|
+
* What this line contributes to a subtotal: one copy's price × the number of
|
|
43
|
+
* copies. THE call site should use this rather than multiplying `unitPriceFor`
|
|
44
|
+
* itself — that multiplication is where the six hand-rolled copies diverged.
|
|
45
|
+
*/
|
|
46
|
+
export declare function lineTotalFor(item: CartItemDocument, product: ProductDocument | null): number;
|
|
29
47
|
/**
|
|
30
48
|
* P-6 — pre-order groups charge each product's own `preOrderDepositPercent`
|
|
31
49
|
* (falling back to the generic COD deposit % when a product doesn't have one
|