@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
@@ -12,6 +12,8 @@ import { storeRepository } from "../../stores/repository/store.repository";
12
12
  import { normalizeListingType } from "../../products/utils/listing-type";
13
13
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
14
14
  import { CART_LANE, activeLane, canAddNewItems, } from "../../../_internal/shared/checkout/lanes";
15
+ import { CART_MAX_ITEMS } from "../../../constants/limits";
16
+ import { getExpandedDecrements, validateCartItemStock, } from "../../../_internal/server/features/checkout/bundle-expansion";
15
17
  /**
16
18
  * Product documents don't always carry a denormalized `storeName` — the
17
19
  * create routes only set it when the caller explicitly passes one, so
@@ -27,6 +29,31 @@ async function resolveStoreName(storeId, candidate) {
27
29
  const store = await storeRepository.findById(storeId).catch(() => null);
28
30
  return store?.storeName ?? storeId;
29
31
  }
32
+ /**
33
+ * Lane gate. While a higher-obligation lane (a won auction, then an accepted
34
+ * offer) is outstanding, no NEW shopping may be added — otherwise the buyer can
35
+ * keep deferring something they already committed to by piling more into the
36
+ * cart. Enforced on the server, not only in the UI: the UI hint is a courtesy,
37
+ * this is the rule.
38
+ *
39
+ * 🛑 Every add-to-cart entry point must call this. It lived inline inside
40
+ * `addItemToCart` and therefore covered exactly one of the four ways a line can
41
+ * be created — `addBundleToCartAction` and `POST /api/cart` both call
42
+ * `cartRepository.addItem` directly and so bypassed it entirely, letting a buyer
43
+ * with an unpaid auction win keep shopping.
44
+ *
45
+ * Returns the loaded cart so the caller doesn't pay a second read.
46
+ */
47
+ export async function assertCanAddNewItems(userId) {
48
+ const cart = await cartRepository.getOrCreate(userId);
49
+ if (!canAddNewItems(cart.items ?? [])) {
50
+ const blocking = activeLane(cart.items ?? []);
51
+ throw new ValidationError(blocking === CART_LANE.AUCTION
52
+ ? "Settle your won auction first — you can add other items once it's paid for."
53
+ : "Complete your accepted offer first — you can add other items once it's paid for.", { code: CART_LANE_BLOCKED });
54
+ }
55
+ return cart;
56
+ }
30
57
  export async function addItemToCart(userId, input) {
31
58
  serverLogger.debug("addItemToCart", { userId, productId: input.productId });
32
59
  // SB-UNI-F 2026-05-13 — capability gate. Classified + live listings reject
@@ -36,23 +63,165 @@ export async function addItemToCart(userId, input) {
36
63
  if (!canAddToCart(input.listingType)) {
37
64
  throw new ValidationError(`Listings of type "${input.listingType}" cannot be added to the cart.`);
38
65
  }
39
- // Lane gate. While a higher-obligation lane (a won auction, then an accepted
40
- // offer) is outstanding, no NEW shopping may be added — otherwise the buyer
41
- // can keep deferring something they already committed to by piling more into
42
- // the cart. Enforced here, on the server, not only in the UI: the UI hint is
43
- // a courtesy, this is the rule.
44
- const cart = await cartRepository.getOrCreate(userId);
45
- if (!canAddNewItems(cart.items ?? [])) {
46
- const blocking = activeLane(cart.items ?? []);
47
- throw new ValidationError(blocking === CART_LANE.AUCTION
48
- ? "Settle your won auction first — you can add other items once it's paid for."
49
- : "Complete your accepted offer first — you can add other items once it's paid for.", { code: CART_LANE_BLOCKED });
50
- }
66
+ await assertCanAddNewItems(userId);
51
67
  const storeName = await resolveStoreName(input.storeId, input.storeName);
52
68
  return cartRepository.addItem(userId, { ...input, storeName });
53
69
  }
54
70
  /** Error code the client maps to a lane-aware message. */
55
71
  export const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
72
+ /** Max members one grouped line may hold. Keeps the batch read bounded (Rule #6). */
73
+ export const GROUP_LINE_MAX_MEMBERS = 20;
74
+ /**
75
+ * Add a buyer-assembled selection of group members as ONE cart line.
76
+ *
77
+ * The client sends ids and quantities and nothing else. Accepting a price, a
78
+ * title or an image from the browser would be a ₹1-group exploit; every
79
+ * snapshot below is read from Firestore here.
80
+ *
81
+ * A rejected member fails the WHOLE request, naming the offender. Silently
82
+ * dropping it would hand the buyer a line that quietly isn't what they picked —
83
+ * the "wrong data, no error" failure nobody can report.
84
+ */
85
+ export async function addGroupLineToCart(userId, input) {
86
+ const { groupId, groupSource, members } = input;
87
+ if (!groupId)
88
+ throw new ValidationError("A group id is required.");
89
+ if (!members.length)
90
+ throw new ValidationError("Select at least one item to add.");
91
+ if (members.length > GROUP_LINE_MAX_MEMBERS) {
92
+ throw new ValidationError(`A single group line can hold at most ${GROUP_LINE_MAX_MEMBERS} different items.`);
93
+ }
94
+ for (const m of members) {
95
+ if (!Number.isInteger(m.quantity) || m.quantity < 1) {
96
+ throw new ValidationError("Every selected item needs a whole quantity of at least 1.");
97
+ }
98
+ }
99
+ if (new Set(members.map((m) => m.productId)).size !== members.length) {
100
+ throw new ValidationError("The same item was selected twice.");
101
+ }
102
+ const cart = await assertCanAddNewItems(userId);
103
+ // One batched read for the whole selection — capped at 20 above, so well
104
+ // inside the per-request Firestore budget.
105
+ const docs = await Promise.all(members.map((m) => productRepository.findById(m.productId).catch(() => null)));
106
+ const productById = new Map(members.map((m, i) => [m.productId, docs[i]]));
107
+ const { getListingRule } = await import("../../../_internal/shared/checkout/rules");
108
+ const { ProductStatusValues } = await import("../../products/schemas/firestore");
109
+ const resolved = [];
110
+ for (const m of members) {
111
+ const product = productById.get(m.productId);
112
+ if (!product) {
113
+ throw new ValidationError(`One of the selected items no longer exists.`);
114
+ }
115
+ if (product.status !== ProductStatusValues.PUBLISHED || product.isSold) {
116
+ throw new ValidationError(`"${product.title}" is no longer available.`);
117
+ }
118
+ // Membership. Without this a caller could staple arbitrary products into
119
+ // one line and buy them under a group's identity.
120
+ const belongs = groupSource === "product-group"
121
+ ? product.groupId === groupId
122
+ : await isGroupedListingMember(groupId, product.id);
123
+ if (!belongs) {
124
+ throw new ValidationError(`"${product.title}" is not part of this group.`);
125
+ }
126
+ const listingType = normalizeListingType(product);
127
+ const rule = getListingRule(listingType);
128
+ if (!rule.cartEligible) {
129
+ throw new ValidationError(`"${product.title}" can't be added to the cart. ${rule.cartIneligibleHint ?? ""}`.trim());
130
+ }
131
+ if ((product.availableQuantity ?? 0) < m.quantity) {
132
+ throw new ValidationError(`Only ${product.availableQuantity ?? 0} left of "${product.title}".`);
133
+ }
134
+ resolved.push({
135
+ productId: product.id,
136
+ quantity: m.quantity,
137
+ unitPrice: product.price,
138
+ title: product.title,
139
+ image: product.images?.[0] ?? product.mainImage,
140
+ gstRate: product.gstRate,
141
+ hsnCode: product.hsnCode,
142
+ listingType,
143
+ });
144
+ }
145
+ // Single-store rule. `storeId` is the order-splitting key AND the key the
146
+ // per-store add-ons, coupons, shipping and payout all hang off, so a line
147
+ // spanning two stores would break five things at once. The picker hides
148
+ // itself for a cross-store group; this is the server-side half of that.
149
+ const storeIds = new Set(resolved.map((r) => productById.get(r.productId)?.storeId ?? ""));
150
+ if (storeIds.size > 1) {
151
+ throw new ValidationError("These items are sold by different sellers, so they can't be combined into one cart line.");
152
+ }
153
+ const storeId = [...storeIds][0] ?? "";
154
+ const first = productById.get(resolved[0].productId);
155
+ // A one-member selection is just an ordinary line: 1 item × qty N is exactly
156
+ // what a normal cart line already means, and routing it through the group
157
+ // path would create a second representation of the same thing.
158
+ if (resolved.length === 1) {
159
+ return addItemToCart(userId, {
160
+ productId: first.id,
161
+ productTitle: first.title,
162
+ productImage: first.images?.[0] ?? first.mainImage,
163
+ price: first.price,
164
+ currency: first.currency,
165
+ quantity: resolved[0].quantity,
166
+ storeId,
167
+ storeName: first.storeName ?? "",
168
+ listingType: resolved[0].listingType ?? "standard",
169
+ });
170
+ }
171
+ // A grouped line counts as ONE item against the distinct-items cap.
172
+ if (cart.items.length >= CART_MAX_ITEMS) {
173
+ throw new ValidationError(`Cart full (${cart.items.length}/${CART_MAX_ITEMS}). Remove items to add new ones.`);
174
+ }
175
+ // Same stock check checkout will run, so add-time and checkout-time agree by
176
+ // construction rather than by two similar-looking implementations.
177
+ const candidate = {
178
+ itemId: "candidate",
179
+ productId: groupId,
180
+ quantity: 1,
181
+ groupMembers: resolved,
182
+ };
183
+ const expansion = getExpandedDecrements([...cart.items, candidate]);
184
+ const shortfall = validateCartItemStock(candidate, new Map([...productById.entries()].filter(([, p]) => p !== null)), expansion.decrements);
185
+ if (shortfall) {
186
+ const short = productById.get(shortfall.productId);
187
+ throw new ValidationError(`Only ${shortfall.availableQty} left of "${short?.title ?? shortfall.productId}" once your cart is counted.`);
188
+ }
189
+ const groupTitle = await resolveGroupTitle(groupId, groupSource, first ?? null);
190
+ return cartRepository.addGroupLine(userId, {
191
+ productId: groupId,
192
+ productTitle: groupTitle,
193
+ productImage: resolved[0].image ?? "",
194
+ currency: first?.currency ?? getDefaultCurrency(),
195
+ // Pinned to 1: member quantities carry the whole selection, and a copies
196
+ // multiplier on top would double-count. See the invariant on CartLineMember.
197
+ quantity: 1,
198
+ storeId,
199
+ storeName: await resolveStoreName(storeId, first?.storeName),
200
+ listingType: "standard",
201
+ lineKind: "group",
202
+ groupSource,
203
+ groupId,
204
+ groupSlug: groupSource === "product-group" ? groupId : undefined,
205
+ groupTitle,
206
+ groupMembers: resolved,
207
+ });
208
+ }
209
+ /** Is this product listed on that `groupedListings` document? */
210
+ async function isGroupedListingMember(groupId, productId) {
211
+ const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
212
+ const group = await groupedListingsRepository.findById(groupId).catch(() => null);
213
+ return Boolean(group?.productIds?.includes(productId));
214
+ }
215
+ /** Display name for the line. Falls back to the parent product's group title. */
216
+ async function resolveGroupTitle(groupId, groupSource, sample) {
217
+ if (groupSource === "grouped-listing") {
218
+ const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
219
+ const group = await groupedListingsRepository.findById(groupId).catch(() => null);
220
+ if (group?.title)
221
+ return group.title;
222
+ }
223
+ return sample?.groupTitle ?? "Selected items";
224
+ }
56
225
  /**
57
226
  * Won-auction lines are non-removable and fixed-quantity — the buyer committed
58
227
  * to the purchase by bidding, and letting them delete it would turn every
@@ -65,6 +234,14 @@ export const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
65
234
  export async function updateCartItem(userId, itemId, input) {
66
235
  return cartRepository.updateItem(userId, itemId, input);
67
236
  }
237
+ /**
238
+ * Replace a grouped line's member quantities. A member set to 0 is dropped;
239
+ * dropping the last one removes the line (enforced in the repository so a
240
+ * forgetful caller can't leave an empty ghost line behind).
241
+ */
242
+ export async function updateCartGroupMembers(userId, itemId, input) {
243
+ return cartRepository.updateGroupMembers(userId, itemId, input);
244
+ }
68
245
  export async function removeCartItem(userId, itemId) {
69
246
  return cartRepository.removeItem(userId, itemId);
70
247
  }
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Aside, Button, Div, Heading, Row, Span, Stack, Text, TextLink } from "../../../ui";
4
4
  import { MediaImage } from "../../media/MediaImage";
5
+ import { QuantityStepper } from "../../../ui/components/QuantityStepper";
5
6
  import { formatCurrency } from "../../../utils/number.formatter";
6
7
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
7
8
  import { useHandMode } from "../../../_internal/client/hand-mode";
@@ -18,7 +19,7 @@ export function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock =
18
19
  return (_jsxs(Div, { layout: "flex", gap: isRow ? "3" : "4", surface: isRow ? "none" : "card", padding: isRow ? "none" : "sm", className: `min-w-0 transition-opacity ${isOutOfStock ? "opacity-60" : ""}`, children: [_jsx(Div, { surface: "muted", className: `relative ${isRow ? "h-16 w-16" : "h-20 w-20"} flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: item.meta.image && (_jsx(MediaImage, { src: item.meta.image, alt: item.meta.title, size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", className: "min-w-0", align: "start", children: [href ? (_jsx(TextLink, { href: href, target: "_blank", rel: "noopener noreferrer", weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: item.meta.title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.meta.title })), isOutOfStock && (_jsx(Span, { padding: "pill-2xs", color: "error", surface: "danger-surface", weight: "semibold", className: "flex-shrink-0 text-[10px] tracking-wide", rounded: "default", transform: "uppercase", children: "Out of Stock" }))] }), item.meta.attributes &&
19
20
  Object.keys(item.meta.attributes).length > 0 && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", truncate: 1, children: Object.entries(item.meta.attributes)
20
21
  .map(([k, v]) => `${k}: ${v}`)
21
- .join(", ") })), _jsxs(Row, { justify: "between", className: "min-w-0", gap: "sm", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity - 1), disabled: item.quantity <= 1, variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] disabled:opacity-40", children: "\u2212" }), _jsx(Span, { size: "sm", className: "min-w-[1.5rem]", align: "center", children: item.quantity }), _jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity + 1), variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)]", children: "+" })] })), isOutOfStock && (_jsxs(Span, { size: "xs", color: "muted", children: ["Qty: ", item.quantity] }))] })] }), onRemove && (_jsx(Button, { onClick: () => onRemove(item.id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
22
+ .join(", ") })), _jsxs(Row, { justify: "between", className: "min-w-0", gap: "sm", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (_jsx(QuantityStepper, { value: item.quantity, onChange: (next) => onQtyChange(item.id, next), min: 1, ariaLabel: `Quantity for ${item.meta.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })), isOutOfStock && (_jsxs(Span, { size: "xs", color: "muted", children: ["Qty: ", item.quantity] }))] })] }), onRemove && (_jsx(Button, { onClick: () => onRemove(item.id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
22
23
  }
23
24
  export function CartDrawer({ isOpen, onClose, items, subtotal = 0, currency, isLoading, onQtyChange, onRemove, onCheckout, labels = {}, }) {
24
25
  const { hand } = useHandMode();
@@ -1,7 +1,7 @@
1
1
  import { type DocumentReference } from "firebase-admin/firestore";
2
2
  import type { DocumentSnapshot } from "../../../providers/db-firebase";
3
3
  import { BaseRepository, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
4
- import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type UpdateCartItemInput } from "../schemas";
4
+ import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type AddGroupLineInput, type UpdateCartItemInput, type UpdateCartGroupMembersInput } from "../schemas";
5
5
  export declare class CartRepository extends BaseRepository<CartDocument> {
6
6
  private static readonly SIEVE_FIELDS;
7
7
  constructor();
@@ -12,6 +12,38 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
12
12
  getOrCreate(userId: string): Promise<CartDocument>;
13
13
  createEmpty(userId: string): Promise<CartDocument>;
14
14
  addItem(userId: string, input: AddToCartInput): Promise<CartDocument>;
15
+ /**
16
+ * Price of ONE COPY of a multi-member line.
17
+ *
18
+ * Private and called at every write of `groupMembers`, so `item.price` cannot
19
+ * drift from the members it is supposed to summarise — no caller sets it, so
20
+ * no caller can forget it. That property is load-bearing well beyond this
21
+ * file: `getSubtotal`, the cart page's per-seller subtotal, and both lane
22
+ * subtotals all compute `price × quantity`, and a grouped line pins
23
+ * `quantity` to 1, so all four stay correct with no changes of their own.
24
+ */
25
+ private deriveLinePrice;
26
+ /**
27
+ * Create a multi-member line. Deliberately never merges into an existing
28
+ * line — see the note in `addItem`.
29
+ */
30
+ addGroupLine(userId: string, input: AddGroupLineInput): Promise<CartDocument>;
31
+ /**
32
+ * Replace a grouped line's member quantities.
33
+ *
34
+ * Takes the WHOLE array rather than a per-member delta: the cart is one
35
+ * Firestore document written with `set()`, so N per-member writes would be
36
+ * both a Rule #6 cost and a genuine lost-update race on a fast double-click.
37
+ *
38
+ * A member dropped to 0 is removed, and **a line left with no members is
39
+ * removed entirely** — enforced here rather than in the client, so a caller
40
+ * that forgets cannot leave an empty ghost line in the document.
41
+ *
42
+ * Only quantities move. Prices, titles and images are the snapshots taken
43
+ * server-side at add time and are never re-accepted from a caller; adding a
44
+ * NEW member is an add-to-cart, which has its own eligibility checks.
45
+ */
46
+ updateGroupMembers(userId: string, itemId: string, input: UpdateCartGroupMembersInput): Promise<CartDocument>;
15
47
  updateItem(userId: string, itemId: string, input: UpdateCartItemInput): Promise<CartDocument>;
16
48
  removeItem(userId: string, itemId: string): Promise<CartDocument>;
17
49
  clearCart(userId: string): Promise<CartDocument>;
@@ -72,10 +72,17 @@ export class CartRepository extends BaseRepository {
72
72
  if (input.bidId && items.some((item) => item.bidId === input.bidId)) {
73
73
  return cart;
74
74
  }
75
+ // A grouped line is never merged by productId. Two "Add selected"
76
+ // presses are two different selections, and merging them would silently
77
+ // rewrite the buyer's per-member quantities into a copy count. Bundles
78
+ // DO still merge — the same bundle twice genuinely means two copies.
75
79
  const isLockedLine = Boolean(input.offerId || input.bidId);
76
- const existingIndex = isLockedLine
80
+ const existingIndex = isLockedLine || input.lineKind === "group"
77
81
  ? -1
78
- : items.findIndex((item) => item.productId === input.productId && !item.offerId && !item.bidId);
82
+ : items.findIndex((item) => item.productId === input.productId &&
83
+ !item.offerId &&
84
+ !item.bidId &&
85
+ item.lineKind !== "group");
79
86
  if (existingIndex >= 0) {
80
87
  items[existingIndex] = {
81
88
  ...items[existingIndex],
@@ -115,6 +122,12 @@ export class CartRepository extends BaseRepository {
115
122
  ...(input.bundleProductIds !== undefined && {
116
123
  bundleProductIds: input.bundleProductIds,
117
124
  }),
125
+ ...(input.lineKind !== undefined && { lineKind: input.lineKind }),
126
+ ...(input.groupSource !== undefined && { groupSource: input.groupSource }),
127
+ ...(input.groupId !== undefined && { groupId: input.groupId }),
128
+ ...(input.groupSlug !== undefined && { groupSlug: input.groupSlug }),
129
+ ...(input.groupTitle !== undefined && { groupTitle: input.groupTitle }),
130
+ ...(input.groupMembers !== undefined && { groupMembers: input.groupMembers }),
118
131
  addedAt: new Date(),
119
132
  updatedAt: new Date(),
120
133
  };
@@ -137,6 +150,137 @@ export class CartRepository extends BaseRepository {
137
150
  throw new DatabaseError("Failed to add item to cart", error);
138
151
  }
139
152
  }
153
+ /**
154
+ * Price of ONE COPY of a multi-member line.
155
+ *
156
+ * Private and called at every write of `groupMembers`, so `item.price` cannot
157
+ * drift from the members it is supposed to summarise — no caller sets it, so
158
+ * no caller can forget it. That property is load-bearing well beyond this
159
+ * file: `getSubtotal`, the cart page's per-seller subtotal, and both lane
160
+ * subtotals all compute `price × quantity`, and a grouped line pins
161
+ * `quantity` to 1, so all four stay correct with no changes of their own.
162
+ */
163
+ deriveLinePrice(members) {
164
+ const raw = members.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0);
165
+ // audit-money-units-ok: decimal rupees throughout; rounding to 2dp, not paise conversion.
166
+ return Math.round(raw * 100) / 100;
167
+ }
168
+ /**
169
+ * Create a multi-member line. Deliberately never merges into an existing
170
+ * line — see the note in `addItem`.
171
+ */
172
+ async addGroupLine(userId, input) {
173
+ try {
174
+ if (!input.groupMembers.length) {
175
+ throw new ValidationError("A grouped cart line needs at least one member.");
176
+ }
177
+ const cart = await this.getOrCreate(userId);
178
+ const newItem = {
179
+ itemId: randomUUID(),
180
+ productId: input.productId,
181
+ productTitle: input.productTitle,
182
+ productImage: input.productImage,
183
+ price: this.deriveLinePrice(input.groupMembers),
184
+ currency: input.currency,
185
+ quantity: input.quantity,
186
+ storeId: input.storeId,
187
+ storeName: input.storeName,
188
+ listingType: input.listingType,
189
+ groupMembers: input.groupMembers,
190
+ ...(input.lineKind !== undefined && { lineKind: input.lineKind }),
191
+ ...(input.groupSource !== undefined && { groupSource: input.groupSource }),
192
+ ...(input.groupId !== undefined && { groupId: input.groupId }),
193
+ ...(input.groupSlug !== undefined && { groupSlug: input.groupSlug }),
194
+ ...(input.groupTitle !== undefined && { groupTitle: input.groupTitle }),
195
+ ...(input.bundleCategorySlug !== undefined && {
196
+ bundleCategorySlug: input.bundleCategorySlug,
197
+ }),
198
+ addedAt: new Date(),
199
+ updatedAt: new Date(),
200
+ };
201
+ const updatedCart = {
202
+ ...cart,
203
+ items: [...cart.items, newItem],
204
+ updatedAt: new Date(),
205
+ };
206
+ await this.db
207
+ .collection(this.collection)
208
+ .doc(userId)
209
+ .set(prepareForFirestore(updatedCart));
210
+ return updatedCart;
211
+ }
212
+ catch (error) {
213
+ if (error instanceof DatabaseError || error instanceof ValidationError)
214
+ throw error;
215
+ throw new DatabaseError("Failed to add grouped line to cart", error);
216
+ }
217
+ }
218
+ /**
219
+ * Replace a grouped line's member quantities.
220
+ *
221
+ * Takes the WHOLE array rather than a per-member delta: the cart is one
222
+ * Firestore document written with `set()`, so N per-member writes would be
223
+ * both a Rule #6 cost and a genuine lost-update race on a fast double-click.
224
+ *
225
+ * A member dropped to 0 is removed, and **a line left with no members is
226
+ * removed entirely** — enforced here rather than in the client, so a caller
227
+ * that forgets cannot leave an empty ghost line in the document.
228
+ *
229
+ * Only quantities move. Prices, titles and images are the snapshots taken
230
+ * server-side at add time and are never re-accepted from a caller; adding a
231
+ * NEW member is an add-to-cart, which has its own eligibility checks.
232
+ */
233
+ async updateGroupMembers(userId, itemId, input) {
234
+ try {
235
+ const cart = await this.findByUserId(userId);
236
+ if (!cart)
237
+ throw new NotFoundError("Cart not found");
238
+ const itemIndex = cart.items.findIndex((item) => item.itemId === itemId);
239
+ if (itemIndex < 0)
240
+ throw new NotFoundError(ERR_CART_ITEM_NOT_FOUND);
241
+ const target = cart.items[itemIndex];
242
+ if (target.locked)
243
+ throw new ValidationError(ERR_CART_ITEM_LOCKED);
244
+ if (!target.groupMembers?.length) {
245
+ throw new ValidationError("This cart line has no members to update.");
246
+ }
247
+ const requested = new Map(input.groupMembers.map((m) => [m.productId, m.quantity]));
248
+ for (const productId of requested.keys()) {
249
+ if (!target.groupMembers.some((m) => m.productId === productId)) {
250
+ throw new ValidationError("That item is not part of this cart line. Add it from the group page instead.");
251
+ }
252
+ }
253
+ const nextMembers = target.groupMembers
254
+ .map((m) => (requested.has(m.productId)
255
+ ? { ...m, quantity: requested.get(m.productId) }
256
+ : m))
257
+ .filter((m) => m.quantity > 0);
258
+ if (nextMembers.length === 0) {
259
+ return this.removeItem(userId, itemId);
260
+ }
261
+ const items = [...cart.items];
262
+ items[itemIndex] = {
263
+ ...target,
264
+ groupMembers: nextMembers,
265
+ price: this.deriveLinePrice(nextMembers),
266
+ updatedAt: new Date(),
267
+ };
268
+ const updatedCart = { ...cart, items, updatedAt: new Date() };
269
+ await this.db
270
+ .collection(this.collection)
271
+ .doc(userId)
272
+ .set(prepareForFirestore(updatedCart));
273
+ return updatedCart;
274
+ }
275
+ catch (error) {
276
+ if (error instanceof DatabaseError ||
277
+ error instanceof NotFoundError ||
278
+ error instanceof ValidationError) {
279
+ throw error;
280
+ }
281
+ throw new DatabaseError("Failed to update grouped cart line", error);
282
+ }
283
+ }
140
284
  async updateItem(userId, itemId, input) {
141
285
  try {
142
286
  const cart = await this.findByUserId(userId);
@@ -6,6 +6,53 @@
6
6
  */
7
7
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
8
8
  import type { ListingType } from "../../products/types/index";
9
+ /**
10
+ * What KIND of thing one cart line represents.
11
+ *
12
+ * - absent — an ordinary single-product line.
13
+ * - `"bundle"` — a fixed, all-or-nothing set priced as a whole
14
+ * (`categoryType:"bundle"` row on `categories`). Members are
15
+ * not pickable; the only stepper is the line-level one and it
16
+ * means COPIES OF THE BUNDLE.
17
+ * - `"group"` — a buyer-assembled selection out of a product group
18
+ * (`product.groupId`) or a `groupedListings` doc. Members ARE
19
+ * pickable; there is no line-level stepper.
20
+ */
21
+ export type CartLineKind = "bundle" | "group";
22
+ /** Which collection a `"group"` line's members were picked from. */
23
+ export type CartGroupSource = "product-group" | "grouped-listing";
24
+ /**
25
+ * One member product inside a multi-member cart line.
26
+ *
27
+ * 🛑 THE QUANTITY INVARIANT — every reader and writer depends on it:
28
+ *
29
+ * `CartItemDocument.quantity` = copies of the whole selection
30
+ * `CartLineMember.quantity` = units of THIS member per copy
31
+ * demand for a product = item.quantity × member.quantity
32
+ *
33
+ * A `"group"` line pins `item.quantity` to 1, which is what lets `unitPriceFor`
34
+ * keep returning a UNIT price that every existing call site multiplies by
35
+ * `item.quantity` without double-counting. A `"bundle"` line is the mirror
36
+ * image: `item.quantity` is the editable one and member quantities are frozen
37
+ * at add time.
38
+ *
39
+ * Prices/titles/images are SNAPSHOTS taken server-side at add time. They are
40
+ * never accepted from the client — see `POST /api/cart/group`, which takes ids
41
+ * and quantities only.
42
+ */
43
+ export interface CartLineMember {
44
+ productId: string;
45
+ /** Units of this member per copy of the line. See the invariant above. */
46
+ quantity: number;
47
+ /** Snapshot of the member's own price at add time, in decimal rupees. */
48
+ unitPrice: number;
49
+ title: string;
50
+ image?: string;
51
+ /** Snapshot for per-member GST proration at checkout (`lineTaxComponentsFor`). */
52
+ gstRate?: number;
53
+ hsnCode?: string;
54
+ listingType?: ListingType;
55
+ }
9
56
  export interface CartItemDocument {
10
57
  itemId: string;
11
58
  productId: string;
@@ -64,16 +111,47 @@ export interface CartItemDocument {
64
111
  /**
65
112
  * SB-UNI-4 2026-05-13 — bundle identifier when this cart line represents a
66
113
  * bundle (categoryType:"bundle" row on the categories collection). When set,
67
- * `productId` points at the bundle category's id, `price` is the locked
68
- * bundlePrice, and `bundleProductIds` snapshots the member product
69
- * ids at add-to-cart time. Order-side fan-out into per-product OrderItem
70
- * entries lands in S-SBUNI-5 (checkout-side stock decrement + per-store
71
- * grouping); until then the foundation is here but BundleDetailView keeps
72
- * the "Add to cart coming soon" notice up.
114
+ * `productId` points at the bundle category's id and `price` is the locked
115
+ * bundlePrice.
116
+ *
117
+ * This is an IDENTITY, not a mirror — it is what the order-side collapse key
118
+ * (`groupOrderItemsByLine`) groups on, and it keeps being written.
73
119
  */
74
120
  bundleCategorySlug?: string;
75
- /** Snapshot of `bundle.bundleProductIds` at add-to-cart time. */
121
+ /**
122
+ * @deprecated Legacy carts only. Superseded by `groupMembers`, which can
123
+ * express per-member quantities where a bare id array cannot.
124
+ *
125
+ * Do NOT write this from new code and do NOT read it directly — a
126
+ * hand-maintained mirror alongside `groupMembers` is exactly the drift
127
+ * Recurrent Root Cause #42 describes. Read through `getCartLineMembers()`
128
+ * (`_internal/server/features/checkout/bundle-expansion.ts`), which falls
129
+ * back to these ids at quantity 1 so lines written before `groupMembers`
130
+ * existed keep behaving byte-identically.
131
+ */
76
132
  bundleProductIds?: string[];
133
+ /**
134
+ * Discriminator for a multi-member line. Absent on an ordinary
135
+ * single-product line. See `CartLineKind` for the two behaviours.
136
+ */
137
+ lineKind?: CartLineKind;
138
+ /** Which collection the members came from. `"group"` lines only. */
139
+ groupSource?: CartGroupSource;
140
+ /** `product.groupId` | `groupedListing.id` | the bundle category's id. */
141
+ groupId?: string;
142
+ /** Slug for the line's detail href. Bundles use `bundleCategorySlug` instead. */
143
+ groupSlug?: string;
144
+ /** Display snapshot of the group/bundle name. */
145
+ groupTitle?: string;
146
+ /**
147
+ * The members of a multi-member line, AUTHORITATIVE over `bundleProductIds`.
148
+ * See `CartLineMember` for the quantity invariant.
149
+ *
150
+ * `price` on this document is derived from this array by the repository
151
+ * (`deriveLinePrice`) at every write, never set by a caller — so no caller
152
+ * can forget to keep the two in step.
153
+ */
154
+ groupMembers?: CartLineMember[];
77
155
  /**
78
156
  * S-SBUNI-RULES 2026-05-13 — buyer's chosen shipping provider for this
79
157
  * item, snapshotted at cart time. Locked at checkout; the per-tab
@@ -149,10 +227,32 @@ export declare const CART_FIELDS: {
149
227
  readonly STORE_ID: "storeId";
150
228
  readonly STORE_NAME: "storeName";
151
229
  readonly LISTING_TYPE: "listingType";
230
+ readonly IS_OFFER: "isOffer";
231
+ readonly OFFER_ID: "offerId";
232
+ readonly IS_AUCTION_WIN: "isAuctionWin";
233
+ readonly AUCTION_ID: "auctionId";
234
+ readonly BID_ID: "bidId";
235
+ readonly IS_BUYOUT: "isBuyout";
236
+ readonly LOCKED_PRICE: "lockedPrice";
237
+ readonly CHECKOUT_DEADLINE: "checkoutDeadline";
238
+ readonly LOCKED: "locked";
239
+ readonly BUNDLE_CATEGORY_SLUG: "bundleCategorySlug";
240
+ /** @deprecated legacy read path — see CartItemDocument.bundleProductIds */
241
+ readonly BUNDLE_PRODUCT_IDS: "bundleProductIds";
242
+ readonly LINE_KIND: "lineKind";
243
+ readonly GROUP_SOURCE: "groupSource";
244
+ readonly GROUP_ID: "groupId";
245
+ readonly GROUP_SLUG: "groupSlug";
246
+ readonly GROUP_TITLE: "groupTitle";
247
+ readonly GROUP_MEMBERS: "groupMembers";
248
+ readonly CHOSEN_SHIPPING_PROVIDER_ID: "chosenShippingProviderId";
249
+ readonly CHOSEN_SHIPPING_FEE: "chosenShippingFee";
152
250
  readonly ADDED_AT: "addedAt";
153
251
  readonly UPDATED_AT: "updatedAt";
154
252
  };
155
253
  };
254
+ export declare const CART_LINE_KIND_VALUES: readonly ["bundle", "group"];
255
+ export declare const CART_GROUP_SOURCE_VALUES: readonly ["product-group", "grouped-listing"];
156
256
  export declare const DEFAULT_CART_DATA: Partial<CartDocument>;
157
257
  export type CartCreateInput = Omit<CartDocument, "id" | "createdAt" | "updatedAt">;
158
258
  export type AddToCartInput = {
@@ -178,12 +278,36 @@ export type AddToCartInput = {
178
278
  locked?: boolean;
179
279
  /** SB-UNI-4 2026-05-13 — bundle identifier when the line is a bundle. */
180
280
  bundleCategorySlug?: string;
181
- /** Snapshot of bundle members at add-to-cart time. */
281
+ /** @deprecated Legacy only — write `groupMembers` instead. */
182
282
  bundleProductIds?: string[];
283
+ lineKind?: CartLineKind;
284
+ groupSource?: CartGroupSource;
285
+ groupId?: string;
286
+ groupSlug?: string;
287
+ groupTitle?: string;
288
+ groupMembers?: CartLineMember[];
289
+ };
290
+ /**
291
+ * Input for creating a multi-member line. `price` is deliberately absent — the
292
+ * repository derives it from `groupMembers` so the two can never disagree.
293
+ */
294
+ export type AddGroupLineInput = Omit<AddToCartInput, "price" | "groupMembers"> & {
295
+ groupMembers: CartLineMember[];
183
296
  };
184
297
  export type UpdateCartItemInput = {
185
298
  quantity: number;
186
299
  };
300
+ /**
301
+ * Body of a per-member edit. The WHOLE array is sent, not a delta: the cart is
302
+ * a single Firestore document written with `set()`, so N per-member writes
303
+ * would be both a Rule #6 cost and a real lost-update race on a double-click.
304
+ */
305
+ export type UpdateCartGroupMembersInput = {
306
+ groupMembers: Array<{
307
+ productId: string;
308
+ quantity: number;
309
+ }>;
310
+ };
187
311
  export declare const cartQueryHelpers: {
188
312
  readonly byUserId: (userId: string) => {
189
313
  field: "userId";