@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.
Files changed (69) hide show
  1. package/dist/_internal/server/features/bundles/actions.js +7 -0
  2. package/dist/_internal/server/features/checkout/actions.js +30 -21
  3. package/dist/_internal/server/features/checkout/bundle-expansion.d.ts +4 -6
  4. package/dist/_internal/server/features/checkout/bundle-expansion.js +19 -17
  5. package/dist/_internal/server/features/checkout/stock-restore.js +6 -5
  6. package/dist/_internal/server/features/products/group-members.d.ts +55 -0
  7. package/dist/_internal/server/features/products/group-members.js +41 -0
  8. package/dist/_internal/shared/actions/action-registry.js +41 -0
  9. package/dist/_internal/shared/checkout/line-members.d.ts +60 -0
  10. package/dist/_internal/shared/checkout/line-members.js +68 -0
  11. package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
  12. package/dist/_internal/shared/checkout/order-math.js +29 -6
  13. package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
  14. package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
  15. package/dist/_internal/shared/checkout/rules/_registry.js +8 -2
  16. package/dist/_internal/shared/features/cart/config.d.ts +2 -1
  17. package/dist/_internal/shared/features/cart/config.js +2 -1
  18. package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
  19. package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
  20. package/dist/_internal/shared/features/notifications/action-url.js +111 -0
  21. package/dist/client.d.ts +2 -0
  22. package/dist/client.js +1 -0
  23. package/dist/constants/api-endpoints.d.ts +27 -0
  24. package/dist/constants/api-endpoints.js +9 -0
  25. package/dist/constants/notification-types.d.ts +38 -10
  26. package/dist/constants/notification-types.js +39 -28
  27. package/dist/features/account/types/index.d.ts +1 -10
  28. package/dist/features/admin/actions/notification-actions.d.ts +12 -0
  29. package/dist/features/admin/actions/notification-actions.js +149 -4
  30. package/dist/features/admin/schemas/firestore.d.ts +24 -29
  31. package/dist/features/admin/schemas/firestore.js +89 -26
  32. package/dist/features/cart/actions/cart-actions.d.ts +47 -1
  33. package/dist/features/cart/actions/cart-actions.js +189 -12
  34. package/dist/features/cart/components/CartDrawer.js +2 -1
  35. package/dist/features/cart/repository/cart.repository.d.ts +33 -1
  36. package/dist/features/cart/repository/cart.repository.js +146 -2
  37. package/dist/features/cart/schemas/firestore.d.ts +132 -8
  38. package/dist/features/cart/schemas/firestore.js +22 -0
  39. package/dist/features/email/index.d.ts +1 -1
  40. package/dist/features/email/index.js +5 -1
  41. package/dist/features/email/notification-templates.d.ts +35 -0
  42. package/dist/features/email/notification-templates.js +96 -0
  43. package/dist/features/orders/schemas/index.d.ts +2 -2
  44. package/dist/features/products/components/GroupMemberPicker.d.ts +46 -0
  45. package/dist/features/products/components/GroupMemberPicker.js +108 -0
  46. package/dist/features/products/components/ShowGroupSection.js +7 -9
  47. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +30 -0
  48. package/dist/index.d.ts +4 -3
  49. package/dist/index.js +7 -1
  50. package/dist/schemas/registry.d.ts +2 -2
  51. package/dist/seed/factories/notification.factory.d.ts +2 -1
  52. package/dist/seed/notifications-seed-data.js +225 -0
  53. package/dist/seed/payouts-seed-data.js +47 -0
  54. package/dist/seed/scammers-seed-data.js +34 -0
  55. package/dist/seed/stores-seed-data.js +34 -0
  56. package/dist/seed/support-tickets-seed-data.js +34 -0
  57. package/dist/server-entry.d.ts +1 -0
  58. package/dist/server-entry.js +5 -0
  59. package/dist/server.d.ts +5 -1
  60. package/dist/server.js +10 -1
  61. package/dist/styles.css +58 -1
  62. package/dist/tailwind-utilities.css +1 -1
  63. package/dist/ui/components/QuantityStepper.d.ts +38 -0
  64. package/dist/ui/components/QuantityStepper.js +20 -0
  65. package/dist/ui/components/QuantityStepper.style.css +56 -0
  66. package/dist/ui/components/index.style.css +1 -0
  67. package/dist/ui/index.d.ts +2 -0
  68. package/dist/ui/index.js +1 -0
  69. 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 + unitPriceFor(item, product) * item.quantity, 0);
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 = unitPriceFor(item, product) * item.quantity;
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. Bundles skip — they bypass the cart path.
760
- if (!item.bundleProductIds?.length) {
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
- const orderedProductIds = new Set(availableItems.map((a) => a.item.productId));
778
- const remainingCartItems = cart.items.filter((ci) => !orderedProductIds.has(ci.productId));
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 + unitPriceFor(item, product) * item.quantity, 0), 0);
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 + unitPriceFor(item, product) * item.quantity, 0),
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 + unitPriceFor(item, product) * item.quantity, 0), 0);
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 + unitPriceFor(item, product) * item.quantity, 0);
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 = unitPriceFor(item, product) * item.quantity;
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: unitPriceFor(item, product) * item.quantity }));
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 + unitPriceFor(item, product) * item.quantity, 0);
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: unitPriceFor(item, product) * item.quantity }));
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
- // Bundle items bypass the cart flow so skip the prize-pool cap.
1402
- const preflightPairs = productChecks.filter((p) => p.product !== null && p.product !== undefined && !p.item.bundleProductIds?.length);
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 + unitPriceFor(item, product) * item.quantity, 0);
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 + unitPriceFor(item, product) * item.quantity, 0), 0);
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 + unitPriceFor(item, product) * item.quantity, 0),
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. Bundle members get
30
- * `item.quantity` per occurrence (so 2x of a bundle with member X
31
- * decrements X by 2, not 1).
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
- /** Resolve the member-product ids for a cart item. Bundle items expand to
25
- * `bundleProductIds[]`; regular items collapse to `[productId]`. */
26
- export function getCartItemMemberIds(item) {
27
- if (item.bundleProductIds && item.bundleProductIds.length > 0) {
28
- return item.bundleProductIds;
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 memberIds = getCartItemMemberIds(item);
37
- for (const pid of memberIds) {
38
- decrements.set(pid, (decrements.get(pid) ?? 0) + item.quantity);
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 memberIds = getCartItemMemberIds(item);
59
- for (const pid of memberIds) {
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: pid, availableQty: product?.availableQuantity ?? 0 };
61
+ return { productId: member.productId, availableQty: product?.availableQuantity ?? 0 };
63
62
  }
64
- const totalNeeded = decrements.get(pid) ?? item.quantity;
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: pid, availableQty: product.availableQuantity };
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
- const memberIds = item.bundleProductIds && item.bundleProductIds.length > 0
19
- ? item.bundleProductIds
20
- : [item.productId];
21
- for (const pid of memberIds) {
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
- * Three cases, in priority order:
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 bundle line** (SB-UNI-5 2026-05-13) — `item.price` is the bundle price
21
- * locked at add-time.
22
- * 3. **Everything else** — the live Firestore price, so a stale cart-cached
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