@mohasinac/appkit 4.25.0 → 4.27.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 (58) hide show
  1. package/dist/_internal/server/features/bundles/actions.d.ts +4 -1
  2. package/dist/_internal/server/features/bundles/actions.js +54 -5
  3. package/dist/_internal/server/features/bundles/data.d.ts +19 -0
  4. package/dist/_internal/server/features/bundles/data.js +32 -0
  5. package/dist/_internal/server/features/bundles/index.d.ts +1 -1
  6. package/dist/_internal/server/features/bundles/index.js +1 -1
  7. package/dist/_internal/server/features/checkout/actions.js +38 -68
  8. package/dist/_internal/server/features/checkout/expand-order-items.d.ts +35 -0
  9. package/dist/_internal/server/features/checkout/expand-order-items.js +85 -0
  10. package/dist/_internal/server/features/grouped/index.d.ts +2 -0
  11. package/dist/_internal/server/features/grouped/index.js +2 -0
  12. package/dist/_internal/server/features/grouped/metadata.d.ts +17 -0
  13. package/dist/_internal/server/features/grouped/metadata.js +48 -0
  14. package/dist/_internal/server/features/grouped/og.d.ts +16 -0
  15. package/dist/_internal/server/features/grouped/og.js +24 -0
  16. package/dist/_internal/server/features/seo/sitemap.js +24 -1
  17. package/dist/_internal/shared/checkout/order-math.d.ts +63 -4
  18. package/dist/_internal/shared/checkout/order-math.js +119 -7
  19. package/dist/client.d.ts +7 -3
  20. package/dist/client.js +3 -2
  21. package/dist/constants/api-endpoints.d.ts +0 -6
  22. package/dist/constants/api-endpoints.js +0 -2
  23. package/dist/features/analytics/types.d.ts +1 -1
  24. package/dist/features/analytics/types.js +3 -0
  25. package/dist/features/cart/components/CartGroupLineRow.d.ts +42 -0
  26. package/dist/features/cart/components/CartGroupLineRow.js +33 -0
  27. package/dist/features/cart/components/index.d.ts +2 -0
  28. package/dist/features/cart/components/index.js +1 -0
  29. package/dist/features/cart/repository/cart.repository.js +6 -0
  30. package/dist/features/cart/schemas/firestore.d.ts +7 -2
  31. package/dist/features/categories/components/BundleBuyNowCta.d.ts +18 -5
  32. package/dist/features/categories/components/BundleBuyNowCta.js +24 -10
  33. package/dist/features/categories/components/BundleDetailView.d.ts +8 -1
  34. package/dist/features/categories/components/BundleDetailView.js +2 -2
  35. package/dist/features/categories/components/BundlesListView.d.ts +1 -0
  36. package/dist/features/categories/components/CategoryBundlesListing.d.ts +3 -0
  37. package/dist/features/grouped/components/GroupedListingDetailView.d.ts +21 -0
  38. package/dist/features/grouped/components/GroupedListingDetailView.js +17 -0
  39. package/dist/features/grouped/components/GroupedListingsCarousel.js +6 -1
  40. package/dist/features/orders/index.d.ts +1 -1
  41. package/dist/features/orders/index.js +1 -1
  42. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  43. package/dist/features/orders/utils/bundle-grouping.d.ts +29 -19
  44. package/dist/features/orders/utils/bundle-grouping.js +32 -22
  45. package/dist/features/tester/seed-data/categories-tester-seed-data.js +6 -0
  46. package/dist/features/tester/seed-data/products-tester-seed-data.js +150 -1
  47. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +118 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +1 -1
  50. package/dist/next/routing/route-map.d.ts +12 -0
  51. package/dist/next/routing/route-map.js +6 -0
  52. package/dist/server-entry.d.ts +1 -1
  53. package/dist/server-entry.js +1 -1
  54. package/dist/server.d.ts +3 -1
  55. package/dist/server.js +5 -1
  56. package/dist/styles.css +1 -1
  57. package/dist/tailwind-utilities.css +1 -1
  58. package/package.json +1 -1
@@ -12,4 +12,7 @@
12
12
  * bundleCategorySlug + bundleProductIds so checkout's bundle-expansion
13
13
  * helpers can fan out stock decrements to member products.
14
14
  */
15
- export declare function addBundleToCartAction(userId: string, bundleSlug: string): Promise<void>;
15
+ export declare function addBundleToCartAction(userId: string, bundleSlug: string,
16
+ /** Number of COPIES of the whole bundle. Bundles are all-or-nothing, so this
17
+ * is the only quantity a buyer can change — member quantities are fixed. */
18
+ quantity?: number): Promise<void>;
@@ -16,12 +16,20 @@ import { NotFoundError, ValidationError } from "../../../../errors";
16
16
  import { categoriesRepository } from "../../../../repositories";
17
17
  import { cartRepository } from "../../../../features/cart/repository/cart.repository";
18
18
  import { assertCanAddNewItems } from "../../../../features/cart/actions/cart-actions";
19
+ import { productRepository } from "../../../../features/products/repository/products.repository";
20
+ import { normalizeListingType } from "../../../../features/products/utils/listing-type";
19
21
  import { getDefaultCurrency } from "../../../../core/baseline-resolver";
20
- export async function addBundleToCartAction(userId, bundleSlug) {
22
+ export async function addBundleToCartAction(userId, bundleSlug,
23
+ /** Number of COPIES of the whole bundle. Bundles are all-or-nothing, so this
24
+ * is the only quantity a buyer can change — member quantities are fixed. */
25
+ quantity = 1) {
21
26
  if (!userId)
22
27
  throw new ValidationError("User id is required");
23
28
  if (!bundleSlug)
24
29
  throw new ValidationError("Bundle slug is required");
30
+ if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
31
+ throw new ValidationError("Choose between 1 and 99 copies of this bundle.");
32
+ }
25
33
  const bundle = await categoriesRepository.findBySlugAndType(bundleSlug, "bundle");
26
34
  if (!bundle)
27
35
  throw new NotFoundError("Bundle not found");
@@ -39,17 +47,58 @@ export async function addBundleToCartAction(userId, bundleSlug) {
39
47
  // silently skipped it: a buyer with an unpaid auction win could keep shopping
40
48
  // by adding bundles.
41
49
  await assertCanAddNewItems(userId);
50
+ // Members, so the cart can show WHAT is in the bundle and checkout can
51
+ // prorate GST across them.
52
+ //
53
+ // Every member is ONE unit per copy. `BundleItemDetail` has no quantity field
54
+ // — its only per-member number is `drawCount` (prize-draw entries), and
55
+ // treating that as a stock quantity would silently change how much a
56
+ // prize-draw member decrements, which the stock fan-out has never done.
57
+ const memberIds = bundle.bundleProductIds ?? [];
58
+ const memberDocs = await Promise.all(memberIds.map((id) => productRepository.findById(id).catch(() => null)));
59
+ const groupMembers = memberDocs
60
+ .filter((p) => p !== null)
61
+ .map((p) => ({
62
+ productId: p.id,
63
+ quantity: 1,
64
+ unitPrice: p.price,
65
+ title: p.title,
66
+ image: p.images?.[0] ?? p.mainImage,
67
+ gstRate: p.gstRate,
68
+ hsnCode: p.hsnCode,
69
+ listingType: normalizeListingType(p),
70
+ }));
71
+ // `createdByStoreId` is optional on a bundle category and the tester fixture
72
+ // has none, so this used to write `storeId: ""` — which produces an order
73
+ // group keyed `standard:` with `storeId: undefined`: no seller notification,
74
+ // no shipping resolution, no payout. Fall back to a member's store.
75
+ const storeId = bundle.createdByStoreId ||
76
+ memberDocs.find((p) => p?.storeId)?.storeId ||
77
+ "";
78
+ const storeName = bundle.createdByStoreName ||
79
+ memberDocs.find((p) => p?.storeId === storeId)?.storeName ||
80
+ "";
42
81
  await cartRepository.addItem(userId, {
43
82
  productId: bundle.id,
44
83
  productTitle: bundle.name,
45
84
  productImage: bundle.display?.coverImage ?? "",
85
+ // The bundle's own locked price — NOT the sum of its members. That is the
86
+ // whole point of a bundle, and `unitPriceFor` honours it.
46
87
  price: bundle.bundlePrice,
47
88
  currency: getDefaultCurrency(),
48
- quantity: 1,
49
- storeId: bundle.createdByStoreId ?? "",
50
- storeName: bundle.createdByStoreName ?? "",
89
+ quantity,
90
+ storeId,
91
+ storeName,
51
92
  listingType: "standard",
93
+ lineKind: "bundle",
94
+ groupId: bundle.id,
95
+ groupSlug: bundle.slug,
96
+ groupTitle: bundle.name,
52
97
  bundleCategorySlug: bundle.slug,
53
- bundleProductIds: bundle.bundleProductIds ?? [],
98
+ ...(groupMembers.length > 0 && { groupMembers }),
99
+ // `bundleProductIds` stays written ONLY when we could not resolve members
100
+ // — it is the legacy read path (see CartItemDocument), and writing both
101
+ // would be exactly the mirror-drift Root Cause #42 warns about.
102
+ ...(groupMembers.length === 0 && { bundleProductIds: memberIds }),
54
103
  });
55
104
  }
@@ -40,6 +40,25 @@ export declare const listBundleMembers: (bundle: Pick<CategoryDocument, "bundleP
40
40
  * displayed discount.
41
41
  */
42
42
  export declare function resolveBundleOriginalTotal(productIds: string[]): Promise<number | undefined>;
43
+ /**
44
+ * Reject a bundle whose members belong to more than one seller.
45
+ *
46
+ * `storeId` is a SCALAR on the cart line, and it is simultaneously the
47
+ * order-splitting key and the key that per-store add-ons, coupons, shipping and
48
+ * payout all hang off. A bundle spanning two sellers therefore produces one
49
+ * order belonging to one of them while containing the other's products — the
50
+ * second seller gets no notification, no shipping resolution and no payout.
51
+ *
52
+ * Grouped cart lines refuse this at both the picker and the add-to-cart action.
53
+ * Bundles had no equivalent guard, so the only reason nothing was broken was
54
+ * that every bundle anyone had built happened to be single-store. This closes
55
+ * that at the point a bundle is SAVED, which is the cheapest place to say no:
56
+ * bundles already in the database keep working, and checkout is untouched.
57
+ *
58
+ * Returns the offending store ids (empty when fine) rather than throwing, so
59
+ * each route can phrase its own 400.
60
+ */
61
+ export declare function findBundleMemberStores(productIds: string[]): Promise<string[]>;
43
62
  /**
44
63
  * Union of the member products' `categorySlugs` — the `bundleCategorySlugs`
45
64
  * mirror that lets a category page scope its Bundles tab.
@@ -68,6 +68,38 @@ export async function resolveBundleOriginalTotal(productIds) {
68
68
  const total = prices.reduce((sum, p) => sum + p, 0);
69
69
  return Math.round(total * 100) / 100;
70
70
  }
71
+ /**
72
+ * Reject a bundle whose members belong to more than one seller.
73
+ *
74
+ * `storeId` is a SCALAR on the cart line, and it is simultaneously the
75
+ * order-splitting key and the key that per-store add-ons, coupons, shipping and
76
+ * payout all hang off. A bundle spanning two sellers therefore produces one
77
+ * order belonging to one of them while containing the other's products — the
78
+ * second seller gets no notification, no shipping resolution and no payout.
79
+ *
80
+ * Grouped cart lines refuse this at both the picker and the add-to-cart action.
81
+ * Bundles had no equivalent guard, so the only reason nothing was broken was
82
+ * that every bundle anyone had built happened to be single-store. This closes
83
+ * that at the point a bundle is SAVED, which is the cheapest place to say no:
84
+ * bundles already in the database keep working, and checkout is untouched.
85
+ *
86
+ * Returns the offending store ids (empty when fine) rather than throwing, so
87
+ * each route can phrase its own 400.
88
+ */
89
+ export async function findBundleMemberStores(productIds) {
90
+ if (productIds.length === 0)
91
+ return [];
92
+ const results = await Promise.all(productIds.map((id) => productRepository.findById(id).catch(() => null)));
93
+ const stores = new Set();
94
+ for (const p of results) {
95
+ // An unresolvable member says nothing about seller spread — the price
96
+ // helper above already refuses to guess in that case, and inventing a
97
+ // store here would produce a false positive that blocks a valid save.
98
+ if (p?.storeId)
99
+ stores.add(p.storeId);
100
+ }
101
+ return [...stores];
102
+ }
71
103
  /**
72
104
  * Union of the member products' `categorySlugs` — the `bundleCategorySlugs`
73
105
  * mirror that lets a category page scope its Bundles tab.
@@ -1,4 +1,4 @@
1
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, resolveBundleCategorySlugs, type BundleDataOptions, } from "./data";
1
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, findBundleMemberStores, resolveBundleCategorySlugs, type BundleDataOptions, } from "./data";
2
2
  export { buildBundleMetadata, type BundleMetadataOptions } from "./metadata";
3
3
  export { renderBundleOg, renderBundleOgImage, type BundleOgData, } from "./og";
4
4
  export { addBundleToCartAction } from "./actions";
@@ -1,4 +1,4 @@
1
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, resolveBundleCategorySlugs, } from "./data";
1
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, findBundleMemberStores, resolveBundleCategorySlugs, } from "./data";
2
2
  export { buildBundleMetadata } from "./metadata";
3
3
  // S-SBUNI-4 2026-05-13 — bundle OG renderer.
4
4
  export { renderBundleOg, renderBundleOgImage, } from "./og";
@@ -13,8 +13,7 @@ import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../
13
13
  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
- import { calculateGst } from "../../../shared/fees/calculator";
17
- import { computePreOrderDepositAmount, lineTotalFor, unitPriceFor } from "../../../shared/checkout/order-math";
16
+ import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math";
18
17
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
19
18
  import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
20
19
  import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
@@ -27,6 +26,7 @@ import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../fea
27
26
  import { CART_COLLECTION } from "../../../../features/cart/schemas/index";
28
27
  // S-SBUNI-RULES 2026-05-13 — bundle cart-line stock fan-out helpers.
29
28
  import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, isMultiMemberLine, } from "./bundle-expansion";
29
+ import { expandCartLineToOrderRows } from "./expand-order-items";
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";
@@ -376,32 +376,13 @@ async function createOrderForGroup(group, orderType, ctx) {
376
376
  const { platformFee = 0, gstOnFee: platformFeeGst = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
377
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
- const orderItems = group.map(({ item, product }) => {
379
+ // A multi-member line expands into one row PER MEMBER sharing a groupSlug, so
380
+ // each row carries its own HSN/GST and can be cancelled independently; the
381
+ // receipt still collapses them under one header.
382
+ const orderItems = group.flatMap(({ item, product }) => {
380
383
  const lt = (product.listingType ?? "standard");
381
384
  const itemRule = getListingRule(lt);
382
- // SB-UNI-5 2026-05-13 — forward bundle identifiers so the order-detail
383
- // UI can collapse expanded lines back under a "Bundle: <name>" header.
384
- const bundleFields = item.bundleCategorySlug && item.bundleProductIds
385
- ? {
386
- bundleCategorySlug: item.bundleCategorySlug,
387
- bundleProductIds: item.bundleProductIds,
388
- }
389
- : {};
390
- const unitPrice = unitPriceFor(item, product);
391
- const gstFields = product.gstRate != null
392
- ? { gstRate: product.gstRate, ...(product.hsnCode ? { hsnCode: product.hsnCode } : {}) }
393
- : {};
394
- const baseLine = {
395
- productId: item.productId,
396
- productTitle: item.productTitle,
397
- image: item.productImage,
398
- quantity: item.quantity,
399
- unitPrice,
400
- totalPrice: unitPrice * item.quantity,
401
- ...bundleFields,
402
- ...gstFields,
403
- };
404
- return itemRule.decorateOrderItem(baseLine, product);
385
+ return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
405
386
  });
406
387
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
407
388
  const { shippingFee, storeOwnerId, storeEmiEnabled, storeState } = await resolveShippingCost(firstItem.storeId);
@@ -412,16 +393,11 @@ async function createOrderForGroup(group, orderType, ctx) {
412
393
  let gstBreakdown;
413
394
  if (gstSettings?.enabled && buyerState) {
414
395
  const intraState = !!storeState && storeState === buyerState;
415
- let taxableAmount = 0;
416
- let gstAmount = 0;
417
- for (const { item, product } of group) {
418
- const lineTotal = lineTotalFor(item, product);
419
- const rate = product.gstRate ?? 0;
420
- if (rate > 0) {
421
- taxableAmount += lineTotal;
422
- gstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
423
- }
424
- }
396
+ // Per-member, not per-line: a bundle or grouped line spans several products
397
+ // with several different rates, and taxing the whole line at one product's
398
+ // rate is wrong in both directions. Shared with previewCheckoutPricing so
399
+ // the figure shown and the figure charged cannot drift.
400
+ const { taxableAmount, gstAmount } = sumGroupGst(group.map(({ item }) => item), ctx.productById, intraState);
425
401
  if (taxableAmount > 0) {
426
402
  gstBreakdown = intraState
427
403
  ? { taxableAmount, cgst: Math.round(gstAmount / 2), sgst: gstAmount - Math.round(gstAmount / 2), igst: 0, gstAmount }
@@ -793,7 +769,7 @@ export async function createCheckoutOrderAction(input) {
793
769
  updatedAt: new Date(),
794
770
  }, { merge: true });
795
771
  }
796
- return { available: availableItems, unavailable: unavailableItems };
772
+ return { available: availableItems, unavailable: unavailableItems, productById };
797
773
  });
798
774
  }
799
775
  catch (err) {
@@ -806,7 +782,7 @@ export async function createCheckoutOrderAction(input) {
806
782
  .catch((logErr) => { void normalizeError(logErr); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: logErr instanceof Error ? logErr.message : String(logErr) }); });
807
783
  throw err;
808
784
  }
809
- const { available, unavailable } = stockResult;
785
+ const { available, unavailable, productById: checkoutProductById } = stockResult;
810
786
  if (available.length === 0) {
811
787
  failedCheckoutRepository
812
788
  .logCheckout(uid, "stock_failed", "All items out of stock", {
@@ -860,6 +836,7 @@ export async function createCheckoutOrderAction(input) {
860
836
  siteContactUpiVpa: siteSettings?.contact?.upiVpa,
861
837
  storeAddons: cart.storeAddons,
862
838
  platformFeeByStore,
839
+ productById: checkoutProductById,
863
840
  };
864
841
  for (const { items: group, orderType } of orderGroups) {
865
842
  total += await createOrderForGroup(group, orderType, groupCtx);
@@ -920,9 +897,18 @@ export async function previewCheckoutPricing(input) {
920
897
  // Best-effort product lookup — a preview never throws on a since-unpublished
921
898
  // product, it just falls back to the item's cart-snapshot price for that
922
899
  // line (same fallback cartRepository.getSubtotal() implicitly relies on).
923
- const uniqueProductIds = [...new Set(cartItems.map((item) => item.productId))];
900
+ //
901
+ // Fetch by the EXPANDED member set, not by `item.productId`. On a bundle line
902
+ // `productId` is a CATEGORY id, so it resolved to null and the GST loop below
903
+ // read `product?.gstRate ?? 0` — which is precisely why a bundle has paid no
904
+ // GST since bundles reached the cart. The expansion is the same fan-out the
905
+ // stock decrement uses, so preview and placement agree by construction.
906
+ const expandedIds = getExpandedDecrements(cartItems).productIds;
907
+ const uniqueProductIds = [...new Set([...expandedIds, ...cartItems.map((i) => i.productId)])];
924
908
  const productDocs = await Promise.all(uniqueProductIds.map((pid) => productRepository.findById(pid)));
925
909
  const productById = new Map(uniqueProductIds.map((pid, i) => [pid, productDocs[i]]));
910
+ /** Non-null view, for helpers that need a definite product map. */
911
+ const resolvedProductById = new Map([...productById.entries()].filter(([, p]) => p != null));
926
912
  let resolvedAddress = null;
927
913
  if (addressId) {
928
914
  const addressDoc = await unitOfWork.addresses.findById(addressId);
@@ -956,12 +942,10 @@ export async function previewCheckoutPricing(input) {
956
942
  let groupGstAmount = 0;
957
943
  if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
958
944
  const intraState = !!storeState && storeState === resolvedAddress.state;
959
- for (const { item, product } of group) {
960
- const lineTotal = lineTotalFor(item, product);
961
- const rate = product?.gstRate ?? 0;
962
- if (rate > 0)
963
- groupGstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
964
- }
945
+ // Same helper createOrderForGroup uses — the two disagreeing is the
946
+ // recurring defect here (Root Cause #59), and it is the buyer seeing one
947
+ // tax figure and being charged another.
948
+ groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById, intraState).gstAmount;
965
949
  }
966
950
  const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
967
951
  const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
@@ -1138,7 +1122,7 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
1138
1122
  * from the inline version this replaced.
1139
1123
  */
1140
1124
  async function createRazorpayGroupOrder(group, orderType, ctx) {
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;
1125
+ const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: razorpayProductById, } = ctx;
1142
1126
  const firstItem = group[0].item;
1143
1127
  const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
1144
1128
  // Reuses the same store/seller lookup as the COD/UPI path above instead
@@ -1186,30 +1170,15 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
1186
1170
  // alongside P-13 (Razorpay is disabled by default today, so this order
1187
1171
  // type doesn't currently carry a GST breakdown).
1188
1172
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
1189
- const orderItems = group.map(({ item, product }) => {
1173
+ // Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
1174
+ // the price rule (`isBundle ? item.price : product.price`) that — like the
1175
+ // two in the consumer's Razorpay route — silently omitted `lockedPrice`, so
1176
+ // an accepted offer paid through Razorpay was RECORDED at the seller's list
1177
+ // price. `lineTotalFor` inside the expansion is the single definition.
1178
+ const orderItems = group.flatMap(({ item, product }) => {
1190
1179
  const lt = (product?.listingType ?? "standard");
1191
1180
  const itemRule = getListingRule(lt);
1192
- // SB-UNI-5 2026-05-13 — bundle cart-lines surface the locked
1193
- // bundlePrice (item.price), not the representative member's
1194
- // product.price. Stock decrement still runs per member elsewhere.
1195
- const isBundle = Boolean(item.bundleCategorySlug && item.bundleProductIds?.length);
1196
- const unitPrice = isBundle ? item.price : product.price;
1197
- const bundleFields = isBundle
1198
- ? {
1199
- bundleCategorySlug: item.bundleCategorySlug,
1200
- bundleProductIds: item.bundleProductIds,
1201
- }
1202
- : {};
1203
- const baseLine = {
1204
- productId: item.productId,
1205
- productTitle: item.productTitle,
1206
- image: item.productImage,
1207
- quantity: item.quantity,
1208
- unitPrice,
1209
- totalPrice: unitPrice * item.quantity,
1210
- ...bundleFields,
1211
- };
1212
- return itemRule.decorateOrderItem(baseLine, product);
1181
+ return expandCartLineToOrderRows(item, product, razorpayProductById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
1213
1182
  });
1214
1183
  const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
1215
1184
  const imageUrls = [
@@ -1537,6 +1506,7 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1537
1506
  orderIds,
1538
1507
  emailsToSend,
1539
1508
  couponUsageAccumulator,
1509
+ productById: productByIdPaid,
1540
1510
  });
1541
1511
  }
1542
1512
  await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cart line → order rows.
3
+ *
4
+ * A multi-member line (a bundle, or a buyer-assembled group selection) becomes
5
+ * **N rows, one per member**, all sharing a `groupSlug`. Not one collapsed row.
6
+ *
7
+ * Why expand:
8
+ * - `hsnCode` and `gstRate` are per-PRODUCT, and a GST invoice legally needs
9
+ * HSN per line. A collapsed row can carry exactly one — which is why the
10
+ * collapsed bundle row has always carried none.
11
+ * - `cancelledQuantity` is per-product; partially cancelling one member of a
12
+ * three-item group is impossible on a collapsed row.
13
+ * - After per-member GST landed, `order.taxableAmount` is a sum over members.
14
+ * With one collapsed row it would not equal the sum of the order's own
15
+ * lines, and the invoice would visibly fail to add up.
16
+ *
17
+ * The buyer's receipt is unaffected: `groupOrderItemsByLine` collapses these
18
+ * rows back under one header, which it was already written to do.
19
+ */
20
+ import type { CartItemDocument } from "../../../../features/cart/schemas/firestore";
21
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
22
+ import type { OrderDocumentItem } from "../../../../features/orders/schemas/firestore";
23
+ /** A row before rule decoration — the shape `decorateOrderItem` receives. */
24
+ export type BaseOrderLine = Omit<OrderDocumentItem, "listingType">;
25
+ /**
26
+ * Expand one cart line into the order rows it should produce.
27
+ *
28
+ * A single-product line yields exactly one row, identical to before.
29
+ *
30
+ * For a BUNDLE the per-row `totalPrice` is a prorated share of the bundle's
31
+ * locked price — never the member's list price — so the rows sum to what the
32
+ * buyer actually pays. For a GROUP the member prices already are the price, so
33
+ * the shares are exact.
34
+ */
35
+ export declare function expandCartLineToOrderRows(item: CartItemDocument, representative: ProductDocument, productById: Map<string, ProductDocument>): BaseOrderLine[];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Cart line → order rows.
3
+ *
4
+ * A multi-member line (a bundle, or a buyer-assembled group selection) becomes
5
+ * **N rows, one per member**, all sharing a `groupSlug`. Not one collapsed row.
6
+ *
7
+ * Why expand:
8
+ * - `hsnCode` and `gstRate` are per-PRODUCT, and a GST invoice legally needs
9
+ * HSN per line. A collapsed row can carry exactly one — which is why the
10
+ * collapsed bundle row has always carried none.
11
+ * - `cancelledQuantity` is per-product; partially cancelling one member of a
12
+ * three-item group is impossible on a collapsed row.
13
+ * - After per-member GST landed, `order.taxableAmount` is a sum over members.
14
+ * With one collapsed row it would not equal the sum of the order's own
15
+ * lines, and the invoice would visibly fail to add up.
16
+ *
17
+ * The buyer's receipt is unaffected: `groupOrderItemsByLine` collapses these
18
+ * rows back under one header, which it was already written to do.
19
+ */
20
+ import { lineTotalFor, allocateAcrossMembers } from "../../../shared/checkout/order-math";
21
+ /**
22
+ * Expand one cart line into the order rows it should produce.
23
+ *
24
+ * A single-product line yields exactly one row, identical to before.
25
+ *
26
+ * For a BUNDLE the per-row `totalPrice` is a prorated share of the bundle's
27
+ * locked price — never the member's list price — so the rows sum to what the
28
+ * buyer actually pays. For a GROUP the member prices already are the price, so
29
+ * the shares are exact.
30
+ */
31
+ export function expandCartLineToOrderRows(item, representative, productById) {
32
+ const members = item.groupMembers;
33
+ const lineTotal = lineTotalFor(item, representative);
34
+ if (!members?.length) {
35
+ const gstFields = representative.gstRate != null
36
+ ? {
37
+ gstRate: representative.gstRate,
38
+ ...(representative.hsnCode ? { hsnCode: representative.hsnCode } : {}),
39
+ }
40
+ : {};
41
+ return [{
42
+ productId: item.productId,
43
+ productTitle: item.productTitle,
44
+ image: item.productImage,
45
+ quantity: item.quantity,
46
+ unitPrice: lineTotal / (item.quantity || 1),
47
+ totalPrice: lineTotal,
48
+ // Legacy collapsed bundle rows (no groupMembers) keep their old shape so
49
+ // orders already in flight and their UI stay untouched.
50
+ ...(item.bundleCategorySlug && item.bundleProductIds
51
+ ? {
52
+ bundleCategorySlug: item.bundleCategorySlug,
53
+ bundleProductIds: item.bundleProductIds,
54
+ }
55
+ : {}),
56
+ ...gstFields,
57
+ }];
58
+ }
59
+ const groupSlug = item.groupSlug ?? item.bundleCategorySlug ?? item.groupId ?? item.itemId;
60
+ const groupSource = item.lineKind === "bundle" ? "bundle" : item.groupSource ?? "product-group";
61
+ const shares = allocateAcrossMembers(lineTotal, members.map((m) => m.unitPrice * m.quantity));
62
+ return members.map((m, i) => {
63
+ const product = productById.get(m.productId);
64
+ const gstRate = product?.gstRate ?? m.gstRate;
65
+ const hsnCode = product?.hsnCode ?? m.hsnCode;
66
+ // Units of this member across all copies of the line.
67
+ const quantity = m.quantity * item.quantity;
68
+ const totalPrice = shares[i];
69
+ return {
70
+ productId: m.productId,
71
+ productTitle: m.title,
72
+ image: m.image ?? "",
73
+ quantity,
74
+ unitPrice: quantity > 0 ? totalPrice / quantity : totalPrice,
75
+ totalPrice,
76
+ groupSlug,
77
+ groupTitle: item.groupTitle ?? item.productTitle,
78
+ groupSource,
79
+ // Kept on bundle rows so the pre-existing receipt grouping (which keys on
80
+ // this) keeps working for bundles without any UI change.
81
+ ...(item.bundleCategorySlug ? { bundleCategorySlug: item.bundleCategorySlug } : {}),
82
+ ...(gstRate != null ? { gstRate, ...(hsnCode ? { hsnCode } : {}) } : {}),
83
+ };
84
+ });
85
+ }
@@ -1,2 +1,4 @@
1
1
  export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand, type GroupedListingWithItems, type ListGroupedListingsParams, type SitemapGroupedListing, } from "./data";
2
+ export { buildGroupedListingMetadata, type GroupedListingMetadataOptions, } from "./metadata";
3
+ export { renderGroupedListingOg } from "./og";
2
4
  export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
@@ -1,2 +1,4 @@
1
1
  export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand, } from "./data";
2
+ export { buildGroupedListingMetadata, } from "./metadata";
3
+ export { renderGroupedListingOg } from "./og";
2
4
  export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Grouped-listing metadata helper.
3
+ *
4
+ * Maps a `GroupedListingDocument` to a Next.js Metadata object. Does NOT fetch
5
+ * — the page already did, and both share one `React.cache`d call. Site branding
6
+ * flows through opts so this file stays appkit-internal and
7
+ * consumer-configurable (§ "SSR Architecture").
8
+ */
9
+ import type { Metadata } from "next";
10
+ import type { GroupedListingDocument } from "../../../../features/grouped/schemas/firestore";
11
+ export interface GroupedListingMetadataOptions {
12
+ siteName?: string;
13
+ siteUrl?: string;
14
+ /** Override the OG image — defaults to the group's coverImage. */
15
+ ogImageUrl?: string;
16
+ }
17
+ export declare function buildGroupedListingMetadata(group: GroupedListingDocument | null, opts?: GroupedListingMetadataOptions): Metadata;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Grouped-listing metadata helper.
3
+ *
4
+ * Maps a `GroupedListingDocument` to a Next.js Metadata object. Does NOT fetch
5
+ * — the page already did, and both share one `React.cache`d call. Site branding
6
+ * flows through opts so this file stays appkit-internal and
7
+ * consumer-configurable (§ "SSR Architecture").
8
+ */
9
+ const FALLBACK_DESCRIPTION = "A curated group of related listings — pick the pieces you want and add them together.";
10
+ export function buildGroupedListingMetadata(group, opts) {
11
+ const siteName = opts?.siteName?.trim() || "";
12
+ const suffix = siteName ? ` — ${siteName}` : "";
13
+ if (!group) {
14
+ return {
15
+ title: `Group not found${suffix}`,
16
+ description: "This group is unavailable or has been removed.",
17
+ robots: { index: false },
18
+ };
19
+ }
20
+ const title = `${group.title}${suffix}`;
21
+ const description = group.description?.trim() || FALLBACK_DESCRIPTION;
22
+ const siteUrl = opts?.siteUrl?.replace(/\/+$/, "");
23
+ const canonical = siteUrl ? `${siteUrl}/groups/${group.slug}` : undefined;
24
+ const cover = opts?.ogImageUrl ?? group.coverImage;
25
+ // A group below its `minActiveMembers` floor is hidden from every carousel;
26
+ // letting a search engine index it would surface a page the site itself has
27
+ // decided not to show.
28
+ const isPublic = group.isActive && group.visibilityStatus === "visible";
29
+ return {
30
+ title,
31
+ description,
32
+ robots: isPublic ? undefined : { index: false },
33
+ alternates: canonical ? { canonical } : undefined,
34
+ openGraph: {
35
+ title,
36
+ description,
37
+ url: canonical,
38
+ type: "website",
39
+ images: cover ? [{ url: cover }] : undefined,
40
+ },
41
+ twitter: {
42
+ card: cover ? "summary_large_image" : "summary",
43
+ title,
44
+ description,
45
+ images: cover ? [cover] : undefined,
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Grouped-listing OG card.
3
+ *
4
+ * Deliberately delegates to `renderBundleOgImage` rather than re-implementing
5
+ * the layout: the card is the same shape (title, description, cover, item
6
+ * count) minus the price, and `BundleOgData.priceLabel` / `.stockStatus` are
7
+ * already nullable for exactly that case. Forking it would give two ~150-line
8
+ * OG layouts to keep visually in step — the Duplication Framework's
9
+ * "same prop surface, same output" consolidate rule.
10
+ */
11
+ import type { ReactElement } from "react";
12
+ import type { GroupedListingDocument } from "../../../../features/grouped/schemas/firestore";
13
+ export declare function renderGroupedListingOg(doc: Pick<GroupedListingDocument, "title" | "description" | "coverImage" | "productIds"> | null | undefined, opts: {
14
+ siteName: string;
15
+ baseUrl?: string;
16
+ }): ReactElement;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Grouped-listing OG card.
3
+ *
4
+ * Deliberately delegates to `renderBundleOgImage` rather than re-implementing
5
+ * the layout: the card is the same shape (title, description, cover, item
6
+ * count) minus the price, and `BundleOgData.priceLabel` / `.stockStatus` are
7
+ * already nullable for exactly that case. Forking it would give two ~150-line
8
+ * OG layouts to keep visually in step — the Duplication Framework's
9
+ * "same prop surface, same output" consolidate rule.
10
+ */
11
+ import { renderBundleOgImage } from "../bundles/og";
12
+ import { resolveOgImageUrl } from "../seo/og";
13
+ export function renderGroupedListingOg(doc, opts) {
14
+ return renderBundleOgImage({
15
+ name: doc?.title ?? "Group",
16
+ description: doc?.description?.slice(0, 140) ??
17
+ `A curated group of related listings on ${opts.siteName}.`,
18
+ coverImageUrl: resolveOgImageUrl(doc?.coverImage ?? null, opts.baseUrl),
19
+ // A group has no price of its own — the buyer's selection is the price.
20
+ priceLabel: null,
21
+ itemCount: doc?.productIds?.length ?? null,
22
+ stockStatus: null,
23
+ }, opts.siteName);
24
+ }