@mohasinac/appkit 4.22.0 → 4.24.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 (112) hide show
  1. package/dist/_internal/server/features/bundles/actions.js +7 -0
  2. package/dist/_internal/server/features/checkout/actions.js +24 -16
  3. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  4. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  5. package/dist/_internal/server/features/stores/adapters.js +5 -0
  6. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  7. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  8. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  9. package/dist/_internal/shared/actions/action-registry.js +41 -0
  10. package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
  11. package/dist/_internal/shared/checkout/order-math.js +29 -6
  12. package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
  13. package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
  14. package/dist/_internal/shared/features/cart/config.d.ts +2 -1
  15. package/dist/_internal/shared/features/cart/config.js +2 -1
  16. package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
  17. package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
  18. package/dist/_internal/shared/features/notifications/action-url.js +111 -0
  19. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  20. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  21. package/dist/_internal/shared/history/index.d.ts +1 -1
  22. package/dist/_internal/shared/history/types.d.ts +16 -0
  23. package/dist/client.d.ts +2 -0
  24. package/dist/client.js +1 -0
  25. package/dist/constants/field-names.d.ts +12 -0
  26. package/dist/constants/field-names.js +12 -0
  27. package/dist/constants/notification-types.d.ts +38 -10
  28. package/dist/constants/notification-types.js +39 -28
  29. package/dist/features/account/types/index.d.ts +1 -10
  30. package/dist/features/admin/actions/admin-actions.js +13 -3
  31. package/dist/features/admin/actions/notification-actions.d.ts +12 -0
  32. package/dist/features/admin/actions/notification-actions.js +149 -4
  33. package/dist/features/admin/components/AdminBidsView.js +7 -1
  34. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  35. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  36. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  37. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  38. package/dist/features/admin/schemas/firestore.d.ts +14 -3
  39. package/dist/features/admin/schemas/firestore.js +79 -0
  40. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  41. package/dist/features/auctions/repository/bid.repository.js +57 -33
  42. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  43. package/dist/features/auctions/schemas/firestore.js +21 -0
  44. package/dist/features/auth/schemas/index.d.ts +2 -2
  45. package/dist/features/cart/actions/cart-actions.d.ts +16 -0
  46. package/dist/features/cart/actions/cart-actions.js +26 -12
  47. package/dist/features/cart/components/CartDrawer.js +2 -1
  48. package/dist/features/cart/schemas/firestore.d.ts +132 -8
  49. package/dist/features/cart/schemas/firestore.js +22 -0
  50. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  51. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  52. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  53. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  54. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  55. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  56. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  57. package/dist/features/catalogue/schemas/firestore.js +12 -0
  58. package/dist/features/categories/schemas/index.d.ts +2 -2
  59. package/dist/features/email/index.d.ts +1 -1
  60. package/dist/features/email/index.js +5 -1
  61. package/dist/features/email/notification-templates.d.ts +35 -0
  62. package/dist/features/email/notification-templates.js +96 -0
  63. package/dist/features/messages/schemas/index.d.ts +6 -6
  64. package/dist/features/orders/schemas/index.d.ts +14 -14
  65. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  66. package/dist/features/payments/repository/payout.repository.js +45 -19
  67. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  68. package/dist/features/payments/schemas/firestore.js +39 -0
  69. package/dist/features/payments/schemas/index.d.ts +12 -12
  70. package/dist/features/products/schemas/index.d.ts +2 -2
  71. package/dist/features/promotions/schemas/index.d.ts +12 -12
  72. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  73. package/dist/features/scams/repository/scammer.repository.js +24 -2
  74. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  75. package/dist/features/scams/schemas/firestore.js +30 -0
  76. package/dist/features/shipments/components/AdminShipmentEditorView.js +2 -1
  77. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  78. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  79. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  80. package/dist/features/shipments/schemas/firestore.js +19 -0
  81. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  82. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  83. package/dist/features/status-history/index.d.ts +2 -0
  84. package/dist/features/status-history/index.js +1 -0
  85. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  86. package/dist/features/stores/repository/store.repository.js +43 -4
  87. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  88. package/dist/features/stores/schemas/firestore.js +31 -0
  89. package/dist/features/support/repository/support.repository.d.ts +47 -3
  90. package/dist/features/support/repository/support.repository.js +72 -12
  91. package/dist/features/support/schemas/firestore.d.ts +38 -1
  92. package/dist/features/support/schemas/firestore.js +34 -0
  93. package/dist/features/support/schemas/index.d.ts +2 -2
  94. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +84 -0
  95. package/dist/index.d.ts +7 -4
  96. package/dist/index.js +15 -2
  97. package/dist/schemas/registry.d.ts +34 -34
  98. package/dist/seed/bids-seed-data.js +8 -0
  99. package/dist/seed/factories/notification.factory.d.ts +2 -1
  100. package/dist/server-entry.d.ts +1 -0
  101. package/dist/server-entry.js +5 -0
  102. package/dist/server.d.ts +5 -1
  103. package/dist/server.js +10 -1
  104. package/dist/styles.css +58 -1
  105. package/dist/tailwind-utilities.css +1 -1
  106. package/dist/ui/components/QuantityStepper.d.ts +38 -0
  107. package/dist/ui/components/QuantityStepper.js +20 -0
  108. package/dist/ui/components/QuantityStepper.style.css +56 -0
  109. package/dist/ui/components/index.style.css +1 -0
  110. package/dist/ui/index.d.ts +2 -0
  111. package/dist/ui/index.js +1 -0
  112. 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";
@@ -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;
@@ -774,8 +774,16 @@ export async function createCheckoutOrderAction(input) {
774
774
  }
775
775
  }
776
776
  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));
777
+ // Clear by LINE identity, not by productId. `itemId` is what uniquely
778
+ // identifies a line; `productId` is not unique across lines and is not
779
+ // even a product id on a bundle/group line (it points at the bundle
780
+ // category, or at the group parent — which is itself purchasable). So
781
+ // matching on it deleted every OTHER line that happened to share the
782
+ // value: a loose "Set" parent sitting beside a group line built from
783
+ // that same group, or two lines of one product, both vanished when only
784
+ // one was ordered.
785
+ const orderedItemIds = new Set(availableItems.map((a) => a.item.itemId));
786
+ const remainingCartItems = cart.items.filter((ci) => !orderedItemIds.has(ci.itemId));
779
787
  const cartRef = db.collection(CART_COLLECTION).doc(uid);
780
788
  tx.set(cartRef, {
781
789
  items: remainingCartItems,
@@ -818,13 +826,13 @@ export async function createCheckoutOrderAction(input) {
818
826
  const orderIds = [];
819
827
  let total = 0;
820
828
  const emailsToSend = [];
821
- const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
829
+ const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
822
830
  const couponUsageAccumulator = new Map();
823
831
  // One commission on one transaction, capped, then apportioned across the
824
832
  // orders this cart splits into — see allocateCheckoutFees.
825
833
  const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
826
834
  g[0].item.storeId,
827
- g.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0),
835
+ g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
828
836
  ]), computeCheckoutFees(cartSubtotal, commissions));
829
837
  const groupCtx = {
830
838
  paymentMethod,
@@ -922,7 +930,7 @@ export async function previewCheckoutPricing(input) {
922
930
  }
923
931
  const checks = cartItems.map((item) => ({ item, product: productById.get(item.productId) ?? null }));
924
932
  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);
933
+ const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
926
934
  // Same re-validation the real order placement runs, so the preview never
927
935
  // shows a discount the order won't actually honour.
928
936
  const { coupons: appliedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], checks, uid);
@@ -932,7 +940,7 @@ export async function previewCheckoutPricing(input) {
932
940
  const stores = [];
933
941
  for (const { items: group } of orderGroups) {
934
942
  const firstItem = group[0].item;
935
- const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
943
+ const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
936
944
  const { shippingFee: groupShippingFee, storeState } = await resolveShippingCost(firstItem.storeId);
937
945
  // Add-ons are per store, read off the cart document — the same entry
938
946
  // createOrderForGroup will read when the order is actually placed.
@@ -948,13 +956,13 @@ export async function previewCheckoutPricing(input) {
948
956
  if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
949
957
  const intraState = !!storeState && storeState === resolvedAddress.state;
950
958
  for (const { item, product } of group) {
951
- const lineTotal = unitPriceFor(item, product) * item.quantity;
959
+ const lineTotal = lineTotalFor(item, product);
952
960
  const rate = product?.gstRate ?? 0;
953
961
  if (rate > 0)
954
962
  groupGstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
955
963
  }
956
964
  }
957
- const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
965
+ const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
958
966
  const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
959
967
  stores.push({
960
968
  storeId: firstItem.storeId,
@@ -1131,12 +1139,12 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
1131
1139
  async function createRazorpayGroupOrder(group, orderType, ctx) {
1132
1140
  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
1141
  const firstItem = group[0].item;
1134
- const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
1142
+ const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
1135
1143
  // Reuses the same store/seller lookup as the COD/UPI path above instead
1136
1144
  // of re-implementing it inline — was two sequential findById calls per
1137
1145
  // seller group here, duplicated from resolveShippingCost.
1138
1146
  const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
1139
- const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
1147
+ const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
1140
1148
  const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
1141
1149
  // Mirrors createOrderForGroup — without this the online-payment path wrote
1142
1150
  // the discount onto the order but never incremented usage.currentUsage or
@@ -1469,7 +1477,7 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1469
1477
  const expectedAddonFees = expectedShippingGroups.reduce((sum, g) => {
1470
1478
  const storeId = g.items[0].item.storeId;
1471
1479
  const addons = cart.storeAddons?.[storeId] ?? {};
1472
- const groupSubtotal = g.items.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0);
1480
+ const groupSubtotal = g.items.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0);
1473
1481
  return (sum +
1474
1482
  computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissionRates) +
1475
1483
  computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
@@ -1501,12 +1509,12 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1501
1509
  let total = 0;
1502
1510
  const emailsToSend = [];
1503
1511
  const cartSubtotal = orderGroups.reduce((s, { items: g }) => s +
1504
- g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
1512
+ g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
1505
1513
  const couponUsageAccumulator = new Map();
1506
1514
  // Same single capped commission + pro-rata split as the COD/UPI path.
1507
1515
  const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
1508
1516
  g[0].item.storeId,
1509
- g.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0),
1517
+ g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
1510
1518
  ]), computeCheckoutFees(cartSubtotal, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS));
1511
1519
  for (const { items: group, orderType } of orderGroups) {
1512
1520
  total += await createRazorpayGroupOrder(group, orderType, {
@@ -237,7 +237,10 @@ export async function finalizeLockedLines(items, orderId) {
237
237
  }
238
238
  if (item.bidId) {
239
239
  try {
240
- await bidRepository.attachOrder(item.bidId, orderId);
240
+ await bidRepository.attachOrder(item.bidId, orderId, {
241
+ actor: { role: "system" },
242
+ trigger: "finalizeLockedLines",
243
+ });
241
244
  }
242
245
  catch (err) {
243
246
  void normalizeError(err);
@@ -270,7 +273,14 @@ async function closeOutBuyoutLosers(productId, winningBidId) {
270
273
  const losers = active.filter((e) => e.id !== winningBidId);
271
274
  if (losers.length === 0)
272
275
  return;
273
- await bidRepository.markManyLost(losers.map((e) => e.ref));
276
+ // Pass the {ref, data} pairs whole, not just the refs — `getActiveByProduct`
277
+ // already returned the documents, so the timeline gets a real before-value
278
+ // at zero read cost.
279
+ await bidRepository.markManyLost(losers, {
280
+ actor: { role: "system" },
281
+ trigger: "finalizeLockedLines:buyoutClosedAuction",
282
+ reason: "A Buy Now purchase ended this auction.",
283
+ });
274
284
  serverLogger.info("finalizeLockedLines: buyout closed out losing bids", {
275
285
  productId,
276
286
  winningBidId,
@@ -27,7 +27,7 @@ import type { StoreDetail, StoreListItem } from "../../../../features/stores/typ
27
27
  */
28
28
  export declare const PUBLIC_STORE_FIELDS: readonly ["id", "storeSlug", "ownerId", "storeName", "storeDescription", "storeCategory", "storeLogoURL", "storeBannerURL", "status", "isPublic", "stats", "bio", "location", "website", "socialLinks", "returnPolicy", "shippingPolicy", "isVacationMode", "vacationMessage", "createdAt"];
29
29
  /** Everything else, with the reason it stays server-side. */
30
- export declare const PRIVATE_STORE_FIELDS: readonly ["updatedAt", "whatsappConfig", "adminNotes", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
30
+ export declare const PRIVATE_STORE_FIELDS: readonly ["updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
31
31
  /**
32
32
  * Accepted input: a `StoreDocument` (the common case) or a loosely-typed
33
33
  * Firestore row. The legacy top-level counters below predate `stats` and are
@@ -29,6 +29,11 @@ export const PRIVATE_STORE_FIELDS = [
29
29
  "updatedAt", // bookkeeping; no public reader
30
30
  "whatsappConfig", // Meta access token + WABA/catalog ids — secret
31
31
  "adminNotes", // schema comment: never shown to the owner or the public
32
+ // Admin decisions with actor UIDs and suspension reasons. The store OWNER
33
+ // sees this on their own admin surfaces; an anonymous visitor to /stores has
34
+ // no business reading who suspended whom and why.
35
+ "statusHistory",
36
+ "statusHistoryTruncated",
32
37
  "isVerified", // admin moderation state; surfaced via a separate badge feed if ever needed
33
38
  "suspensionReason", // admin moderation note
34
39
  "capabilities", // internal feature gating; server-enforced, never client-read
@@ -32,7 +32,11 @@ async function releasePendingBuyouts(ctx, product, holds) {
32
32
  for (const hold of holds) {
33
33
  try {
34
34
  await cartRepository.removeItemsByBidId(hold.data.userId, hold.id);
35
- await bidRepository.markCancelled(hold.id);
35
+ await bidRepository.markCancelled(hold.id, {
36
+ actor: { role: "system" },
37
+ trigger: "auctionSettlement:lapsedBuyoutHold",
38
+ reason: "The auction ended before this Buy Now claim was paid for.",
39
+ });
36
40
  await sendNotification({
37
41
  userId: hold.data.userId,
38
42
  type: "auction_ended",
@@ -84,7 +88,9 @@ async function settleAuction(ctx, product) {
84
88
  const reserve = product.reservePrice;
85
89
  if (typeof reserve === "number" && reserve > 0 && winnerEntry.data.bidAmount < reserve) {
86
90
  const batch = ctx.db.batch();
87
- activeBids.forEach(({ ref }) => bidRepository.markLost(batch, ref));
91
+ // `data` comes from the same query as `ref`, so the timeline entry is
92
+ // free even on an auction that closed with hundreds of bids (Rule #6).
93
+ activeBids.forEach(({ ref, data }) => bidRepository.markLost(batch, ref, { actor: { role: "system" }, trigger: "auctionSettlement" }, data));
88
94
  productRepository.updateStatusInBatch(batch, product.id, "archived");
89
95
  await batch.commit();
90
96
  const reserveStore = product.storeId
@@ -117,8 +123,8 @@ async function settleAuction(ctx, product) {
117
123
  return;
118
124
  }
119
125
  const batch = ctx.db.batch();
120
- bidRepository.markWon(batch, winnerEntry.ref);
121
- loserEntries.forEach(({ ref }) => bidRepository.markLost(batch, ref));
126
+ bidRepository.markWon(batch, winnerEntry.ref, { actor: { role: "system" }, trigger: "auctionSettlement" }, winnerEntry.data);
127
+ loserEntries.forEach(({ ref, data }) => bidRepository.markLost(batch, ref, { actor: { role: "system" }, trigger: "auctionSettlement" }, data));
122
128
  // Two-axis model: mark as sold without changing publish status.
123
129
  batch.update(ctx.db.collection("products").doc(product.id), {
124
130
  isSold: true,
@@ -155,7 +155,11 @@ async function lapseUnpaidAuctionWins(ctx) {
155
155
  for (const { userId, item } of buyoutHolds) {
156
156
  try {
157
157
  await cartRepository.removeItemsByBidId(userId, item.bidId);
158
- await bidRepository.markCancelled(item.bidId);
158
+ await bidRepository.markCancelled(item.bidId, {
159
+ actor: { role: "system" },
160
+ trigger: "runOfferExpiry:buyoutLapsed",
161
+ reason: "The Buy Now checkout window closed without payment.",
162
+ });
159
163
  await sendNotification({
160
164
  userId,
161
165
  type: "auction_ended",
@@ -211,7 +215,11 @@ async function lapseUnpaidAuctionWins(ctx) {
211
215
  for (const { userId, item } of auctionLines) {
212
216
  try {
213
217
  await cartRepository.removeItemsByBidId(userId, item.bidId);
214
- await bidRepository.markForfeited(item.bidId);
218
+ await bidRepository.markForfeited(item.bidId, {
219
+ actor: { role: "system" },
220
+ trigger: "runOfferExpiry:winUnpaid",
221
+ reason: "The 48-hour checkout window closed without payment.",
222
+ });
215
223
  await sendNotification({
216
224
  userId,
217
225
  type: "auction_ended",
@@ -9,7 +9,19 @@ import { payoutRepository } from "../../../../repositories";
9
9
  const MAX_FAILURES = 3;
10
10
  async function dispatch(ctx, entry) {
11
11
  const { ref, data: payout } = entry;
12
- await payoutRepository.markProcessing(ref);
12
+ /*
13
+ * The batch's running copy of the document, threaded through every write.
14
+ *
15
+ * Two reasons it is not just `payout`. (a) The snapshot came from the
16
+ * pending query, so history costs no extra read. (b) The sequence is
17
+ * pending → processing → failed; diffing the final write against the
18
+ * ORIGINAL snapshot would record "pending → failed" and lose the dispatch
19
+ * attempt that is the whole reason anyone opens the timeline.
20
+ */
21
+ let current = payout;
22
+ const actor = { role: "system" };
23
+ await payoutRepository.markProcessing(ref, { actor }, current);
24
+ current = { ...current, status: "processing" };
13
25
  try {
14
26
  const keyId = ctx.env("RAZORPAY_KEY_ID") ?? "";
15
27
  const keySecret = ctx.env("RAZORPAY_KEY_SECRET") ?? "";
@@ -67,7 +79,7 @@ async function dispatch(ctx, entry) {
67
79
  throw new Error(`Razorpay ${response.status}: ${errBody}`);
68
80
  }
69
81
  const result = (await response.json());
70
- await payoutRepository.recordSuccess(ref, result.id, result.status);
82
+ await payoutRepository.recordSuccess(ref, result.id, result.status, { actor }, current);
71
83
  ctx.logger.info(`Payout ${payout.id} dispatched`, { razorpayId: result.id, razorpayStatus: result.status });
72
84
  }
73
85
  catch (error) {
@@ -75,7 +87,7 @@ async function dispatch(ctx, entry) {
75
87
  const failureCount = (payout.failureCount ?? 0) + 1;
76
88
  const isFinal = failureCount >= MAX_FAILURES;
77
89
  const reason = error instanceof Error ? error.message : String(error);
78
- await payoutRepository.recordFailure(ref, failureCount, reason, isFinal);
90
+ await payoutRepository.recordFailure(ref, failureCount, reason, isFinal, { actor }, current);
79
91
  if (isFinal) {
80
92
  ctx.logger.error(`Payout ${payout.id} permanently failed after ${MAX_FAILURES} attempts`, error, {
81
93
  sellerId: payout.storeId,
@@ -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": {
@@ -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
@@ -1,8 +1,8 @@
1
1
  import { roundRupees } from "../../../utils/number.formatter";
2
2
  /**
3
- * The single source of truth for "what do we charge for this line".
3
+ * The single source of truth for "what do we charge for ONE COPY of this line".
4
4
  *
5
- * Three cases, in priority order:
5
+ * Four cases, in priority order:
6
6
  *
7
7
  * 1. **A locked price** — an accepted offer or a won auction. `lockedPrice` is
8
8
  * the negotiated/won amount and it OVERRIDES the current listing price, which
@@ -10,18 +10,33 @@ import { roundRupees } from "../../../utils/number.formatter";
10
10
  * copies of this function looked at `lockedPrice`, so an accepted offer was
11
11
  * billed at the seller's listed price while the cart UI displayed the agreed
12
12
  * one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
13
- * 2. **A bundle line** (SB-UNI-5 2026-05-13) — `item.price` is the bundle price
14
- * locked at add-time.
15
- * 3. **Everything else** — the live Firestore price, so a stale cart-cached
13
+ * 2. **A multi-member line** — the sum of its members' own snapshotted prices,
14
+ * weighted by each member's per-copy quantity. A `"group"` line pins
15
+ * `item.quantity` to 1, so this is both the unit price and the line total;
16
+ * see the invariant on `CartLineMember`.
17
+ * 3. **A legacy bundle line** (SB-UNI-5 2026-05-13) — written before
18
+ * `groupMembers` existed. `item.price` is the bundle price locked at
19
+ * add-time.
20
+ * 4. **Everything else** — the live Firestore price, so a stale cart-cached
16
21
  * price is never charged on a COD/UPI order.
17
22
  *
18
23
  * `product` may be null for a locked line whose listing has since been archived;
19
24
  * the locked price is still owed, so that case must not dereference `product`.
25
+ *
26
+ * 🛑 Do not hand-roll this rule at a call site. It has been re-derived by hand
27
+ * six times, and the two copies that lived in the consumer repo
28
+ * (`src/app/api/payment/create-order/route.ts`) each reproduced the bundle
29
+ * branch while silently omitting case 1 — so Razorpay captured the LIST price
30
+ * for an accepted offer while the cart displayed the negotiated one. Call
31
+ * `lineTotalFor` and let this function stay the only definition.
20
32
  */
21
33
  export function unitPriceFor(item, product) {
22
34
  if (typeof item.lockedPrice === "number" && item.lockedPrice > 0) {
23
35
  return item.lockedPrice;
24
36
  }
37
+ if (item.groupMembers?.length) {
38
+ return roundRupees(item.groupMembers.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0));
39
+ }
25
40
  if (item.bundleCategorySlug && item.bundleProductIds?.length) {
26
41
  return item.price;
27
42
  }
@@ -31,6 +46,14 @@ export function unitPriceFor(item, product) {
31
46
  // crash whenever a cart line outlives its listing.
32
47
  return product?.price ?? item.price;
33
48
  }
49
+ /**
50
+ * What this line contributes to a subtotal: one copy's price × the number of
51
+ * copies. THE call site should use this rather than multiplying `unitPriceFor`
52
+ * itself — that multiplication is where the six hand-rolled copies diverged.
53
+ */
54
+ export function lineTotalFor(item, product) {
55
+ return unitPriceFor(item, product) * item.quantity;
56
+ }
34
57
  /**
35
58
  * P-6 — pre-order groups charge each product's own `preOrderDepositPercent`
36
59
  * (falling back to the generic COD deposit % when a product doesn't have one
@@ -41,7 +64,7 @@ export function unitPriceFor(item, product) {
41
64
  */
42
65
  export function computePreOrderDepositAmount(group, defaultDepositPercent) {
43
66
  const raw = group.reduce((sum, { item, product }) => {
44
- const lineTotal = unitPriceFor(item, product) * item.quantity;
67
+ const lineTotal = lineTotalFor(item, product);
45
68
  const pct = product.preOrderDepositPercent ?? defaultDepositPercent;
46
69
  return sum + lineTotal * (pct / 100); // audit-money-units-ok: percentage divisor, not paise
47
70
  }, 0);
@@ -1,6 +1,16 @@
1
1
  /** Checkout window / capacity constants — single source of truth. */
2
- /** Maximum distinct line items in one cart (hard cap, already enforced at addToCart). */
3
- export declare const CART_MAX_ITEMS = 50;
2
+ /**
3
+ * Maximum distinct line items in one cart (hard cap, enforced at every
4
+ * add-to-cart entry point).
5
+ *
6
+ * Re-exported, not re-declared. This value existed as three independent `= 50`
7
+ * literals — here, in `constants/limits.ts`, and in
8
+ * `_internal/shared/features/cart/config.ts` — with nothing tying them
9
+ * together, so raising the cap in one place would have left two silently
10
+ * disagreeing. `constants/limits.ts` is a dependency-free leaf module, so this
11
+ * costs no import chain.
12
+ */
13
+ export { CART_MAX_ITEMS } from "../../../../constants/limits";
4
14
  /**
5
15
  * Maximum number of order documents that can be created in one checkout
6
16
  * transaction. If the cart produces more groups than this the checkout is
@@ -1,6 +1,16 @@
1
1
  /** Checkout window / capacity constants — single source of truth. */
2
- /** Maximum distinct line items in one cart (hard cap, already enforced at addToCart). */
3
- export const CART_MAX_ITEMS = 50;
2
+ /**
3
+ * Maximum distinct line items in one cart (hard cap, enforced at every
4
+ * add-to-cart entry point).
5
+ *
6
+ * Re-exported, not re-declared. This value existed as three independent `= 50`
7
+ * literals — here, in `constants/limits.ts`, and in
8
+ * `_internal/shared/features/cart/config.ts` — with nothing tying them
9
+ * together, so raising the cap in one place would have left two silently
10
+ * disagreeing. `constants/limits.ts` is a dependency-free leaf module, so this
11
+ * costs no import chain.
12
+ */
13
+ export { CART_MAX_ITEMS } from "../../../../constants/limits";
4
14
  /**
5
15
  * Maximum number of order documents that can be created in one checkout
6
16
  * transaction. If the cart produces more groups than this the checkout is
@@ -1,3 +1,4 @@
1
- export declare const CART_MAX_ITEMS = 50;
1
+ /** Re-exported from the single declaration — see `constants/limits.ts`. */
2
+ export { CART_MAX_ITEMS } from "../../../../constants/limits";
2
3
  export declare const CART_SESSION_TTL_DAYS = 30;
3
4
  export declare const CART_GUEST_STORAGE_KEY = "appkit:cart";
@@ -1,3 +1,4 @@
1
- export const CART_MAX_ITEMS = 50;
1
+ /** Re-exported from the single declaration — see `constants/limits.ts`. */
2
+ export { CART_MAX_ITEMS } from "../../../../constants/limits";
2
3
  export const CART_SESSION_TTL_DAYS = 30;
3
4
  export const CART_GUEST_STORAGE_KEY = "appkit:cart";
@@ -22,9 +22,9 @@ export declare const addToCartSchema: z.ZodObject<{
22
22
  productId: string;
23
23
  productTitle: string;
24
24
  productImage: string;
25
+ isOffer?: boolean | undefined;
25
26
  offerId?: string | undefined;
26
27
  lockedPrice?: number | undefined;
27
- isOffer?: boolean | undefined;
28
28
  }, {
29
29
  storeId: string;
30
30
  price: number;
@@ -35,9 +35,9 @@ export declare const addToCartSchema: z.ZodObject<{
35
35
  storeName?: string | undefined;
36
36
  quantity?: number | undefined;
37
37
  productImage?: string | undefined;
38
+ isOffer?: boolean | undefined;
38
39
  offerId?: string | undefined;
39
40
  lockedPrice?: number | undefined;
40
- isOffer?: boolean | undefined;
41
41
  }>;
42
42
  export declare const updateCartItemSchema: z.ZodObject<{
43
43
  productId: z.ZodString;
@@ -80,9 +80,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
80
80
  productId: string;
81
81
  productTitle: string;
82
82
  productImage: string;
83
+ isOffer?: boolean | undefined;
83
84
  offerId?: string | undefined;
84
85
  lockedPrice?: number | undefined;
85
- isOffer?: boolean | undefined;
86
86
  }, {
87
87
  storeId: string;
88
88
  price: number;
@@ -93,9 +93,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
93
93
  storeName?: string | undefined;
94
94
  quantity?: number | undefined;
95
95
  productImage?: string | undefined;
96
+ isOffer?: boolean | undefined;
96
97
  offerId?: string | undefined;
97
98
  lockedPrice?: number | undefined;
98
- isOffer?: boolean | undefined;
99
99
  }>, "many">;
100
100
  }, "strip", z.ZodTypeAny, {
101
101
  guestItems: {
@@ -108,9 +108,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
108
108
  productId: string;
109
109
  productTitle: string;
110
110
  productImage: string;
111
+ isOffer?: boolean | undefined;
111
112
  offerId?: string | undefined;
112
113
  lockedPrice?: number | undefined;
113
- isOffer?: boolean | undefined;
114
114
  }[];
115
115
  }, {
116
116
  guestItems: {
@@ -123,9 +123,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
123
123
  storeName?: string | undefined;
124
124
  quantity?: number | undefined;
125
125
  productImage?: string | undefined;
126
+ isOffer?: boolean | undefined;
126
127
  offerId?: string | undefined;
127
128
  lockedPrice?: number | undefined;
128
- isOffer?: boolean | undefined;
129
129
  }[];
130
130
  }>;
131
131
  /**