@mohasinac/appkit 4.63.3 → 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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
- package/dist/_internal/server/features/checkout/actions.js +22 -5
- package/dist/_internal/server/features/orders/adapters.js +3 -1
- package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
- package/dist/_internal/server/features/promotions/adapters.js +43 -0
- package/dist/_internal/server/features/promotions/data.d.ts +26 -0
- package/dist/_internal/server/features/promotions/data.js +45 -0
- package/dist/_internal/server/features/promotions/index.d.ts +2 -1
- package/dist/_internal/server/features/promotions/index.js +2 -1
- package/dist/_internal/server/features/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +1 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
- package/dist/_internal/server/notifications/channel-health.js +2 -2
- package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
- package/dist/_internal/server/notifications/send-guard.js +4 -2
- package/dist/_internal/shared/actions/action-registry.js +28 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
- package/dist/_internal/shared/checkout/order-math.js +1 -1
- package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
- package/dist/_internal/shared/features/checkout/config.js +2 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
- package/dist/_internal/shared/features/messaging/config.js +4 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
- package/dist/_internal/shared/features/site-settings/fees.js +2 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
- package/dist/_internal/shared/fees/calculator.d.ts +8 -0
- package/dist/_internal/shared/fees/calculator.js +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +15 -0
- package/dist/constants/api-endpoints.js +8 -0
- package/dist/constants/field-names.d.ts +4 -0
- package/dist/constants/field-names.js +4 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/constants/notification-types.js +1 -1
- package/dist/core/integration-keys.d.ts +5 -0
- package/dist/core/integration-keys.js +10 -0
- package/dist/features/about/components/FeesView.js +6 -0
- package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
- package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
- package/dist/features/account/components/UserSidebar.js +1 -1
- package/dist/features/account/types/index.d.ts +2 -0
- package/dist/features/admin/actions/notification-actions.d.ts +17 -2
- package/dist/features/admin/actions/notification-actions.js +154 -9
- package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
- package/dist/features/admin/components/AdminOrdersView.js +2 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
- package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
- package/dist/features/admin/constants/site-settings-tabs.js +12 -6
- package/dist/features/admin/repository/site-settings.repository.js +1 -1
- package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
- package/dist/features/admin/schemas/admin-user-form.js +44 -3
- package/dist/features/admin/schemas/firestore.d.ts +73 -0
- package/dist/features/admin/schemas/firestore.js +8 -3
- package/dist/features/auth/checkout-value-otp.d.ts +1 -1
- package/dist/features/auth/checkout-value-otp.js +1 -1
- package/dist/features/auth/permissions/constants.d.ts +2 -2
- package/dist/features/auth/permissions/constants.js +2 -1
- package/dist/features/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +14 -0
- package/dist/features/blog/schemas/firestore.d.ts +17 -0
- package/dist/features/blog/schemas/firestore.js +1 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +3 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
- package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
- package/dist/features/cart/repository/cart.repository.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +2 -1
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
- package/dist/features/events/components/AdminEventEditorView.js +64 -9
- package/dist/features/events/components/EventOfferCard.js +3 -18
- package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
- package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
- package/dist/features/events/schemas/event-form.d.ts +10 -0
- package/dist/features/events/schemas/event-form.js +19 -0
- package/dist/features/events/schemas/firestore.d.ts +68 -0
- package/dist/features/events/schemas/firestore.js +25 -0
- package/dist/features/events/schemas/index.d.ts +7 -0
- package/dist/features/events/schemas/index.js +5 -0
- package/dist/features/events/types/index.d.ts +4 -0
- package/dist/features/homepage/lib/section-renderer.js +1 -1
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
- package/dist/features/orders/components/OrderAddonBadges.js +4 -3
- package/dist/features/orders/repository/orders.repository.d.ts +8 -0
- package/dist/features/orders/repository/orders.repository.js +2 -0
- package/dist/features/orders/schemas/firestore.d.ts +4 -0
- package/dist/features/orders/types/index.d.ts +5 -2
- package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
- package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
- package/dist/features/promotions/components/index.d.ts +4 -0
- package/dist/features/promotions/components/index.js +2 -0
- package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
- package/dist/features/promotions/constants/attached-coupons.js +24 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
- package/dist/features/promotions/repository/coupons.repository.js +39 -0
- package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
- package/dist/features/promotions/schemas/attached-coupons.js +66 -0
- package/dist/features/search/action-index/settings-entries.js +9 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
- package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
- package/dist/features/store-extensions/schemas/firestore.js +10 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
- package/dist/features/stores/components/StoreGuideHubView.js +1 -1
- package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
- package/dist/features/stores/repository/store.repository.d.ts +23 -1
- package/dist/features/stores/repository/store.repository.js +29 -1
- package/dist/features/stores/schemas/firestore.d.ts +16 -6
- package/dist/features/stores/schemas/firestore.js +3 -6
- package/dist/features/stores/schemas/index.d.ts +86 -0
- package/dist/features/stores/schemas/index.js +14 -0
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
- package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/components/index.js +2 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
- package/dist/features/whatsapp-bot/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/index.js +3 -0
- package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
- package/dist/features/whatsapp-bot/manifest.js +13 -0
- package/dist/features/whatsapp-bot/messages/en.json +7 -0
- package/dist/features/whatsapp-bot/server.d.ts +7 -0
- package/dist/features/whatsapp-bot/server.js +7 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
- package/dist/features/whatsapp-bot/types/index.js +1 -0
- package/dist/index.d.ts +23 -6
- package/dist/index.js +38 -12
- package/dist/next/routing/route-map.d.ts +3 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/providers/db-firebase/base.repository.d.ts +1 -1
- package/dist/providers/db-firebase/base.repository.js +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.js +1 -1
- package/dist/schemas/registry.d.ts +121 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +2 -0
- package/dist/server.d.ts +11 -2
- package/dist/server.js +26 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- package/package.json +1 -1
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- package/dist/features/orders/utils/order-announcement-message.js +0 -16
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.63.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.63.5",
|
|
4
|
+
"head": "6eff0d768f87f31fafd2ca5a5af8868622f94228",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
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,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CouponDocument } from "../../../../features/promotions/schemas";
|
|
2
|
+
import type { AttachedCoupon } from "../../../../features/promotions/constants/attached-coupons";
|
|
3
|
+
/**
|
|
4
|
+
* `CouponDocument` → the public `AttachedCoupon` shape.
|
|
5
|
+
*
|
|
6
|
+
* 🛑 Built field by field. Never `{ ...doc }` with deletes — that publishes
|
|
7
|
+
* every key nobody thought to remove, including ones the interface does not
|
|
8
|
+
* declare, which is exactly how `adSettings.providerCredentials` and a
|
|
9
|
+
* decrypted WhatsApp token reached public HTML (Root Cause #70).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT carried across, each for its own reason:
|
|
12
|
+
* usage.* — `currentUsage` / `totalLimit` is redemption telemetry;
|
|
13
|
+
* a reader who learns a code is near its cap learns
|
|
14
|
+
* nothing useful and the seller loses a lever.
|
|
15
|
+
* analytics.* — `totalRevenue` / `totalDiscount` is seller revenue.
|
|
16
|
+
* restrictions.* — `applicableProducts` / `excludeCategories` is the
|
|
17
|
+
* coupon's internal targeting; checkout enforces it and
|
|
18
|
+
* says so there, in context, against a real cart.
|
|
19
|
+
* createdBy — a uid.
|
|
20
|
+
* scope/storeId — not rendered; see the note in AttachedCouponsSection
|
|
21
|
+
* about why the card does not label a seller.
|
|
22
|
+
* bxgy/tiers — structured configs with no renderer; adding one is a
|
|
23
|
+
* feature, not a projection decision.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toAttachedCoupon(doc: CouponDocument): AttachedCoupon;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CouponDocument` → the public `AttachedCoupon` shape.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 Built field by field. Never `{ ...doc }` with deletes — that publishes
|
|
5
|
+
* every key nobody thought to remove, including ones the interface does not
|
|
6
|
+
* declare, which is exactly how `adSettings.providerCredentials` and a
|
|
7
|
+
* decrypted WhatsApp token reached public HTML (Root Cause #70).
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT carried across, each for its own reason:
|
|
10
|
+
* usage.* — `currentUsage` / `totalLimit` is redemption telemetry;
|
|
11
|
+
* a reader who learns a code is near its cap learns
|
|
12
|
+
* nothing useful and the seller loses a lever.
|
|
13
|
+
* analytics.* — `totalRevenue` / `totalDiscount` is seller revenue.
|
|
14
|
+
* restrictions.* — `applicableProducts` / `excludeCategories` is the
|
|
15
|
+
* coupon's internal targeting; checkout enforces it and
|
|
16
|
+
* says so there, in context, against a real cart.
|
|
17
|
+
* createdBy — a uid.
|
|
18
|
+
* scope/storeId — not rendered; see the note in AttachedCouponsSection
|
|
19
|
+
* about why the card does not label a seller.
|
|
20
|
+
* bxgy/tiers — structured configs with no renderer; adding one is a
|
|
21
|
+
* feature, not a projection decision.
|
|
22
|
+
*/
|
|
23
|
+
export function toAttachedCoupon(doc) {
|
|
24
|
+
return {
|
|
25
|
+
id: doc.id,
|
|
26
|
+
code: doc.code,
|
|
27
|
+
name: doc.name,
|
|
28
|
+
description: doc.description,
|
|
29
|
+
type: doc.type,
|
|
30
|
+
discountValue: doc.discount.value,
|
|
31
|
+
maxDiscount: doc.discount.maxDiscount,
|
|
32
|
+
minPurchase: doc.discount.minPurchase,
|
|
33
|
+
/*
|
|
34
|
+
* ISO, not a `Date`. This crosses a Server → Client Component boundary and
|
|
35
|
+
* a `Date` survives that only because Next serialises it; an ISO string is
|
|
36
|
+
* the shape the renderer actually wants and removes the question.
|
|
37
|
+
*
|
|
38
|
+
* 🛑 Absent means NO END DATE, never "expired". A renderer that treats a
|
|
39
|
+
* missing `endsAt` as expired hides every open-ended coupon.
|
|
40
|
+
*/
|
|
41
|
+
endsAt: doc.validity.endDate ? new Date(doc.validity.endDate).toISOString() : undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -1 +1,27 @@
|
|
|
1
|
+
import type { AttachedCoupon } from "../../../../features/promotions/constants/attached-coupons";
|
|
1
2
|
export declare const getCouponByCode: (code: string) => Promise<import("../../../..").CouponDocument | null>;
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the coupons a piece of content has attached, ready to render.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the event detail page and the blog post page — one implementation,
|
|
7
|
+
* because two hand-rolled copies of "read the ids, drop the dead ones, project"
|
|
8
|
+
* is how a fix lands on the route and never reaches the SSR view that
|
|
9
|
+
* duplicates it (Root Cause #59/#99).
|
|
10
|
+
*
|
|
11
|
+
* 🛑 `cache`d because the page body and `generateMetadata` may both want it.
|
|
12
|
+
* Keyed on the argument list, so callers must pass the SAME array contents —
|
|
13
|
+
* which they do, both reading `doc.couponIds`.
|
|
14
|
+
*
|
|
15
|
+
* 🛑 EXPIRED AND INACTIVE COUPONS ARE DROPPED HERE, not in the renderer.
|
|
16
|
+
* A blog post from March that still advertises a code which now errors at
|
|
17
|
+
* checkout is worse than one that advertises nothing: the reader does the
|
|
18
|
+
* work of copying it and is refused at the till. `isCouponValid` is the same
|
|
19
|
+
* predicate checkout applies (active + inside its window + under its total
|
|
20
|
+
* limit), so the page cannot promise what the till will decline.
|
|
21
|
+
*
|
|
22
|
+
* It does NOT check `perUserLimit` — that is per-reader and unknowable on a
|
|
23
|
+
* cached public page. A reader who has personally exhausted a still-valid
|
|
24
|
+
* coupon is refused at checkout with a message that can explain why; that is
|
|
25
|
+
* the correct place for it.
|
|
26
|
+
*/
|
|
27
|
+
export declare const getAttachedCoupons: (couponIds: readonly string[] | undefined) => Promise<AttachedCoupon[]>;
|
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
import { cache } from "react";
|
|
2
2
|
import { couponsRepository } from "../../../../repositories/index.js";
|
|
3
|
+
import { isCouponValid } from "../../../../features/promotions/schemas/index.js";
|
|
4
|
+
import { toAttachedCoupon } from "./adapters.js";
|
|
5
|
+
import { safeRead } from "../../../../errors/safe-read.js";
|
|
3
6
|
export const getCouponByCode = cache(async (code) => {
|
|
4
7
|
return (await couponsRepository.getCouponByCode(code)) ?? null;
|
|
5
8
|
});
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the coupons a piece of content has attached, ready to render.
|
|
11
|
+
*
|
|
12
|
+
* Shared by the event detail page and the blog post page — one implementation,
|
|
13
|
+
* because two hand-rolled copies of "read the ids, drop the dead ones, project"
|
|
14
|
+
* is how a fix lands on the route and never reaches the SSR view that
|
|
15
|
+
* duplicates it (Root Cause #59/#99).
|
|
16
|
+
*
|
|
17
|
+
* 🛑 `cache`d because the page body and `generateMetadata` may both want it.
|
|
18
|
+
* Keyed on the argument list, so callers must pass the SAME array contents —
|
|
19
|
+
* which they do, both reading `doc.couponIds`.
|
|
20
|
+
*
|
|
21
|
+
* 🛑 EXPIRED AND INACTIVE COUPONS ARE DROPPED HERE, not in the renderer.
|
|
22
|
+
* A blog post from March that still advertises a code which now errors at
|
|
23
|
+
* checkout is worse than one that advertises nothing: the reader does the
|
|
24
|
+
* work of copying it and is refused at the till. `isCouponValid` is the same
|
|
25
|
+
* predicate checkout applies (active + inside its window + under its total
|
|
26
|
+
* limit), so the page cannot promise what the till will decline.
|
|
27
|
+
*
|
|
28
|
+
* It does NOT check `perUserLimit` — that is per-reader and unknowable on a
|
|
29
|
+
* cached public page. A reader who has personally exhausted a still-valid
|
|
30
|
+
* coupon is refused at checkout with a message that can explain why; that is
|
|
31
|
+
* the correct place for it.
|
|
32
|
+
*/
|
|
33
|
+
export const getAttachedCoupons = cache(async (couponIds) => {
|
|
34
|
+
if (!couponIds || couponIds.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
/*
|
|
37
|
+
* `safeRead`, so a Firestore hiccup (or a missing index on a cold
|
|
38
|
+
* collection) costs the page its coupon strip and not the whole render.
|
|
39
|
+
* These pages are prerendered, and an unguarded throw during the
|
|
40
|
+
* prerender ATTEMPT is fatal to the production build — see the dashboard
|
|
41
|
+
* note in § SSR Architecture. The failure is recorded as a DEGRADED_READ
|
|
42
|
+
* rather than swallowed, which is the distinction Root Cause #59 is about.
|
|
43
|
+
*/
|
|
44
|
+
const docs = await safeRead(() => couponsRepository.findByIds([...couponIds]), {
|
|
45
|
+
route: "content.attachedCoupons",
|
|
46
|
+
key: "promotions.findByIds",
|
|
47
|
+
fallback: [],
|
|
48
|
+
});
|
|
49
|
+
return docs.filter(isCouponValid).map(toAttachedCoupon);
|
|
50
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { getCouponByCode } from "./data";
|
|
1
|
+
export { getCouponByCode, getAttachedCoupons } from "./data";
|
|
2
|
+
export { toAttachedCoupon } from "./adapters";
|
|
2
3
|
export { validateCoupon, computeDiscount } from "./service";
|
|
3
4
|
export { applyCouponAction, createCouponAction, updateCouponAction, deactivateCouponAction } from "./actions";
|
|
4
5
|
export { COUPONS_PAGE_SIZE, COUPON_CODE_MAX_LENGTH } from "../../../shared/features/promotions/config";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { getCouponByCode } from "./data.js";
|
|
1
|
+
export { getCouponByCode, getAttachedCoupons } from "./data.js";
|
|
2
|
+
export { toAttachedCoupon } from "./adapters.js";
|
|
2
3
|
export { validateCoupon, computeDiscount } from "./service.js";
|
|
3
4
|
export { applyCouponAction, createCouponAction, updateCouponAction, deactivateCouponAction } from "./actions.js";
|
|
4
5
|
export { COUPONS_PAGE_SIZE, COUPON_CODE_MAX_LENGTH } from "../../../shared/features/promotions/config.js";
|
|
@@ -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", //
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|