@mohasinac/appkit 4.63.4 → 4.63.5

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 (136) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  3. package/dist/_internal/server/features/checkout/actions.js +22 -5
  4. package/dist/_internal/server/features/orders/adapters.js +3 -1
  5. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  6. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  7. package/dist/_internal/server/features/stores/adapters.js +1 -0
  8. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  9. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  10. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  11. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  12. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  13. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  14. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  15. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  16. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  17. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  18. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  19. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  20. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  21. package/dist/_internal/server/notifications/channel-health.js +2 -2
  22. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  23. package/dist/_internal/server/notifications/send-guard.js +4 -2
  24. package/dist/_internal/shared/actions/action-registry.js +28 -0
  25. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  26. package/dist/_internal/shared/checkout/order-math.js +1 -1
  27. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  28. package/dist/_internal/shared/features/checkout/config.js +2 -0
  29. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  30. package/dist/_internal/shared/features/messaging/config.js +4 -1
  31. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  32. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  33. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  34. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  35. package/dist/_internal/shared/fees/calculator.js +7 -0
  36. package/dist/cli/index.js +2 -0
  37. package/dist/client.d.ts +2 -2
  38. package/dist/client.js +1 -1
  39. package/dist/constants/api-endpoints.d.ts +15 -0
  40. package/dist/constants/api-endpoints.js +8 -0
  41. package/dist/constants/notification-types.d.ts +1 -1
  42. package/dist/constants/notification-types.js +1 -1
  43. package/dist/core/integration-keys.d.ts +5 -0
  44. package/dist/core/integration-keys.js +10 -0
  45. package/dist/features/about/components/FeesView.js +6 -0
  46. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  47. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  48. package/dist/features/account/types/index.d.ts +2 -0
  49. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  50. package/dist/features/admin/actions/notification-actions.js +154 -9
  51. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  52. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  53. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  54. package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
  55. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  56. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  57. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  58. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  59. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  60. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  61. package/dist/features/admin/schemas/firestore.js +8 -3
  62. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  63. package/dist/features/auth/checkout-value-otp.js +1 -1
  64. package/dist/features/auth/permissions/constants.d.ts +2 -2
  65. package/dist/features/auth/permissions/constants.js +2 -1
  66. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  67. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  68. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  69. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  70. package/dist/features/cart/repository/cart.repository.js +2 -1
  71. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  72. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  73. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  74. package/dist/features/homepage/lib/section-renderer.js +1 -1
  75. package/dist/features/orders/actions/refund-actions.js +1 -0
  76. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  77. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  78. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  79. package/dist/features/orders/repository/orders.repository.js +2 -0
  80. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  81. package/dist/features/orders/types/index.d.ts +5 -2
  82. package/dist/features/search/action-index/settings-entries.js +9 -0
  83. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  84. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  85. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  86. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  87. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
  88. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  89. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  90. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  91. package/dist/features/stores/repository/store.repository.js +29 -1
  92. package/dist/features/stores/schemas/firestore.d.ts +16 -6
  93. package/dist/features/stores/schemas/firestore.js +3 -6
  94. package/dist/features/stores/schemas/index.d.ts +86 -0
  95. package/dist/features/stores/schemas/index.js +14 -0
  96. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  97. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  98. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  99. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  100. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  101. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  102. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  103. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  104. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  105. package/dist/features/whatsapp-bot/components/index.js +2 -0
  106. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  107. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  108. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  109. package/dist/features/whatsapp-bot/index.js +3 -0
  110. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  111. package/dist/features/whatsapp-bot/manifest.js +13 -0
  112. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  113. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  114. package/dist/features/whatsapp-bot/server.js +7 -0
  115. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  116. package/dist/features/whatsapp-bot/types/index.js +1 -0
  117. package/dist/index.d.ts +23 -6
  118. package/dist/index.js +38 -12
  119. package/dist/next/routing/route-map.d.ts +3 -0
  120. package/dist/next/routing/route-map.js +1 -0
  121. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  122. package/dist/providers/db-firebase/base.repository.js +1 -1
  123. package/dist/repositories/index.d.ts +1 -1
  124. package/dist/repositories/index.js +1 -1
  125. package/dist/schemas/registry.d.ts +105 -0
  126. package/dist/schemas/registry.js +2 -1
  127. package/dist/security/pii-encrypt.js +1 -1
  128. package/dist/server.d.ts +10 -2
  129. package/dist/server.js +22 -2
  130. package/dist/styles.css +1 -1
  131. package/dist/tailwind-utilities.css +1 -1
  132. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  133. package/dist/ui/components/NavigationLoader.js +79 -30
  134. package/package.json +1 -1
  135. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  136. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.63.4",
4
- "head": "75f5bc368ad0433147e270c708aed152c9952ce0",
3
+ "version": "4.63.5",
4
+ "head": "6eff0d768f87f31fafd2ca5a5af8868622f94228",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-23T12:57:38.033Z"
7
+ "builtAt": "2026-10-23T20:06:38.671Z"
8
8
  }
@@ -226,6 +226,7 @@ export interface CheckoutPricingPreviewStore {
226
226
  subtotal: number;
227
227
  shippingFee: number;
228
228
  codHandlingFee: number;
229
+ whatsappNotifyFee: number;
229
230
  giftWrapFee: number;
230
231
  shipmentProtectionFee: number;
231
232
  saleProtectionFee: number;
@@ -294,6 +295,7 @@ export interface CheckoutPricingPreview {
294
295
  subtotal: number;
295
296
  shippingFee: number;
296
297
  codHandlingFee: number;
298
+ whatsappNotifyFee: number;
297
299
  giftWrapFee: number;
298
300
  shipmentProtectionFee: number;
299
301
  saleProtectionFee: number;
@@ -48,7 +48,7 @@ import { countDestinations, quoteSharedPurchase, } from "../../../shared/feature
48
48
  import { resolveCouponCategorySlugs } from "../../../../features/promotions/actions/coupon-actions.js";
49
49
  import { assertCheckoutLane, assertLockedLinesStillValid, finalizeLockedLines, } from "./locked-lines.js";
50
50
  import { activeLane, defaultCheckoutItems, resolveCheckoutItems, laneOf, } from "../../../shared/checkout/lanes.js";
51
- import { computeCodHandlingFee, computeGiftWrapFee, computeShipmentProtectionFee, computeSaleProtectionFee, computeCheckoutFees, allocateCheckoutFees, } from "../../../shared/fees/calculator.js";
51
+ import { computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, computeSaleProtectionFee, computeCheckoutFees, allocateCheckoutFees, } from "../../../shared/fees/calculator.js";
52
52
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase/index.js";
53
53
  import { snapshotDeliveryAddress, } from "../../../shared/features/orders/delivery-snapshot.js";
54
54
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore.js";
@@ -192,7 +192,7 @@ function assertLiveJurisdiction(cartItemsArg, productByIdArg, buyerState) {
192
192
  * either party. We emit the buyer + seller rows here at the create boundary.
193
193
  */
194
194
  function emitOrderPlacedNotifications(args) {
195
- const { orderId, buyerUid, buyerName, storeOwnerId, productLabel, paid } = args;
195
+ const { orderId, buyerUid, buyerName, storeOwnerId, productLabel, paid, storeId } = args;
196
196
  const buyerNotif = sendNotification({
197
197
  userId: buyerUid,
198
198
  type: "order_placed",
@@ -202,6 +202,7 @@ function emitOrderPlacedNotifications(args) {
202
202
  relatedId: orderId,
203
203
  relatedType: "order",
204
204
  actionUrl: `/user/orders/view/${orderId}`,
205
+ storeId,
205
206
  }).catch((err) => serverLogger.warn("Failed to send buyer order_placed notification", {
206
207
  err: err instanceof Error ? err.message : String(err),
207
208
  orderId,
@@ -740,7 +741,7 @@ async function createOrderForGroup(group, orderType, ctx) {
740
741
  const firstItem = group[0].item;
741
742
  const firstProduct = group[0].product;
742
743
  // Add-ons are this store's choice, not the cart's — see OrderGroupContext.
743
- const { giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, saleProtectionAddon = false, } = storeAddons?.[firstItem.storeId] ?? {};
744
+ const { whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, saleProtectionAddon = false, } = storeAddons?.[firstItem.storeId] ?? {};
744
745
  const { platformFee = 0, gstOnFee: platformFeeGst = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
745
746
  const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
746
747
  /*
@@ -873,6 +874,7 @@ async function createOrderForGroup(group, orderType, ctx) {
873
874
  // Handling fee charged to the buyer for choosing COD, on top of the order
874
875
  // total — not deducted from the deposit/remaining split above.
875
876
  const codHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
877
+ const whatsappNotifyFee = computeWhatsAppNotifyFee(whatsappNotifyAddon, commissions);
876
878
  const giftWrapFee = computeGiftWrapFee(giftWrapAddon, commissions);
877
879
  /*
878
880
  * ── C5e: all three protection tiers, enforced SERVER-SIDE ────────────────
@@ -1052,7 +1054,7 @@ async function createOrderForGroup(group, orderType, ctx) {
1052
1054
  * compounds nine half-paisa errors instead of one, which is how a total
1053
1055
  * drifts from the sum of the lines shown beside it.
1054
1056
  */
1055
- roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0) + sharedQuote.total);
1057
+ roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0) + sharedQuote.total);
1056
1058
  /*
1057
1059
  * ── D43: the COD deposit/remaining split, on the ORDER TOTAL ──────────────
1058
1060
  *
@@ -1263,6 +1265,8 @@ async function createOrderForGroup(group, orderType, ctx) {
1263
1265
  * one so a later reader does not have to reconstruct it.
1264
1266
  */
1265
1267
  platformFeeGst: !adminBypass && platformFeeGst > 0 ? platformFeeGst : undefined,
1268
+ whatsappNotifyAddon: !adminBypass && whatsappNotifyFee > 0 ? true : undefined,
1269
+ whatsappNotifyFee: !adminBypass && whatsappNotifyFee > 0 ? whatsappNotifyFee : undefined,
1266
1270
  giftWrapAddon: !adminBypass && giftWrapFee > 0 ? true : undefined,
1267
1271
  giftWrapFee: !adminBypass && giftWrapFee > 0 ? giftWrapFee : undefined,
1268
1272
  giftWrapMessage: !adminBypass && giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
@@ -1405,6 +1409,7 @@ async function createOrderForGroup(group, orderType, ctx) {
1405
1409
  storeOwnerId,
1406
1410
  productLabel: orderItems.length > 1 ? `${orderItems.length} items` : firstItem.productTitle,
1407
1411
  paid: adminBypass,
1412
+ storeId: order.storeId,
1408
1413
  });
1409
1414
  return groupTotal;
1410
1415
  }
@@ -1885,6 +1890,7 @@ const EMPTY_PRICING_PREVIEW = {
1885
1890
  subtotal: 0,
1886
1891
  shippingFee: 0,
1887
1892
  codHandlingFee: 0,
1893
+ whatsappNotifyFee: 0,
1888
1894
  giftWrapFee: 0,
1889
1895
  shipmentProtectionFee: 0,
1890
1896
  saleProtectionFee: 0,
@@ -2029,6 +2035,7 @@ export async function previewCheckoutPricing(input) {
2029
2035
  * rather than one number the buyer has to interpret.
2030
2036
  */
2031
2037
  const groupEmiDeferred = groupEmiPreviewQuote?.remainingBalance ?? 0;
2038
+ const groupWhatsappFee = computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissions);
2032
2039
  const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
2033
2040
  /*
2034
2041
  * C5e — the same three tiers `createOrderForGroup` enforces, so the figure
@@ -2129,6 +2136,7 @@ export async function previewCheckoutPricing(input) {
2129
2136
  codHandlingFee: groupCodHandlingFee,
2130
2137
  emiSurcharge: groupEmiSurcharge,
2131
2138
  emiDeferred: groupEmiDeferred,
2139
+ whatsappNotifyFee: groupWhatsappFee,
2132
2140
  giftWrapFee: groupGiftWrapFee,
2133
2141
  shipmentProtectionFee: groupShipmentProtectionFee,
2134
2142
  saleProtectionFee: groupSaleProtectionFee,
@@ -2147,6 +2155,7 @@ export async function previewCheckoutPricing(input) {
2147
2155
  groupShippingFee +
2148
2156
  groupCodHandlingFee +
2149
2157
  groupEmiSurcharge +
2158
+ groupWhatsappFee +
2150
2159
  groupGiftWrapFee +
2151
2160
  groupShipmentProtectionFee +
2152
2161
  groupSaleProtectionFee +
@@ -2169,6 +2178,7 @@ export async function previewCheckoutPricing(input) {
2169
2178
  const codHandlingFee = sum((s) => s.codHandlingFee);
2170
2179
  const emiSurcharge = sum((s) => s.emiSurcharge);
2171
2180
  const emiDeferred = sum((s) => s.emiDeferred);
2181
+ const whatsappNotifyFeeTotal = sum((s) => s.whatsappNotifyFee);
2172
2182
  const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
2173
2183
  const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
2174
2184
  const saleProtectionFeeTotal = sum((s) => s.saleProtectionFee);
@@ -2216,6 +2226,7 @@ export async function previewCheckoutPricing(input) {
2216
2226
  shippingFee +
2217
2227
  codHandlingFee +
2218
2228
  emiSurcharge +
2229
+ whatsappNotifyFeeTotal +
2219
2230
  giftWrapFeeTotal +
2220
2231
  shipmentProtectionFeeTotal +
2221
2232
  saleProtectionFeeTotal +
@@ -2271,6 +2282,7 @@ export async function previewCheckoutPricing(input) {
2271
2282
  codHandlingFee,
2272
2283
  emiSurcharge,
2273
2284
  emiDeferred,
2285
+ whatsappNotifyFee: whatsappNotifyFeeTotal,
2274
2286
  giftWrapFee: giftWrapFeeTotal,
2275
2287
  shipmentProtectionFee: shipmentProtectionFeeTotal,
2276
2288
  saleProtectionFee: saleProtectionFeeTotal,
@@ -2513,6 +2525,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2513
2525
  // cart-wide fee was passed in and billed against every group.
2514
2526
  const commissionRates = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
2515
2527
  const addons = storeAddons?.[firstItem.storeId] ?? {};
2528
+ const whatsappNotifyFee = computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissionRates);
2516
2529
  const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
2517
2530
  const giftWrapMessage = addons.giftWrapMessage;
2518
2531
  /*
@@ -2617,7 +2630,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2617
2630
  });
2618
2631
  // D42: rounded once on the sum — see the COD/UPI path's 🛑 for why a raw
2619
2632
  // float sum of seven 2dp terms reached Firestore as 1011.8000000000001.
2620
- const orderTotal = roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (phonepeEmiSchedule?.surchargeAmount ?? 0) + phonepeSharedQuote.total);
2633
+ const orderTotal = roundRupees(Math.max(0, groupTotal - couponDiscount) + shippingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst + (phonepeEmiSchedule?.surchargeAmount ?? 0) + phonepeSharedQuote.total);
2621
2634
  /*
2622
2635
  * ── POINTS TENDER (D46/D47) — the same STAGE as the COD/manual path ───────
2623
2636
  *
@@ -2767,6 +2780,8 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2767
2780
  // leaving the refund path to re-derive it from live percentages (D32).
2768
2781
  platformFeeGst: platformFeeGst > 0 ? platformFeeGst : undefined,
2769
2782
  shippingFee: shippingFee > 0 ? shippingFee : undefined,
2783
+ whatsappNotifyAddon: whatsappNotifyFee > 0 ? true : undefined,
2784
+ whatsappNotifyFee: whatsappNotifyFee > 0 ? whatsappNotifyFee : undefined,
2770
2785
  giftWrapAddon: giftWrapFee > 0 ? true : undefined,
2771
2786
  giftWrapFee: giftWrapFee > 0 ? giftWrapFee : undefined,
2772
2787
  giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
@@ -2864,6 +2879,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
2864
2879
  storeOwnerId,
2865
2880
  productLabel: orderItems.length > 1 ? `${orderItems.length} items` : firstItem.productTitle,
2866
2881
  paid: true,
2882
+ storeId: order.storeId,
2867
2883
  });
2868
2884
  return orderTotal;
2869
2885
  }
@@ -3282,6 +3298,7 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3282
3298
  const addons = cart.storeAddons?.[storeId] ?? {};
3283
3299
  const groupSubtotal = g.items.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0);
3284
3300
  return (sum +
3301
+ computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissionRates) +
3285
3302
  computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
3286
3303
  computeShipmentProtectionFee(groupSubtotal, addons.shipmentProtectionAddon ?? false, commissionRates));
3287
3304
  }, 0);
@@ -185,7 +185,7 @@ export function orderDocumentToOrder(doc) {
185
185
  couponDiscount: doc.couponDiscount,
186
186
  appliedDiscounts: doc.appliedDiscounts,
187
187
  // Add-ons are operational, not decorative: the status-change notifier reads
188
- // the packer needs giftWrapMessage in hand. An
188
+ // whatsappNotifyAddon, and the packer needs giftWrapMessage in hand. An
189
189
  // adapter that drops them makes every downstream surface render its
190
190
  // "not applicable" fallback instead (Root Cause #57).
191
191
  // EMI, so the buyer can see and pay their own schedule. Mapped with the
@@ -195,6 +195,8 @@ export function orderDocumentToOrder(doc) {
195
195
  emiEnabled: doc.emiEnabled,
196
196
  emiInstallments: doc.emiInstallments,
197
197
  emiRemainingBalance: doc.emiRemainingBalance,
198
+ whatsappNotifyAddon: doc.whatsappNotifyAddon,
199
+ whatsappNotifyFee: doc.whatsappNotifyFee,
198
200
  giftWrapAddon: doc.giftWrapAddon,
199
201
  giftWrapFee: doc.giftWrapFee,
200
202
  giftWrapMessage: doc.giftWrapMessage,
@@ -7,7 +7,7 @@ export const PUBLIC_SITE_SETTINGS_FIELDS = [
7
7
  "contact", // user/orders/[id]/payment — contact.whatsappNumber (narrowed: no address/upiVpa)
8
8
  "payment", // CheckoutRouteClient — payment.otpCheckoutThreshold (narrowed)
9
9
  "listings", // useListingTypeFlags — narrowed to listings.listingTypes; categoryTypes has no client reader
10
- "notificationChannels", // NotificationPreferencesPanel reads the per-channel toggles
10
+ "notificationChannels", // NARROWED: only {email,whatsapp,sms}.enabled — see the projection below
11
11
  "announcementBar", // the homepage banner (AdminSiteSettingsView edits it)
12
12
  /*
13
13
  * `support.enabled` — a REAL client reader, which is the bar this list sets:
@@ -138,7 +138,25 @@ export function toPublicSiteSettings(doc, opts) {
138
138
  listings: {
139
139
  listingTypes: doc.listings?.listingTypes,
140
140
  },
141
- notificationChannels: doc.notificationChannels,
141
+ /*
142
+ * 🛑 Narrowed to the three booleans a client reads, not the group by
143
+ * reference. Emitted whole, this published every WhatsApp channel setting
144
+ * to anonymous callers — `minPriority`, and the `types[]` allow-list naming
145
+ * exactly which notification types each channel carries. Nothing reads
146
+ * those in a browser: `NotificationPreferencesPanel` wants the three
147
+ * `enabled` flags to label its toggles, `whatsapp.otpEnabled` is read by a
148
+ * SERVER action, and the admin editor reads `GET /api/admin/site`.
149
+ *
150
+ * Same reasoning as `support` below — a field added to a channel later must
151
+ * not be published by the act of adding it (Root Cause #70).
152
+ */
153
+ notificationChannels: doc.notificationChannels
154
+ ? {
155
+ email: { enabled: doc.notificationChannels.email?.enabled === true },
156
+ whatsapp: { enabled: doc.notificationChannels.whatsapp?.enabled === true },
157
+ sms: { enabled: doc.notificationChannels.sms?.enabled === true },
158
+ }
159
+ : undefined,
142
160
  announcementBar: doc.announcementBar,
143
161
  // Narrowed to the one boolean a client reads, not the group by reference —
144
162
  // so a field added to `support` later is not published by accident.
@@ -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", "supportsShippingProtection", "supportsSaleProtection", "allowSharedPurchase", "sharedPurchaseHandlingFee", "sharedPurchaseExtraShippingPerDestination", "createdAt"];
29
29
  /** Everything else, with the reason it stays server-side. */
30
- export declare const PRIVATE_STORE_FIELDS: readonly ["trustedSeller", "acceptsPoints", "searchTxt", "updatedAt", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "pricesIncludeGst", "shippingPricing", "returnsDirectToSeller", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt", "gstin", "legalName", "registeredAddress"];
30
+ export declare const PRIVATE_STORE_FIELDS: readonly ["trustedSeller", "acceptsPoints", "searchTxt", "updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "pricesIncludeGst", "shippingPricing", "returnsDirectToSeller", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt", "gstin", "legalName", "registeredAddress"];
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
@@ -95,6 +95,7 @@ export const PRIVATE_STORE_FIELDS = [
95
95
  // hundred tokens.
96
96
  "searchTxt",
97
97
  "updatedAt", // bookkeeping; no public reader
98
+ "whatsappConfig", // Meta access token + WABA/catalog ids — secret
98
99
  "adminNotes", // schema comment: never shown to the owner or the public
99
100
  // Admin decisions with actor UIDs and suspension reasons. The store OWNER
100
101
  // sees this on their own admin surfaces; an anonymous visitor to /stores has
@@ -299,7 +299,7 @@ function buildMetricCache(ctx, bucket, now, onSaturated) {
299
299
  * The owner's contact details, resolved once per owner.
300
300
  *
301
301
  * 🛑 This is how the seller's `notifyChannels` is HONOURED rather than
302
- * ignored. `sendNotification` decides email eligibility itself
302
+ * ignored. `sendNotification` decides email and WhatsApp eligibility itself
303
303
  * (type gate → admin allow-list → user prefs → daily budget) and takes no
304
304
  * channel list — but it cannot email an address it was not given. So omitting
305
305
  * `userEmail` when the seller did not tick email is a real narrowing through
@@ -389,6 +389,9 @@ export async function runAnalyticsAlertEvaluate(ctx) {
389
389
  ...(alert.notifyChannels?.includes("email") && contact.email
390
390
  ? { userEmail: contact.email }
391
391
  : {}),
392
+ ...(alert.notifyChannels?.includes("whatsapp") && contact.phone
393
+ ? { userPhone: contact.phone }
394
+ : {}),
392
395
  audience: b.scope === "admin" ? "admin" : "seller",
393
396
  });
394
397
  };
@@ -426,7 +429,7 @@ export async function runAnalyticsAlertEvaluate(ctx) {
426
429
  *
427
430
  * If the notification throws, the cooldown has still been recorded —
428
431
  * so a persistently failing channel cannot turn into one attempt per
429
- * run forever. The inverse order would retry a broken send
432
+ * run forever. The inverse order would retry a broken WhatsApp send
430
433
  * every hour against a 100/day email allowance.
431
434
  */
432
435
  await ctx.db
@@ -11,6 +11,7 @@
11
11
  import { runWeeklyPayoutEligibility } from "./weeklyPayoutEligibility.js";
12
12
  import { runHardBanCascade } from "./hardBanCascade.js";
13
13
  import { runResetOtpVerification } from "./resetOtpVerification.js";
14
+ import { runWhatsAppNotify } from "./whatsappNotify.js";
14
15
  import { runNewsletterExport } from "./newsletterExport.js";
15
16
  import { runStoreNameBackfill } from "./storeNameBackfill.js";
16
17
  import { runCategoryTaxonomyBackfill } from "./categoryTaxonomyBackfill.js";
@@ -41,6 +42,9 @@ async function runHardBanCascadeJob(payload, ctx) {
41
42
  async function runResetOtpVerificationJob(_payload, ctx) {
42
43
  return runResetOtpVerification(ctx);
43
44
  }
45
+ async function runWhatsAppNotifyJob(payload, ctx) {
46
+ return runWhatsAppNotify(payload, ctx);
47
+ }
44
48
  async function runNewsletterExportJob(_payload, ctx) {
45
49
  return runNewsletterExport(ctx);
46
50
  }
@@ -105,6 +109,7 @@ export const JOB_RUNNERS = {
105
109
  payoutsWeekly: runPayoutsWeeklyJob,
106
110
  hardBanCascade: runHardBanCascadeJob,
107
111
  resetOtpVerification: runResetOtpVerificationJob,
112
+ whatsappNotify: runWhatsAppNotifyJob,
108
113
  newsletterExport: runNewsletterExportJob,
109
114
  storeNameBackfill: runStoreNameBackfillJob,
110
115
  categoryTaxonomyBackfill: runCategoryTaxonomyBackfillJob,
@@ -1,7 +1,77 @@
1
1
  import { normalizeError } from "../../../../errors/normalize.js";
2
2
  import { ORDER_PII_FIELDS, revealPii } from "../../../../security/index.js";
3
- import { adminNotificationsRepository } from "../../../../repositories/index.js";
4
- import { buildPurchaseAnnouncementMessage } from "../../../../features/orders/utils/order-announcement-message.js";
3
+ import { adminNotificationsRepository, storeRepository, userRepository } from "../../../../repositories/index.js";
4
+ import { decryptPii } from "../../../../security/index.js";
5
+ import { sendWhatsAppBusinessMessage, buildPurchaseAnnouncementMessage, } from "../../../../features/whatsapp-bot/server.js";
6
+ import { resolveKeys } from "../../../../core/integration-keys.js";
7
+ import { guardSend, logSuppressedSend } from "../../notifications/send-guard.js";
8
+ async function sendAnnouncement(ctx, toPhone, message, phoneNumberId, accessToken, label, orderId) {
9
+ try {
10
+ const sent = await sendWhatsAppBusinessMessage({
11
+ toPhone,
12
+ message,
13
+ phoneNumberId,
14
+ accessToken,
15
+ });
16
+ if (sent) {
17
+ ctx.logger.info(`Announcement sent to ${label}`, {
18
+ orderId,
19
+ toPhone: `...${toPhone.slice(-4)}`,
20
+ });
21
+ }
22
+ else {
23
+ ctx.logger.error(`Announcement delivery failed for ${label} (non-fatal)`, null, { orderId });
24
+ }
25
+ }
26
+ catch (err) {
27
+ void normalizeError(err);
28
+ ctx.logger.error(`Announcement send threw for ${label} (non-fatal)`, err, { orderId });
29
+ }
30
+ }
31
+ async function notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId) {
32
+ try {
33
+ const store = await storeRepository.findBySlug(storeId);
34
+ if (!store?.ownerId)
35
+ return;
36
+ const owner = await userRepository.findById(store.ownerId);
37
+ const encryptedPhone = owner?.phoneNumber;
38
+ if (!encryptedPhone)
39
+ return;
40
+ const ownerPhone = decryptPii(encryptedPhone);
41
+ if (ownerPhone) {
42
+ await sendAnnouncement(ctx, ownerPhone, message, phoneNumberId, accessToken, "store-owner", orderId);
43
+ }
44
+ }
45
+ catch (err) {
46
+ void normalizeError(err);
47
+ ctx.logger.error("Store owner lookup failed (non-fatal)", err, { orderId, storeId });
48
+ }
49
+ }
50
+ /**
51
+ * Seeded credentials are `*_PLACEHOLDER` strings rather than empty, so an
52
+ * emptiness test alone would happily send with a fake token. Mirrors the guard
53
+ * in checkout-value-otp-actions.ts.
54
+ */
55
+ function usable(value) {
56
+ const v = value?.trim() ?? "";
57
+ return v && !v.includes("PLACEHOLDER") ? v : "";
58
+ }
59
+ async function resolveWhatsAppCredentials(ctx, orderId) {
60
+ let db = {};
61
+ try {
62
+ db = await resolveKeys();
63
+ }
64
+ catch (err) {
65
+ void normalizeError(err);
66
+ // Firestore unreachable from the Functions runtime — env-only is still a
67
+ // valid configuration, so this is a warning, not a failure.
68
+ ctx.logger.warn("Credential lookup failed — falling back to env", { orderId });
69
+ }
70
+ return {
71
+ phoneNumberId: usable(db.whatsappPhoneNumberId) || usable(ctx.env("WHATSAPP_PHONE_NUMBER_ID")),
72
+ accessToken: usable(db.whatsappCloudApiToken) || usable(ctx.env("WHATSAPP_CLOUD_API_TOKEN")),
73
+ };
74
+ }
5
75
  export async function handleOrderCreate(input, ctx) {
6
76
  const { orderId, order } = input;
7
77
  const items = order.items ?? [];
@@ -70,13 +140,37 @@ export async function handleOrderCreate(input, ctx) {
70
140
  ctx.logger.error("Failed to write admin order notification (non-fatal)", err, { orderId });
71
141
  }
72
142
  /*
73
- * The seller is told about their own sale by the `order_placed`
74
- * notification `createOrderForGroup` already sends (in-app + email). This
75
- * handler used to ALSO push a WhatsApp Cloud API message here — a duplicate
76
- * channel for the same event, and the only thing in this file that needed a
77
- * Meta token, a registered number and a billing relationship. Removed with
78
- * the rest of the WhatsApp integration on 2026-10-20; no signal was lost,
79
- * because the notification path is independent and unchanged.
143
+ * Seller side keeps WhatsApp: it is ONE message to ONE person about their
144
+ * own sale, which is the case the channel is good at. Credentials resolve
145
+ * DB-first (Admin → Site Settings → WhatsApp) with an env fallback — this
146
+ * read `ctx.env()` only until 2026-08-22, so credentials saved in Site
147
+ * Settings never reached the announcement and it silently no-op'd.
80
148
  */
81
- ctx.logger.info("Order announcement complete", { orderId, storeId: order.storeId ?? null });
149
+ const storeId = order.storeId;
150
+ if (!storeId) {
151
+ ctx.logger.info("Order has no storeId — nothing to announce to a seller", { orderId });
152
+ return;
153
+ }
154
+ const { phoneNumberId, accessToken } = await resolveWhatsAppCredentials(ctx, orderId);
155
+ if (!phoneNumberId || !accessToken) {
156
+ ctx.logger.info("WhatsApp Cloud API not configured — skipping seller announcement", { orderId });
157
+ return;
158
+ }
159
+ // Audience is "user": a seller receiving news of their own sale is a
160
+ // marketplace participant, not an operator of the site. Staff exemption is
161
+ // for the digest and the payout summary, and widening it to "anyone we think
162
+ // ought to see this" would make the kill switch mean nothing.
163
+ const guard = {
164
+ channel: "whatsapp",
165
+ feature: "order_placed_seller",
166
+ audience: "user",
167
+ };
168
+ const decision = await guardSend(guard);
169
+ if (!decision.allow) {
170
+ logSuppressedSend(guard, decision);
171
+ ctx.logger.info("Seller order announcement suppressed by messaging guard", { orderId, storeId });
172
+ return;
173
+ }
174
+ await notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId);
175
+ ctx.logger.info(`Order announcement complete`, { orderId, storeId });
82
176
  }
@@ -12,6 +12,8 @@ export type OrderAfter = {
12
12
  */
13
13
  paymentStatus?: string;
14
14
  invoiceNumber?: string;
15
+ /** The seller. Decides the WhatsApp arm via their notification subscription. */
16
+ storeId?: string;
15
17
  };
16
18
  export type OrderBefore = {
17
19
  status: OrderStatus;
@@ -119,6 +119,7 @@ export async function handleOrderStatusChange(input, ctx) {
119
119
  relatedId: orderId,
120
120
  relatedType: "order",
121
121
  userEmail,
122
+ storeId: after.storeId,
122
123
  });
123
124
  // The RTDB mirror that used to live here has been removed. `sendNotification`
124
125
  // above is the real path (in-app + email + WhatsApp); the extra
@@ -30,6 +30,7 @@ export async function handleScamReportCreate(input, ctx) {
30
30
  relatedId: scammerId,
31
31
  relatedType: "scammer",
32
32
  userEmail: reporterPlain?.email ?? undefined,
33
+ userPhone: reporterPlain?.phoneNumber ?? undefined,
33
34
  });
34
35
  }
35
36
  catch (err) {
@@ -28,6 +28,7 @@ export async function handleScamReportRejected(input, ctx) {
28
28
  relatedId: scammerId,
29
29
  relatedType: "scammer",
30
30
  userEmail: reporterPlain?.email ?? undefined,
31
+ userPhone: reporterPlain?.phoneNumber ?? undefined,
31
32
  });
32
33
  }
33
34
  catch (err) {
@@ -28,6 +28,7 @@ export async function handleScamReportVerified(input, ctx) {
28
28
  relatedId: scammerId,
29
29
  relatedType: "scammer",
30
30
  userEmail: reporterPlain?.email ?? undefined,
31
+ userPhone: reporterPlain?.phoneNumber ?? undefined,
31
32
  });
32
33
  }
33
34
  catch (err) {
@@ -123,6 +123,7 @@ export async function runPaymentWindowTimeout(ctx) {
123
123
  message: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_MESSAGE(entry.data.productTitle),
124
124
  relatedId: entry.id,
125
125
  relatedType: "order",
126
+ storeId: entry.data.storeId,
126
127
  });
127
128
  }
128
129
  catch (err) {
@@ -67,6 +67,7 @@ export async function runPendingOrderTimeout(ctx) {
67
67
  message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
68
68
  relatedId: entry.id,
69
69
  relatedType: "order",
70
+ storeId: entry.data.storeId,
70
71
  });
71
72
  }
72
73
  catch (err) {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Core: async WhatsApp notification dispatch (`whatsappNotify` job).
3
+ *
4
+ * `sendNotification()` (appkit/src/features/admin/actions/notification-actions.ts)
5
+ * no longer calls the Meta Cloud API synchronously in-request — it enqueues
6
+ * this job instead (CLAUDE.md Rule #6 async job primitive) and returns
7
+ * immediately. This runner does the actual send, with a bounded retry, and
8
+ * writes the outcome back onto `notifications/{notificationId}` so admins
9
+ * have visibility beyond the `jobs/{jobId}` doc itself.
10
+ *
11
+ * Retry note: `sendWhatsAppTemplateMessage`/`sendWhatsAppBusinessMessage`
12
+ * return a plain boolean (not a status code), so this runner cannot cleanly
13
+ * distinguish a transient 5xx from a terminal 4xx (bad token, unknown
14
+ * template) the way a status-code-aware client could. It retries any
15
+ * failure up to MAX_ATTEMPTS with a short backoff — an acceptable
16
+ * simplification since this runs off the critical path, well inside the
17
+ * Firebase Function's 300s budget, and a terminal error just burns two
18
+ * extra fast failures before giving up.
19
+ */
20
+ import type { JobContext } from "../runtime/types";
21
+ import type { JobRunResult } from "./jobRunners";
22
+ export interface WhatsAppNotifyPayload {
23
+ toPhone: string;
24
+ title: string;
25
+ message: string;
26
+ type: string;
27
+ /** Approved Meta template name for this notification type, if configured. */
28
+ templateName?: string;
29
+ /** BCP-47 language code the template was approved in. Defaults to "en". */
30
+ templateLanguage?: string;
31
+ notificationId: string;
32
+ phoneNumberId: string;
33
+ accessToken: string;
34
+ }
35
+ export declare function runWhatsAppNotify(payload: WhatsAppNotifyPayload, ctx: JobContext): Promise<JobRunResult>;