@mohasinac/appkit 4.1.3 → 4.2.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 (54) hide show
  1. package/dist/_internal/server/features/checkout/actions.d.ts +15 -0
  2. package/dist/_internal/server/features/checkout/actions.js +245 -182
  3. package/dist/_internal/server/functions/scheduled.d.ts +2 -1
  4. package/dist/_internal/server/functions/scheduled.js +9 -1
  5. package/dist/_internal/server/jobs/core/jobRunners.js +10 -0
  6. package/dist/_internal/server/jobs/core/newsletterExport.d.ts +15 -0
  7. package/dist/_internal/server/jobs/core/newsletterExport.js +43 -0
  8. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  9. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  10. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  11. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  12. package/dist/_internal/server/jobs/core/revenueRollup.d.ts +13 -0
  13. package/dist/_internal/server/jobs/core/revenueRollup.js +22 -0
  14. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  15. package/dist/_internal/server/jobs/core/whatsappNotify.js +82 -0
  16. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  17. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  18. package/dist/_internal/server/jobs/handlers/revenueRollup.d.ts +2 -0
  19. package/dist/_internal/server/jobs/handlers/revenueRollup.js +2 -0
  20. package/dist/_internal/shared/features/checkout/config.d.ts +7 -0
  21. package/dist/_internal/shared/features/checkout/config.js +7 -0
  22. package/dist/_internal/shared/fees/calculator.d.ts +26 -0
  23. package/dist/_internal/shared/fees/calculator.js +25 -0
  24. package/dist/features/about/components/FeesView.js +18 -0
  25. package/dist/features/admin/actions/notification-actions.d.ts +4 -1
  26. package/dist/features/admin/actions/notification-actions.js +56 -10
  27. package/dist/features/admin/components/AdminAdsView.js +1 -1
  28. package/dist/features/admin/components/AdminNewsletterView.js +42 -11
  29. package/dist/features/admin/components/AdminSiteSettingsView.js +83 -6
  30. package/dist/features/admin/repository/analytics-rollup.repository.d.ts +25 -0
  31. package/dist/features/admin/repository/analytics-rollup.repository.js +31 -0
  32. package/dist/features/admin/repository/site-settings.repository.js +8 -1
  33. package/dist/features/admin/schemas/firestore.d.ts +41 -0
  34. package/dist/features/admin/schemas/firestore.js +62 -24
  35. package/dist/features/layout/BackToTop.d.ts +9 -5
  36. package/dist/features/layout/BackToTop.js +17 -21
  37. package/dist/features/orders/actions/refund-actions.js +1 -0
  38. package/dist/features/orders/schemas/firestore.d.ts +14 -0
  39. package/dist/features/products/constants/action-defs.d.ts +28 -3
  40. package/dist/features/products/constants/action-defs.js +76 -1
  41. package/dist/features/seller/components/SellerOrdersView.js +1 -1
  42. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +10 -1
  43. package/dist/features/whatsapp-bot/helpers/whatsapp.js +47 -0
  44. package/dist/features/whatsapp-bot/types/index.d.ts +19 -0
  45. package/dist/index.d.ts +4 -2
  46. package/dist/index.js +4 -2
  47. package/dist/next/routing/route-map.d.ts +2 -0
  48. package/dist/next/routing/route-map.js +1 -0
  49. package/dist/repositories/index.d.ts +2 -0
  50. package/dist/repositories/index.js +1 -0
  51. package/dist/seed/site-settings-seed-data.js +7 -0
  52. package/dist/server.d.ts +2 -2
  53. package/dist/server.js +2 -2
  54. package/package.json +1 -1
@@ -23,6 +23,14 @@ export interface CreateCheckoutOrderInput {
23
23
  * omits the field.
24
24
  */
25
25
  outOfStockPolicy?: OutOfStockPolicy;
26
+ /** Buyer opted into the ₹10 WhatsApp order-updates addon at checkout. Defaults false (unchecked). Only actually charged/honored when siteSettings.commissions.whatsappNotifyFeeEnabled is also true. */
27
+ whatsappNotifyAddon?: boolean;
28
+ /** Buyer opted into gift wrap at checkout. Defaults false (unchecked). Only actually charged/honored when siteSettings.commissions.giftWrapFeeEnabled is also true. */
29
+ giftWrapAddon?: boolean;
30
+ /** Optional gift message, only meaningful when giftWrapAddon is true. */
31
+ giftWrapMessage?: string;
32
+ /** Buyer opted into shipment protection at checkout. Defaults false (unchecked). Only actually charged/honored when siteSettings.commissions.shipmentProtectionFeeEnabled is also true. */
33
+ shipmentProtectionAddon?: boolean;
26
34
  }
27
35
  /**
28
36
  * Place order(s) from the user's cart in a single Firestore transaction.
@@ -58,5 +66,12 @@ export interface VerifyAndPlaceRazorpayOrderInput {
58
66
  * on the Razorpay path.
59
67
  */
60
68
  outOfStockPolicy?: OutOfStockPolicy;
69
+ /** Buyer opted into the ₹10 WhatsApp order-updates addon at checkout — must match the value sent to /api/payment/create-order so the pre-charged amount and the placed order agree. */
70
+ whatsappNotifyAddon?: boolean;
71
+ /** Buyer opted into gift wrap at checkout — must match the value sent to /api/payment/create-order. */
72
+ giftWrapAddon?: boolean;
73
+ giftWrapMessage?: string;
74
+ /** Buyer opted into shipment protection at checkout — must match the value sent to /api/payment/create-order. */
75
+ shipmentProtectionAddon?: boolean;
61
76
  }
62
77
  export declare function verifyAndPlaceRazorpayOrderAction(input: VerifyAndPlaceRazorpayOrderInput): Promise<CheckoutOrderResult>;
@@ -12,14 +12,14 @@ import { roundRupees } from "../../../../utils/number.formatter";
12
12
  import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../../../errors";
13
13
  import { ORDER_FIELDS } from "../../../../constants/field-names";
14
14
  import { serverLogger } from "../../../../monitoring";
15
- import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, notificationRepository, claimedCouponsRepository, addressesRepository, } from "../../../../repositories";
15
+ import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, } from "../../../../repositories";
16
16
  import { calculateGst } from "../../../shared/fees/calculator";
17
17
  import { computePreOrderDepositAmount } 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";
21
21
  import { resolveDate } from "../../../../utils";
22
- import { computeCodHandlingFee } from "../../../shared/fees/calculator";
22
+ import { computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, } from "../../../shared/fees/calculator";
23
23
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase";
24
24
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore";
25
25
  import { CART_COLLECTION } from "../../../../features/cart/schemas/index";
@@ -112,9 +112,8 @@ function assertLiveJurisdiction(cartItemsArg, productByIdArg, buyerState) {
112
112
  * either party. We emit the buyer + seller rows here at the create boundary.
113
113
  */
114
114
  function emitOrderPlacedNotifications(args) {
115
- const { orderId, buyerUid, buyerName, storeOwnerId, productLabel, paid } = args;
116
- const buyerNotif = notificationRepository
117
- .create({
115
+ const { orderId, buyerUid, buyerName, storeOwnerId, productLabel, paid, whatsappNotifyAddon } = args;
116
+ const buyerNotif = sendNotification({
118
117
  userId: buyerUid,
119
118
  type: "order_placed",
120
119
  priority: "normal",
@@ -123,14 +122,13 @@ function emitOrderPlacedNotifications(args) {
123
122
  relatedId: orderId,
124
123
  relatedType: "order",
125
124
  actionUrl: `/user/orders/view/${orderId}`,
126
- })
127
- .catch((err) => serverLogger.warn("Failed to create buyer order_placed notification", {
125
+ orderWhatsappAddonPaid: whatsappNotifyAddon,
126
+ }).catch((err) => serverLogger.warn("Failed to send buyer order_placed notification", {
128
127
  err: err instanceof Error ? err.message : String(err),
129
128
  orderId,
130
129
  }));
131
130
  const sellerNotif = storeOwnerId
132
- ? notificationRepository
133
- .create({
131
+ ? sendNotification({
134
132
  userId: storeOwnerId,
135
133
  type: "order_placed",
136
134
  priority: "high",
@@ -139,8 +137,7 @@ function emitOrderPlacedNotifications(args) {
139
137
  relatedId: orderId,
140
138
  relatedType: "order",
141
139
  actionUrl: `/store/orders/${orderId}/view`,
142
- })
143
- .catch((err) => serverLogger.warn("Failed to create seller order_placed notification", {
140
+ }).catch((err) => serverLogger.warn("Failed to send seller order_placed notification", {
144
141
  err: err instanceof Error ? err.message : String(err),
145
142
  orderId,
146
143
  }))
@@ -251,7 +248,7 @@ function unitPriceFor(item, product) {
251
248
  * so the caller can accumulate the checkout-wide `total`.
252
249
  */
253
250
  async function createOrderForGroup(group, orderType, ctx) {
254
- const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, } = ctx;
251
+ const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, whatsappNotifyAddon, giftWrapAddon, giftWrapMessage, shipmentProtectionAddon, } = ctx;
255
252
  const firstItem = group[0].item;
256
253
  const firstProduct = group[0].product;
257
254
  const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
@@ -327,6 +324,9 @@ async function createOrderForGroup(group, orderType, ctx) {
327
324
  // Handling fee charged to the buyer for choosing COD, on top of the order
328
325
  // total — not deducted from the deposit/remaining split above.
329
326
  const codHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
327
+ const whatsappNotifyFee = computeWhatsAppNotifyFee(whatsappNotifyAddon, commissions);
328
+ const giftWrapFee = computeGiftWrapFee(giftWrapAddon, commissions);
329
+ const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, commissions);
330
330
  let couponDiscount = 0;
331
331
  const appliedDiscounts = [];
332
332
  const groupCouponCodes = new Set();
@@ -371,7 +371,7 @@ async function createOrderForGroup(group, orderType, ctx) {
371
371
  }
372
372
  }
373
373
  couponDiscount = Math.min(couponDiscount, groupTotal);
374
- const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + (emiSchedule?.surchargeAmount ?? 0) + (gstBreakdown?.gstAmount ?? 0);
374
+ const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + (emiSchedule?.surchargeAmount ?? 0) + (gstBreakdown?.gstAmount ?? 0);
375
375
  const imageUrls = [
376
376
  ...new Set(group
377
377
  .map(({ product }) => product.mainImage)
@@ -428,6 +428,13 @@ async function createOrderForGroup(group, orderType, ctx) {
428
428
  depositAmount: adminBypass ? undefined : depositAmount,
429
429
  codRemainingAmount: adminBypass ? undefined : codRemainingAmount,
430
430
  codHandlingFee: !adminBypass && codHandlingFee > 0 ? codHandlingFee : undefined,
431
+ whatsappNotifyAddon: !adminBypass && whatsappNotifyFee > 0 ? true : undefined,
432
+ whatsappNotifyFee: !adminBypass && whatsappNotifyFee > 0 ? whatsappNotifyFee : undefined,
433
+ giftWrapAddon: !adminBypass && giftWrapFee > 0 ? true : undefined,
434
+ giftWrapFee: !adminBypass && giftWrapFee > 0 ? giftWrapFee : undefined,
435
+ giftWrapMessage: !adminBypass && giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
436
+ shipmentProtectionAddon: !adminBypass && shipmentProtectionFee > 0 ? true : undefined,
437
+ shipmentProtectionFee: !adminBypass && shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
431
438
  taxableAmount: gstBreakdown?.taxableAmount,
432
439
  gstAmount: gstBreakdown?.gstAmount,
433
440
  cgst: gstBreakdown?.cgst || undefined,
@@ -486,6 +493,7 @@ async function createOrderForGroup(group, orderType, ctx) {
486
493
  storeOwnerId,
487
494
  productLabel: orderItems.length > 1 ? `${orderItems.length} items` : firstItem.productTitle,
488
495
  paid: adminBypass,
496
+ whatsappNotifyAddon: !adminBypass && whatsappNotifyFee > 0,
489
497
  });
490
498
  return groupTotal;
491
499
  }
@@ -496,7 +504,7 @@ async function createOrderForGroup(group, orderType, ctx) {
496
504
  * auth + rate-limiting before calling this and supply the resolved user fields.
497
505
  */
498
506
  export async function createCheckoutOrderAction(input) {
499
- const { userId: uid, userName, userEmail, addressId, paymentMethod, emiTenureMonths, notes, excludedProductIds = [], adminBypass = false, adminBypassBy, outOfStockPolicy = OutOfStockPolicyValues.SKIP_ITEMS, } = input;
507
+ const { userId: uid, userName, userEmail, addressId, paymentMethod, emiTenureMonths, notes, excludedProductIds = [], adminBypass = false, adminBypassBy, outOfStockPolicy = OutOfStockPolicyValues.SKIP_ITEMS, whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, } = input;
500
508
  const siteSettings = await siteSettingsRepository.getSingleton();
501
509
  const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
502
510
  const emiSettings = siteSettings?.emi ?? CHECKOUT_DEFAULT_EMI_SETTINGS;
@@ -747,6 +755,10 @@ export async function createCheckoutOrderAction(input) {
747
755
  buyerState: resolvedAddress?.state,
748
756
  gstSettings: siteSettings?.gst,
749
757
  siteContactUpiVpa: siteSettings?.contact?.upiVpa,
758
+ whatsappNotifyAddon,
759
+ giftWrapAddon,
760
+ giftWrapMessage,
761
+ shipmentProtectionAddon,
750
762
  };
751
763
  for (const { items: group, orderType } of orderGroups) {
752
764
  total += await createOrderForGroup(group, orderType, groupCtx);
@@ -872,11 +884,212 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
872
884
  }
873
885
  }
874
886
  }
887
+ /**
888
+ * Places one order for one seller-group within `verifyAndPlaceRazorpayOrderAction`
889
+ * — mirrors `createOrderForGroup`'s role on the COD/cash/EMI path (same
890
+ * per-group fee/coupon/order-doc/notification shape), extracted into its own
891
+ * function purely to keep the parent function under the LARGE_COMPONENT
892
+ * audit threshold (`scripts/audit-code-quality.mjs`). No behavior change
893
+ * from the inline version this replaced.
894
+ */
895
+ async function createRazorpayGroupOrder(group, orderType, ctx) {
896
+ const { appliedCoupons, cartSubtotal, platformFeePercent, gstPercent, whatsappNotifyFee, giftWrapFee, giftWrapMessage, shipmentProtectionAddon, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, } = ctx;
897
+ // SB-UNI-5 2026-05-13 — bundle cart-lines use item.price (locked bundle
898
+ // price); regular lines use product.price. The helper keeps the math
899
+ // local to this block.
900
+ const unitPriceFor = (item, product) => item.bundleCategorySlug && item.bundleProductIds?.length
901
+ ? item.price
902
+ : product.price;
903
+ const firstItem = group[0].item;
904
+ const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
905
+ // Reuses the same store/seller lookup as the COD/UPI path above instead
906
+ // of re-implementing it inline — was two sequential findById calls per
907
+ // seller group here, duplicated from resolveShippingCost.
908
+ const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
909
+ let couponDiscount = 0;
910
+ const appliedDiscounts = [];
911
+ for (const coupon of appliedCoupons) {
912
+ let couponGroupDiscount = 0;
913
+ const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
914
+ if (isSellerScoped) {
915
+ if (coupon.storeId !== firstItem.storeId)
916
+ continue;
917
+ if (coupon.applicableItemIds?.length) {
918
+ const eligibleTotal = group
919
+ .filter(({ item }) => coupon.applicableItemIds.includes(item.itemId))
920
+ .reduce((s, { item, product }) => s + unitPriceFor(item, product) * item.quantity, 0);
921
+ couponGroupDiscount =
922
+ eligibleTotal > 0
923
+ ? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
924
+ : 0;
925
+ }
926
+ else {
927
+ couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
928
+ }
929
+ }
930
+ else if (cartSubtotal > 0) {
931
+ couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
932
+ }
933
+ if (couponGroupDiscount > 0) {
934
+ couponDiscount += couponGroupDiscount;
935
+ appliedDiscounts.push({
936
+ code: coupon.code,
937
+ couponId: coupon.couponId,
938
+ type: "coupon",
939
+ discountAmount: couponGroupDiscount,
940
+ scope: coupon.scope,
941
+ storeId: coupon.storeId,
942
+ });
943
+ }
944
+ }
945
+ couponDiscount = Math.min(couponDiscount, groupTotal);
946
+ const rawPlatformFee = roundRupees(groupTotal * (platformFeePercent / 100));
947
+ const gstOnFee = roundRupees(rawPlatformFee * (gstPercent / 100));
948
+ const platformFee = rawPlatformFee + gstOnFee;
949
+ const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS);
950
+ const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee;
951
+ // P-8 GST — deliberately NOT wired into this Razorpay-verify path. The
952
+ // amount-mismatch check above (expectedPaymentAmountRs) compares against
953
+ // what the buyer already paid via the Razorpay order created earlier in
954
+ // the flow; adding product GST here without also adding it to that
955
+ // upstream pre-payment amount calculation would either fail the mismatch
956
+ // check or silently under/over-charge. Wiring GST through the full
957
+ // Razorpay create→verify round-trip is separate follow-up work, tracked
958
+ // alongside P-13 (Razorpay is disabled by default today, so this order
959
+ // type doesn't currently carry a GST breakdown).
960
+ // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
961
+ const orderItems = group.map(({ item, product }) => {
962
+ const lt = (product?.listingType ?? "standard");
963
+ const itemRule = getListingRule(lt);
964
+ // SB-UNI-5 2026-05-13 — bundle cart-lines surface the locked
965
+ // bundlePrice (item.price), not the representative member's
966
+ // product.price. Stock decrement still runs per member elsewhere.
967
+ const isBundle = Boolean(item.bundleCategorySlug && item.bundleProductIds?.length);
968
+ const unitPrice = isBundle ? item.price : product.price;
969
+ const bundleFields = isBundle
970
+ ? {
971
+ bundleCategorySlug: item.bundleCategorySlug,
972
+ bundleProductIds: item.bundleProductIds,
973
+ }
974
+ : {};
975
+ const baseLine = {
976
+ productId: item.productId,
977
+ productTitle: item.productTitle,
978
+ quantity: item.quantity,
979
+ unitPrice,
980
+ totalPrice: unitPrice * item.quantity,
981
+ ...bundleFields,
982
+ };
983
+ return itemRule.decorateOrderItem(baseLine, product);
984
+ });
985
+ const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
986
+ const imageUrls = [
987
+ ...new Set(group
988
+ .map(({ product }) => product?.mainImage)
989
+ .filter((url) => typeof url === "string" && url.length > 0)),
990
+ ];
991
+ // S-SBUNI-RULES 2026-05-13 — order-doc decoration via rule registry.
992
+ const lt0Rzp = (group[0].product?.listingType ?? "standard");
993
+ const groupRuleRzp = getListingRule(lt0Rzp);
994
+ const extraOrderFields = {
995
+ ...groupRuleRzp.decorateOrderDoc(group[0].item, group[0].product),
996
+ ...(orderType === "prize-draw" && group[0].product
997
+ ? { prizeRevealDeadline: computePrizeRevealDeadline(group[0].product) }
998
+ : {}),
999
+ };
1000
+ const order = await unitOfWork.orders.create({
1001
+ productId: firstItem.productId,
1002
+ productTitle: firstItem.productTitle,
1003
+ userId: uid,
1004
+ userName,
1005
+ userEmail,
1006
+ quantity: totalQuantity,
1007
+ unitPrice: group[0].product.price,
1008
+ totalPrice: orderTotal,
1009
+ currency: firstItem.currency ?? getDefaultCurrency(),
1010
+ storeId: firstItem.storeId || undefined,
1011
+ storeName: firstItem.storeName || undefined,
1012
+ items: orderItems,
1013
+ orderType,
1014
+ offerId: firstItem.offerId ?? undefined,
1015
+ status: OrderStatusValues.CONFIRMED,
1016
+ paymentStatus: PaymentStatusValues.PAID,
1017
+ paymentMethod: PaymentMethodValues.ONLINE,
1018
+ paymentId: razorpay_payment_id,
1019
+ paymentRecord: {
1020
+ method: "razorpay",
1021
+ transactionId: razorpay_payment_id,
1022
+ amount: orderTotal,
1023
+ paidAt: new Date(),
1024
+ verifiedBy: "razorpay-webhook",
1025
+ verificationMethod: "webhook",
1026
+ gatewayRef: {
1027
+ orderId: razorpay_order_id,
1028
+ paymentId: razorpay_payment_id,
1029
+ signature: razorpay_signature,
1030
+ },
1031
+ },
1032
+ shippingAddress,
1033
+ notes,
1034
+ platformFee,
1035
+ shippingFee: shippingFee > 0 ? shippingFee : undefined,
1036
+ whatsappNotifyAddon: whatsappNotifyFee > 0 ? true : undefined,
1037
+ whatsappNotifyFee: whatsappNotifyFee > 0 ? whatsappNotifyFee : undefined,
1038
+ giftWrapAddon: giftWrapFee > 0 ? true : undefined,
1039
+ giftWrapFee: giftWrapFee > 0 ? giftWrapFee : undefined,
1040
+ giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
1041
+ shipmentProtectionAddon: shipmentProtectionFee > 0 ? true : undefined,
1042
+ shipmentProtectionFee: shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
1043
+ couponCode: appliedDiscounts[0]?.code,
1044
+ couponDiscount: couponDiscount > 0 ? couponDiscount : undefined,
1045
+ appliedDiscounts: appliedDiscounts.length > 0 ? appliedDiscounts : undefined,
1046
+ imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
1047
+ outOfStockPolicy,
1048
+ droppedItems: unavailablePaid.length > 0 ? unavailablePaid : undefined,
1049
+ ...extraOrderFields,
1050
+ });
1051
+ orderIds.push(order.id);
1052
+ // SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
1053
+ // order is already persisted so a claim failure is logged, not thrown).
1054
+ if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
1055
+ claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid, {
1056
+ userEmail: userEmail || undefined,
1057
+ userName,
1058
+ productTitle: firstItem.productTitle,
1059
+ }).catch((e) => serverLogger.error("claimDigitalCode", e));
1060
+ }
1061
+ if (userEmail) {
1062
+ emailsToSend.push({
1063
+ to: userEmail,
1064
+ userName,
1065
+ orderId: order.id,
1066
+ productTitle: orderItems.length > 1 ? `${orderItems.length} items` : firstItem.productTitle,
1067
+ quantity: totalQuantity,
1068
+ totalPrice: orderTotal,
1069
+ currency: firstItem.currency ?? getDefaultCurrency(),
1070
+ shippingAddress,
1071
+ paymentMethod: PaymentMethodValues.ONLINE,
1072
+ items: orderItems,
1073
+ });
1074
+ }
1075
+ emitOrderPlacedNotifications({
1076
+ orderId: order.id,
1077
+ buyerUid: uid,
1078
+ buyerName: userName,
1079
+ storeOwnerId,
1080
+ productLabel: orderItems.length > 1 ? `${orderItems.length} items` : firstItem.productTitle,
1081
+ paid: true,
1082
+ whatsappNotifyAddon: whatsappNotifyFee > 0,
1083
+ });
1084
+ return orderTotal;
1085
+ }
875
1086
  export async function verifyAndPlaceRazorpayOrderAction(input) {
876
- const { userId: uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, addressId, notes, outOfStockPolicy = OutOfStockPolicyValues.CANCEL_ORDER, } = input;
1087
+ const { userId: uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, addressId, notes, outOfStockPolicy = OutOfStockPolicyValues.CANCEL_ORDER, whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, } = input;
877
1088
  const siteSettings = await siteSettingsRepository.getSingleton();
878
1089
  const platformFeePercent = siteSettings?.commissions?.platformFeePercent ?? 5;
879
1090
  const gstPercent = siteSettings?.commissions?.gstPercent ?? 18;
1091
+ const whatsappNotifyFee = computeWhatsAppNotifyFee(whatsappNotifyAddon, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS);
1092
+ const giftWrapFee = computeGiftWrapFee(giftWrapAddon, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS);
880
1093
  const isValid = await verifyPaymentSignatureWithKeys({
881
1094
  razorpay_order_id,
882
1095
  razorpay_payment_id,
@@ -1051,178 +1264,28 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1051
1264
  const cartSubtotal = orderGroups.reduce((s, { items: g }) => s +
1052
1265
  g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
1053
1266
  for (const { items: group, orderType } of orderGroups) {
1054
- const firstItem = group[0].item;
1055
- const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
1056
- // Reuses the same store/seller lookup as the COD/UPI path above instead
1057
- // of re-implementing it inline — was two sequential findById calls per
1058
- // seller group here, duplicated from resolveShippingCost.
1059
- const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
1060
- let couponDiscount = 0;
1061
- const appliedDiscounts = [];
1062
- for (const coupon of appliedCoupons) {
1063
- let couponGroupDiscount = 0;
1064
- const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
1065
- if (isSellerScoped) {
1066
- if (coupon.storeId !== firstItem.storeId)
1067
- continue;
1068
- if (coupon.applicableItemIds?.length) {
1069
- const eligibleTotal = group
1070
- .filter(({ item }) => coupon.applicableItemIds.includes(item.itemId))
1071
- .reduce((s, { item, product }) => s + unitPriceFor(item, product) * item.quantity, 0);
1072
- couponGroupDiscount =
1073
- eligibleTotal > 0
1074
- ? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
1075
- : 0;
1076
- }
1077
- else {
1078
- couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
1079
- }
1080
- }
1081
- else if (cartSubtotal > 0) {
1082
- couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
1083
- }
1084
- if (couponGroupDiscount > 0) {
1085
- couponDiscount += couponGroupDiscount;
1086
- appliedDiscounts.push({
1087
- code: coupon.code,
1088
- couponId: coupon.couponId,
1089
- type: "coupon",
1090
- discountAmount: couponGroupDiscount,
1091
- scope: coupon.scope,
1092
- storeId: coupon.storeId,
1093
- });
1094
- }
1095
- }
1096
- couponDiscount = Math.min(couponDiscount, groupTotal);
1097
- const rawPlatformFee = roundRupees(groupTotal * (platformFeePercent / 100));
1098
- const gstOnFee = roundRupees(rawPlatformFee * (gstPercent / 100));
1099
- const platformFee = rawPlatformFee + gstOnFee;
1100
- const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee;
1101
- total += orderTotal;
1102
- // P-8 GST — deliberately NOT wired into this Razorpay-verify path. The
1103
- // amount-mismatch check above (expectedPaymentAmountRs) compares against
1104
- // what the buyer already paid via the Razorpay order created earlier in
1105
- // the flow; adding product GST here without also adding it to that
1106
- // upstream pre-payment amount calculation would either fail the mismatch
1107
- // check or silently under/over-charge. Wiring GST through the full
1108
- // Razorpay create→verify round-trip is separate follow-up work, tracked
1109
- // alongside P-13 (Razorpay is disabled by default today, so this order
1110
- // type doesn't currently carry a GST breakdown).
1111
- // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
1112
- const orderItems = group.map(({ item, product }) => {
1113
- const lt = (product?.listingType ?? "standard");
1114
- const itemRule = getListingRule(lt);
1115
- // SB-UNI-5 2026-05-13 — bundle cart-lines surface the locked
1116
- // bundlePrice (item.price), not the representative member's
1117
- // product.price. Stock decrement still runs per member elsewhere.
1118
- const isBundle = Boolean(item.bundleCategorySlug && item.bundleProductIds?.length);
1119
- const unitPrice = isBundle ? item.price : product.price;
1120
- const bundleFields = isBundle
1121
- ? {
1122
- bundleCategorySlug: item.bundleCategorySlug,
1123
- bundleProductIds: item.bundleProductIds,
1124
- }
1125
- : {};
1126
- const baseLine = {
1127
- productId: item.productId,
1128
- productTitle: item.productTitle,
1129
- quantity: item.quantity,
1130
- unitPrice,
1131
- totalPrice: unitPrice * item.quantity,
1132
- ...bundleFields,
1133
- };
1134
- return itemRule.decorateOrderItem(baseLine, product);
1135
- });
1136
- const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
1137
- const imageUrls = [
1138
- ...new Set(group
1139
- .map(({ product }) => product?.mainImage)
1140
- .filter((url) => typeof url === "string" && url.length > 0)),
1141
- ];
1142
- // S-SBUNI-RULES 2026-05-13 — order-doc decoration via rule registry.
1143
- const lt0Rzp = (group[0].product?.listingType ?? "standard");
1144
- const groupRuleRzp = getListingRule(lt0Rzp);
1145
- const extraOrderFields = {
1146
- ...groupRuleRzp.decorateOrderDoc(group[0].item, group[0].product),
1147
- ...(orderType === "prize-draw" && group[0].product
1148
- ? { prizeRevealDeadline: computePrizeRevealDeadline(group[0].product) }
1149
- : {}),
1150
- };
1151
- const order = await unitOfWork.orders.create({
1152
- productId: firstItem.productId,
1153
- productTitle: firstItem.productTitle,
1154
- userId: uid,
1267
+ total += await createRazorpayGroupOrder(group, orderType, {
1268
+ appliedCoupons,
1269
+ cartSubtotal,
1270
+ platformFeePercent,
1271
+ gstPercent,
1272
+ whatsappNotifyFee,
1273
+ giftWrapFee,
1274
+ giftWrapMessage,
1275
+ shipmentProtectionAddon,
1276
+ siteSettings,
1277
+ uid,
1155
1278
  userName,
1156
1279
  userEmail,
1157
- quantity: totalQuantity,
1158
- unitPrice: group[0].product.price,
1159
- totalPrice: orderTotal,
1160
- currency: firstItem.currency ?? getDefaultCurrency(),
1161
- storeId: firstItem.storeId || undefined,
1162
- storeName: firstItem.storeName || undefined,
1163
- items: orderItems,
1164
- orderType,
1165
- offerId: firstItem.offerId ?? undefined,
1166
- status: OrderStatusValues.CONFIRMED,
1167
- paymentStatus: PaymentStatusValues.PAID,
1168
- paymentMethod: PaymentMethodValues.ONLINE,
1169
- paymentId: razorpay_payment_id,
1170
- paymentRecord: {
1171
- method: "razorpay",
1172
- transactionId: razorpay_payment_id,
1173
- amount: orderTotal,
1174
- paidAt: new Date(),
1175
- verifiedBy: "razorpay-webhook",
1176
- verificationMethod: "webhook",
1177
- gatewayRef: {
1178
- orderId: razorpay_order_id,
1179
- paymentId: razorpay_payment_id,
1180
- signature: razorpay_signature,
1181
- },
1182
- },
1280
+ razorpay_order_id,
1281
+ razorpay_payment_id,
1282
+ razorpay_signature,
1183
1283
  shippingAddress,
1184
1284
  notes,
1185
- platformFee,
1186
- shippingFee: shippingFee > 0 ? shippingFee : undefined,
1187
- couponCode: appliedDiscounts[0]?.code,
1188
- couponDiscount: couponDiscount > 0 ? couponDiscount : undefined,
1189
- appliedDiscounts: appliedDiscounts.length > 0 ? appliedDiscounts : undefined,
1190
- imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
1191
1285
  outOfStockPolicy,
1192
- droppedItems: unavailablePaid.length > 0 ? unavailablePaid : undefined,
1193
- ...extraOrderFields,
1194
- });
1195
- orderIds.push(order.id);
1196
- // SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
1197
- // order is already persisted so a claim failure is logged, not thrown).
1198
- if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
1199
- claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid, {
1200
- userEmail: userEmail || undefined,
1201
- userName,
1202
- productTitle: firstItem.productTitle,
1203
- }).catch((e) => serverLogger.error("claimDigitalCode", e));
1204
- }
1205
- if (userEmail) {
1206
- emailsToSend.push({
1207
- to: userEmail,
1208
- userName,
1209
- orderId: order.id,
1210
- productTitle: orderItems.length > 1 ? `${orderItems.length} items` : firstItem.productTitle,
1211
- quantity: totalQuantity,
1212
- totalPrice: orderTotal,
1213
- currency: firstItem.currency ?? getDefaultCurrency(),
1214
- shippingAddress,
1215
- paymentMethod: PaymentMethodValues.ONLINE,
1216
- items: orderItems,
1217
- });
1218
- }
1219
- emitOrderPlacedNotifications({
1220
- orderId: order.id,
1221
- buyerUid: uid,
1222
- buyerName: userName,
1223
- storeOwnerId,
1224
- productLabel: orderItems.length > 1 ? `${orderItems.length} items` : firstItem.productTitle,
1225
- paid: true,
1286
+ unavailablePaid,
1287
+ orderIds,
1288
+ emailsToSend,
1226
1289
  });
1227
1290
  }
1228
1291
  // SB-UNI-5 2026-05-13 — bundle-aware batch decrement. We iterate the
@@ -6,6 +6,7 @@ export declare const paymentReviewAutoApprove: import("./types").ScheduledFuncti
6
6
  export declare const couponExpiry: import("./types").ScheduledFunctionDefinition;
7
7
  export declare const offerExpiry: import("./types").ScheduledFunctionDefinition;
8
8
  export declare const productStatsSync: import("./types").ScheduledFunctionDefinition;
9
+ export declare const revenueRollup: import("./types").ScheduledFunctionDefinition;
9
10
  export declare const dailyDataCleanup: import("./types").ScheduledFunctionDefinition;
10
11
  export declare const countersReconcile: import("./types").ScheduledFunctionDefinition;
11
12
  export declare const positionsReconcile: import("./types").ScheduledFunctionDefinition;
@@ -25,4 +26,4 @@ export declare const prizeRevealReminder: import("./types").ScheduledFunctionDef
25
26
  export declare const bundleStockSync: import("./types").ScheduledFunctionDefinition;
26
27
  export declare const emiInstallmentReminder: import("./types").ScheduledFunctionDefinition;
27
28
  export declare const catalogueImageStalenessReminder: import("./types").ScheduledFunctionDefinition;
28
- export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
29
+ export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
@@ -1,6 +1,6 @@
1
1
  // Scheduled function definitions (Cloud Scheduler triggers).
2
2
  // Cron syntax accepts both Firebase shorthand and standard cron expressions.
3
- import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, payoutBatchHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeRevealCloseHandler, prizeRevealExpiryHandler, prizeRevealOpenHandler, prizeRevealReminderHandler, productStatsSyncHandler, weeklyPayoutEligibilityHandler, testerSandboxCleanupHandler, } from "../jobs/handlers";
3
+ import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, payoutBatchHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeRevealCloseHandler, prizeRevealExpiryHandler, prizeRevealOpenHandler, prizeRevealReminderHandler, productStatsSyncHandler, revenueRollupHandler, weeklyPayoutEligibilityHandler, testerSandboxCleanupHandler, } from "../jobs/handlers";
4
4
  import { defineFunction } from "./define";
5
5
  const REGION = "asia-south1";
6
6
  const EVERY_5_MIN = "every 5 minutes";
@@ -60,6 +60,13 @@ export const productStatsSync = defineFunction({
60
60
  handler: productStatsSyncHandler,
61
61
  options: { region: REGION, timeoutSeconds: 540, memory: "256MiB", maxInstances: 1 },
62
62
  });
63
+ export const revenueRollup = defineFunction({
64
+ name: "revenueRollup",
65
+ description: "Pre-compute total delivered-order revenue into analytics/dashboardRollup, replacing an unbounded per-request scan (daily 01:30 UTC).",
66
+ trigger: { kind: "schedule", cron: "30 1 * * *", timeZone: "UTC" },
67
+ handler: revenueRollupHandler,
68
+ options: { region: REGION, timeoutSeconds: 300, memory: "256MiB", maxInstances: 1 },
69
+ });
63
70
  export const dailyDataCleanup = defineFunction({
64
71
  name: "dailyDataCleanup",
65
72
  description: "Daily data cleanup (drafts, transient records) at 02:00 UTC.",
@@ -202,6 +209,7 @@ export const SCHEDULED_FUNCTIONS = [
202
209
  couponExpiry,
203
210
  offerExpiry,
204
211
  productStatsSync,
212
+ revenueRollup,
205
213
  dailyDataCleanup,
206
214
  countersReconcile,
207
215
  positionsReconcile,
@@ -11,6 +11,8 @@
11
11
  import { runWeeklyPayoutEligibility } from "./weeklyPayoutEligibility";
12
12
  import { runHardBanCascade } from "./hardBanCascade";
13
13
  import { runResetOtpVerification } from "./resetOtpVerification";
14
+ import { runWhatsAppNotify } from "./whatsappNotify";
15
+ import { runNewsletterExport } from "./newsletterExport";
14
16
  async function runPayoutsWeeklyJob(_payload, ctx) {
15
17
  const { payoutsCreated, ordersProcessed, payoutIds } = await runWeeklyPayoutEligibility(ctx);
16
18
  return {
@@ -37,8 +39,16 @@ async function runHardBanCascadeJob(payload, ctx) {
37
39
  async function runResetOtpVerificationJob(_payload, ctx) {
38
40
  return runResetOtpVerification(ctx);
39
41
  }
42
+ async function runWhatsAppNotifyJob(payload, ctx) {
43
+ return runWhatsAppNotify(payload, ctx);
44
+ }
45
+ async function runNewsletterExportJob(_payload, ctx) {
46
+ return runNewsletterExport(ctx);
47
+ }
40
48
  export const JOB_RUNNERS = {
41
49
  payoutsWeekly: runPayoutsWeeklyJob,
42
50
  hardBanCascade: runHardBanCascadeJob,
43
51
  resetOtpVerification: runResetOtpVerificationJob,
52
+ whatsappNotify: runWhatsAppNotifyJob,
53
+ newsletterExport: runNewsletterExportJob,
44
54
  };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Core: async newsletter-subscriber CSV export (`newsletterExport` job).
3
+ *
4
+ * `GET /api/admin/newsletter/export` used to build the full CSV in-process,
5
+ * synchronously, for up to 10,000 subscribers — a real 10s-timeout risk on
6
+ * Vercel Hobby as the list grows (CLAUDE.md Rule #6). This runner does the
7
+ * same work inside a Firebase Function instead and writes the CSV text onto
8
+ * the job's `result.data.csv` field — small enough for a Firestore doc at
9
+ * realistic subscriber counts, and avoids routing through Storage (which
10
+ * would need a `/media/...` proxy URL per the Media Architecture rules
11
+ * rather than a raw signed URL).
12
+ */
13
+ import type { JobContext } from "../runtime/types";
14
+ import type { JobRunResult } from "./jobRunners";
15
+ export declare function runNewsletterExport(ctx: JobContext): Promise<JobRunResult>;