@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
@@ -32,6 +32,10 @@ export interface NotificationDocument extends BaseDocument {
32
32
  readAt?: Date;
33
33
  relatedId?: string;
34
34
  relatedType?: NotificationRelatedType;
35
+ /** Outcome of the async whatsappNotify job for this notification's WhatsApp send, if one was enqueued. */
36
+ whatsappStatus?: "queued" | "sent" | "failed" | "skipped";
37
+ /** jobs/{id} doc that ran (or is running) the WhatsApp send for this notification. */
38
+ whatsappJobId?: string;
35
39
  }
36
40
  export declare const NOTIFICATIONS_COLLECTION: "notifications";
37
41
  export declare const NOTIFICATIONS_INDEXED_FIELDS: readonly ["userId", "isRead", "createdAt", "type"];
@@ -110,6 +114,7 @@ export interface SiteSettingsCredentials {
110
114
  /** Webhook signing secret from Razorpay's dashboard → Webhooks. Without it `verifyWebhook()` fails closed. */
111
115
  razorpayWebhookSecret?: string;
112
116
  resendApiKey?: string;
117
+ whatsappApiKey?: string;
113
118
  metaAppId?: string;
114
119
  metaAppSecret?: string;
115
120
  metaPageAccessToken?: string;
@@ -124,6 +129,28 @@ export interface SiteSettingsCredentials {
124
129
  /** Google Places API for Google Business Reviews (HS4) */
125
130
  googleMapsApiKey?: string;
126
131
  googlePlaceId?: string;
132
+ /** WhatsApp Business Cloud API — platform level (for admin purchase announcements) */
133
+ whatsappPhoneNumberId?: string;
134
+ whatsappCloudApiToken?: string;
135
+ /** Comma-separated digits-only phone numbers that receive order announcements */
136
+ whatsappAdminNotifyNumbers?: string;
137
+ /**
138
+ * Approved Meta message-template names, one flat string field per
139
+ * order-lifecycle notification type — every `SiteSettingsCredentials`
140
+ * field is stored as an individually-encrypted string (see
141
+ * `getDecryptedCredentials`/`getCredentialsMasked`, which iterate + mask
142
+ * every field as a string), so this can't be a `Record<string,string>`
143
+ * map. Business-initiated WhatsApp sends outside the 24h customer-service
144
+ * window require a pre-approved template.
145
+ */
146
+ whatsappTemplateOrderPlaced?: string;
147
+ whatsappTemplateOrderConfirmed?: string;
148
+ whatsappTemplateOrderShipped?: string;
149
+ whatsappTemplateOrderDelivered?: string;
150
+ whatsappTemplateOrderCancelled?: string;
151
+ whatsappTemplateRefundInitiated?: string;
152
+ /** BCP-47 language code the templates above were approved in, e.g. "en" or "en_US". */
153
+ whatsappTemplateLanguage?: string;
127
154
  }
128
155
  export interface SiteSettingsCredentialsMasked {
129
156
  phonepeClientId?: string;
@@ -136,6 +163,7 @@ export interface SiteSettingsCredentialsMasked {
136
163
  razorpayKeySecret?: string;
137
164
  razorpayWebhookSecret?: string;
138
165
  resendApiKey?: string;
166
+ whatsappApiKey?: string;
139
167
  metaAppId?: string;
140
168
  metaAppSecret?: string;
141
169
  metaPageAccessToken?: string;
@@ -147,6 +175,16 @@ export interface SiteSettingsCredentialsMasked {
147
175
  deviantartClientSecret?: string;
148
176
  googleMapsApiKey?: string;
149
177
  googlePlaceId?: string;
178
+ whatsappPhoneNumberId?: string;
179
+ whatsappCloudApiToken?: string;
180
+ whatsappAdminNotifyNumbers?: string;
181
+ whatsappTemplateOrderPlaced?: string;
182
+ whatsappTemplateOrderConfirmed?: string;
183
+ whatsappTemplateOrderShipped?: string;
184
+ whatsappTemplateOrderDelivered?: string;
185
+ whatsappTemplateOrderCancelled?: string;
186
+ whatsappTemplateRefundInitiated?: string;
187
+ whatsappTemplateLanguage?: string;
150
188
  }
151
189
  export interface NotificationChannelConfig {
152
190
  inApp: {
@@ -162,6 +200,15 @@ export interface NotificationChannelConfig {
162
200
  /** Subset of NotificationTypes to email — absent means all types. */
163
201
  types?: NotificationType[];
164
202
  };
203
+ whatsapp: {
204
+ enabled: boolean;
205
+ /** Minimum priority that triggers a WhatsApp message (default: "high"). */
206
+ minPriority?: NotificationPriority;
207
+ /** Subset of NotificationTypes to WhatsApp — absent means all types. */
208
+ types?: NotificationType[];
209
+ /** When true, WhatsApp OTP is offered as an alternative to email OTP at checkout. */
210
+ otpEnabled?: boolean;
211
+ };
165
212
  sms: {
166
213
  enabled: boolean;
167
214
  /** Minimum priority that triggers an SMS (default: "high"). */
@@ -452,6 +499,29 @@ export interface SiteSettingsDocument extends BaseDocument {
452
499
  * ₹2,000 of "handling" on a ₹20,000 order — see `computeCodHandlingFee`.
453
500
  */ codHandlingFeeCap: number;
454
501
  codHandlingFeePercent: number;
502
+ /**
503
+ * Admin master toggle for the per-order, BUYER-paid WhatsApp addon.
504
+ *
505
+ * 🛑 Defaults to FALSE. The cost of WhatsApp moved to a standing monthly
506
+ * charge on the SELLER (`whatsappNotifyMonthlyFee` below) — the platform
507
+ * owns one number, so the expense is standing rather than per order, and a
508
+ * buyer should not have to purchase their own delivery updates. The
509
+ * checkout path that charges it is left intact rather than cut out again:
510
+ * it is woven through the order-total arithmetic that carries D42's
511
+ * round-once fix, and disabling it makes the fee resolve to 0 without
512
+ * touching that money code.
513
+ */
514
+ whatsappNotifyFeeEnabled: boolean;
515
+ /** Flat rupee fee charged to the buyer when they opt into the per-order addon. Unreachable while `whatsappNotifyFeeEnabled` is false. */
516
+ whatsappNotifyFee: number;
517
+ /**
518
+ * What a seller pays per month to receive WhatsApp order notifications —
519
+ * the admin-set price of `StoreDocument.whatsappNotifySubscribed`.
520
+ *
521
+ * Rupees per month, not per message: Meta bills us per template, but a
522
+ * seller cannot predict their own order count, and a per-message price
523
+ * would make their bill a function of how well they sold.
524
+ */ whatsappNotifyMonthlyFee: number;
455
525
  /** Admin master toggle — whether the gift-wrap addon is offered at checkout at all. */
456
526
  giftWrapFeeEnabled: boolean;
457
527
  /** Flat rupee fee charged to the buyer when they opt into gift wrap. */
@@ -537,6 +607,8 @@ export interface SiteSettingsDocument extends BaseDocument {
537
607
  messaging?: {
538
608
  /** User-facing email. Staff mail (daily digest, payout summary) ignores this. */
539
609
  emailEnabled: boolean;
610
+ /** User-facing WhatsApp. Same exemption for staff. */
611
+ whatsappEnabled: boolean;
540
612
  /**
541
613
  * Reserved sends allowed per channel per day. Applies to staff and
542
614
  * transactional mail too — the ceiling exists to stay inside the
@@ -544,6 +616,7 @@ export interface SiteSettingsDocument extends BaseDocument {
544
616
  */
545
617
  dailyCeiling: {
546
618
  email: number;
619
+ whatsapp: number;
547
620
  sms: number;
548
621
  };
549
622
  };
@@ -15,7 +15,7 @@ export const ADMIN_CHECKOUT_BYPASS_FLAG_KEY = "adminCheckoutBypass";
15
15
  * · `constants/notification-types.ts` (9) — fed the admin filter chips and
16
16
  * the per-channel allow-list, so the admin list could not filter 18 of
17
17
  * the 27 real types and an admin literally could not allow-list
18
- * `offer_received` or `payment_review` for email
18
+ * `offer_received` or `payment_review` for email or WhatsApp
19
19
  * · `features/account/types/index.ts` (5: order|offer|promo|system|message)
20
20
  * — a different vocabulary entirely, on a `UserNotification` type with
21
21
  * zero real consumers
@@ -195,6 +195,7 @@ export const notificationQueryHelpers = {
195
195
  export const DEFAULT_NOTIFICATION_CHANNELS = {
196
196
  inApp: { enabled: true, readOnly: true },
197
197
  email: { enabled: false, minPriority: "normal" },
198
+ whatsapp: { enabled: false, minPriority: "high", otpEnabled: false },
198
199
  sms: { enabled: false, minPriority: "high" },
199
200
  };
200
201
  const PRIORITY_ORDER = {
@@ -254,7 +255,8 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
254
255
  // half-configured site.
255
256
  messaging: {
256
257
  emailEnabled: false,
257
- dailyCeiling: { email: 80, sms: 50 },
258
+ whatsappEnabled: false,
259
+ dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
258
260
  },
259
261
  emi: {
260
262
  enabled: false,
@@ -277,9 +279,12 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
277
279
  // The buyer add-ons ship ENABLED. They were `false`, which made
278
280
  // StoreAddonsPicker return null on both the cart and checkout, so a fresh
279
281
  // install had no add-ons at all and no indication why. `undefined` still
280
- // means disabled everywhere (computeGiftWrapFee et al. read
282
+ // means disabled everywhere (computeWhatsAppNotifyFee et al. read
281
283
  // `if (!rates.XEnabled) return 0`) — only the DEFAULT changed, so an
282
284
  // existing siteSettings document keeps whatever it already stored.
285
+ whatsappNotifyFeeEnabled: false,
286
+ whatsappNotifyFee: 10,
287
+ whatsappNotifyMonthlyFee: 199,
283
288
  giftWrapFeeEnabled: true,
284
289
  giftWrapFee: 49,
285
290
  shipmentProtectionFeeEnabled: true,
@@ -44,6 +44,6 @@ export interface CheckoutValueOtpDoc {
44
44
  * making the whole feature look broken.
45
45
  */
46
46
  export declare function enforceCheckoutValueOtpRateLimit(db: Db, uid: string): Promise<void>;
47
- /** Build the checkout-value OTP message body. */
47
+ /** Build the checkout-value OTP WhatsApp/SMS message body. */
48
48
  export declare function buildCheckoutValueOtpMessage(code: string, siteName: string): string;
49
49
  export {};
@@ -75,7 +75,7 @@ export async function enforceCheckoutValueOtpRateLimit(db, uid) {
75
75
  tx.set(metaRef, { lastSentAt: new Date() }, { merge: true });
76
76
  });
77
77
  }
78
- /** Build the checkout-value OTP message body. */
78
+ /** Build the checkout-value OTP WhatsApp/SMS message body. */
79
79
  export function buildCheckoutValueOtpMessage(code, siteName) {
80
80
  return (`*${siteName} — Order Verification*\n\n` +
81
81
  `Your verification code for this high-value order is: *${code}*\n\n` +
@@ -83,7 +83,7 @@ export declare const ROUTE_PERMISSION_MAP: Record<string, Permission>;
83
83
  *
84
84
  * Defaults on store creation: ["suggest_brands", "create_coupons"]
85
85
  */
86
- export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics";
86
+ export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics" | "whatsapp_catalog_sync";
87
87
  export declare const ALL_STORE_CAPABILITIES: StoreCapability[];
88
88
  /** Narrow an untrusted string to a real capability. */
89
89
  export declare function isStoreCapability(value: unknown): value is StoreCapability;
@@ -118,7 +118,7 @@ export declare function hasCapability(capabilities: StoreCapability[], required:
118
118
  *
119
119
  * 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
120
120
  * already holds the store document does not pay a second Firestore read to ask
121
- * what it is entitled to (Rule #6). Several routes were reading
121
+ * what it is entitled to (Rule #6). Three WhatsApp routes were reading
122
122
  * `store.capabilities ?? []` inline for exactly that reason, which made them a
123
123
  * second, divergent reader of the field — they skipped the default merge, so
124
124
  * they and `getStoreCapabilities` could disagree about the same store.
@@ -390,6 +390,7 @@ const STORE_CAPABILITY_MAP = {
390
390
  lower_commission_rate: true,
391
391
  early_access_features: true,
392
392
  advanced_analytics: true,
393
+ whatsapp_catalog_sync: true,
393
394
  };
394
395
  export const ALL_STORE_CAPABILITIES = Object.keys(STORE_CAPABILITY_MAP);
395
396
  /** Narrow an untrusted string to a real capability. */
@@ -441,7 +442,7 @@ export function hasCapability(capabilities, required) {
441
442
  *
442
443
  * 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
443
444
  * already holds the store document does not pay a second Firestore read to ask
444
- * what it is entitled to (Rule #6). Several routes were reading
445
+ * what it is entitled to (Rule #6). Three WhatsApp routes were reading
445
446
  * `store.capabilities ?? []` inline for exactly that reason, which made them a
446
447
  * second, divergent reader of the field — they skipped the default merge, so
447
448
  * they and `getStoreCapabilities` could disagree about the same store.
@@ -22,6 +22,7 @@ export interface CartPriceBreakdownStore {
22
22
  subtotal: number;
23
23
  shippingFee: number;
24
24
  codHandlingFee: number;
25
+ whatsappNotifyFee: number;
25
26
  giftWrapFee: number;
26
27
  shipmentProtectionFee: number;
27
28
  saleProtectionFee: number;
@@ -44,6 +45,7 @@ export interface CartPriceBreakdownData {
44
45
  subtotal: number;
45
46
  shippingFee: number;
46
47
  codHandlingFee: number;
48
+ whatsappNotifyFee: number;
47
49
  giftWrapFee: number;
48
50
  shipmentProtectionFee: number;
49
51
  saleProtectionFee: number;
@@ -40,7 +40,7 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
40
40
  const subtotalLabel = itemCount !== undefined
41
41
  ? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
42
42
  : "Subtotal";
43
- return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
43
+ return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.whatsappNotifyFee > 0 && (_jsx(Line, { label: withStoreCount("WhatsApp updates", countCharged((s) => s.whatsappNotifyFee)), amount: money(preview.whatsappNotifyFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
44
44
  ? "Your points could not be applied to this order."
45
45
  : storeCount > 1
46
46
  ? "None of these sellers accepts points."
@@ -3,12 +3,13 @@ import React from "react";
3
3
  * Paid add-on checkboxes for ONE store.
4
4
  *
5
5
  * Add-on fees are billed per order group (= per store), so they are chosen per
6
- * store: ticking "gift wrap" for one seller must not bill it for every
6
+ * store: ticking "WhatsApp updates" for one seller must not bill it for every
7
7
  * seller in the cart. Rendered inside that store's card in the cart, and inside
8
8
  * that store's row of the checkout summary — Buy Now skips the cart entirely,
9
9
  * so both surfaces have to be able to edit them.
10
10
  */
11
11
  export interface StoreAddonsValue {
12
+ whatsappNotifyAddon?: boolean;
12
13
  giftWrapAddon?: boolean;
13
14
  giftWrapMessage?: string;
14
15
  shipmentProtectionAddon?: boolean;
@@ -16,6 +17,8 @@ export interface StoreAddonsValue {
16
17
  }
17
18
  /** The subset of `siteSettings.commissions` that gates and prices the add-ons. */
18
19
  export interface StoreAddonsRates {
20
+ whatsappNotifyFeeEnabled?: boolean;
21
+ whatsappNotifyFee?: number;
19
22
  giftWrapFeeEnabled?: boolean;
20
23
  giftWrapFee?: number;
21
24
  shipmentProtectionFeeEnabled?: boolean;
@@ -56,7 +59,7 @@ export interface StoreProtectionContext {
56
59
  * showing an explanatory line instead.
57
60
  *
58
61
  * 🛑 Deliberately asks only about TIER 1. A seller who supports neither
59
- * protection still has gift wrap, and a store whose only
62
+ * protection still has WhatsApp updates and gift wrap, and a store whose only
60
63
  * enabled add-ons are protections it declined must still render the picker —
61
64
  * because that is where the risk notice lives, and a hidden notice is the
62
65
  * tri-state failure in § `StoreProtectionContext`.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Gift, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react";
3
+ import { Gift, MessageCircle, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react";
4
4
  import { Div, Row, Stack, Text } from "../../../ui/index.js";
5
5
  import { Disclaimer } from "../../../ui/components/Disclaimer.js";
6
6
  import { IconBox } from "../../../ui/components/IconBox.js";
@@ -16,14 +16,15 @@ import { formatCurrency } from "../../../utils/number.formatter.js";
16
16
  * showing an explanatory line instead.
17
17
  *
18
18
  * 🛑 Deliberately asks only about TIER 1. A seller who supports neither
19
- * protection still has gift wrap, and a store whose only
19
+ * protection still has WhatsApp updates and gift wrap, and a store whose only
20
20
  * enabled add-ons are protections it declined must still render the picker —
21
21
  * because that is where the risk notice lives, and a hidden notice is the
22
22
  * tri-state failure in § `StoreProtectionContext`.
23
23
  */
24
24
  export function hasAnyStoreAddon(rates) {
25
25
  return (!!rates &&
26
- !!(rates.giftWrapFeeEnabled ||
26
+ !!(rates.whatsappNotifyFeeEnabled ||
27
+ rates.giftWrapFeeEnabled ||
27
28
  rates.shipmentProtectionFeeEnabled ||
28
29
  rates.saleProtectionFeeEnabled));
29
30
  }
@@ -51,7 +52,7 @@ export function StoreAddonsPicker({ storeId, storeSubtotal, value, onChange, rat
51
52
  const shippingDeclined = !!rates.shipmentProtectionFeeEnabled && !protection?.supportsShippingProtection;
52
53
  const saleDeclined = !!rates.saleProtectionFeeEnabled && !protection?.supportsSaleProtection;
53
54
  const saleBlockedByFinalSale = !!rates.saleProtectionFeeEnabled && !!protection?.supportsSaleProtection && !!protection?.hasFinalSaleItem;
54
- return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
55
+ return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.whatsappNotifyFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(MessageCircle, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `whatsappNotifyAddon-${storeId}`, label: `WhatsApp order updates (+${money(rates.whatsappNotifyFee ?? 10)})`, checked: !!value.whatsappNotifyAddon, onChange: (checked) => set({ whatsappNotifyAddon: checked }), disabled: disabled }) })] })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
55
56
  ? "This seller does not offer shipment or sale protection."
56
57
  : shippingDeclined
57
58
  ? "This seller does not offer shipment protection."
@@ -577,7 +577,8 @@ export class CartRepository extends BaseRepository {
577
577
  */
578
578
  async setStoreAddons(userId, storeId, addons) {
579
579
  const isEmpty = !addons ||
580
- (!addons.giftWrapAddon &&
580
+ (!addons.whatsappNotifyAddon &&
581
+ !addons.giftWrapAddon &&
581
582
  !addons.shipmentProtectionAddon &&
582
583
  !addons.saleProtectionAddon);
583
584
  await this.db
@@ -272,11 +272,12 @@ export interface CartAppliedCoupon {
272
272
  *
273
273
  * Add-on fees have always been billed per order group (= per store) by
274
274
  * `createOrderForGroup` / `previewCheckoutPricing`, but the selection used to be
275
- * a single cart-wide boolean — so one tick on "gift wrap" quietly billed
275
+ * a single cart-wide boolean — so one tick on "WhatsApp updates" quietly billed
276
276
  * ₹10 × every store in the cart, with no way to opt in for just one seller.
277
277
  * Keying the selection by storeId makes the choice match the billing.
278
278
  */
279
279
  export interface CartStoreAddons {
280
+ whatsappNotifyAddon?: boolean;
280
281
  giftWrapAddon?: boolean;
281
282
  giftWrapMessage?: string;
282
283
  /** Loss or damage IN TRANSIT. */
@@ -6,17 +6,12 @@
6
6
  * purpose from `checkout-actions.ts`'s third-party shipping consent OTP —
7
7
  * same crypto primitives, separate Firestore namespace and copy.
8
8
  */
9
+ export type CheckoutValueOtpChannel = "email" | "whatsapp";
9
10
  /**
10
- * Email is the only checkout-OTP channel. WhatsApp was the other arm until
11
- * 2026-10-20, when the Cloud API integration was removed — see
12
- * `features/orders/utils/payment-proof-message.ts` for why the `wa.me`
13
- * links survived and the API did not. Kept as a single-member union rather
14
- * than deleted so the parameter stays self-documenting and a second channel
15
- * has an obvious seam.
16
- */
17
- export type CheckoutValueOtpChannel = "email";
18
- /**
19
- * Sends a checkout-value OTP by email.
11
+ * Sends a checkout-value OTP either by email (default) or WhatsApp
12
+ * (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
13
+ * is always resolved server-side from the selected shipping address —
14
+ * never trust a client-supplied phone number.
20
15
  *
21
16
  * If the chosen channel (and, for email, its skip-worthy fallback) is
22
17
  * currently circuit-broken or unconfigured, the OTP gate is treated as
@@ -9,12 +9,17 @@
9
9
  import { timingSafeEqual } from "crypto";
10
10
  import { ValidationError } from "../../../errors/index.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
+ import { safeRead } from "../../../errors/safe-read.js";
12
13
  import { serverLogger } from "../../../monitoring/index.js";
13
14
  import { sendEmail } from "../../contact/email.js";
14
15
  import { getAdminDb } from "../../../providers/db-firebase/index.js";
15
16
  import { resolveDate } from "../../../utils/index.js";
17
+ import { addressesRepository } from "../../addresses/repository/addresses.repository.js";
18
+ import { siteSettingsRepository } from "../../admin/repository/site-settings.repository.js";
19
+ import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp.js";
16
20
  import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
17
- import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, } from "../../auth/checkout-value-otp.js";
21
+ import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard.js";
22
+ import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp.js";
18
23
  function maskEmail(userEmail) {
19
24
  const [local, domain] = userEmail.split("@");
20
25
  return domain
@@ -22,7 +27,10 @@ function maskEmail(userEmail) {
22
27
  : "***";
23
28
  }
24
29
  /**
25
- * Sends a checkout-value OTP by email.
30
+ * Sends a checkout-value OTP either by email (default) or WhatsApp
31
+ * (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
32
+ * is always resolved server-side from the selected shipping address —
33
+ * never trust a client-supplied phone number.
26
34
  *
27
35
  * If the chosen channel (and, for email, its skip-worthy fallback) is
28
36
  * currently circuit-broken or unconfigured, the OTP gate is treated as
@@ -37,6 +45,84 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
37
45
  const codeHash = hashOtp(code);
38
46
  const expiresAt = new Date(Date.now() + CHECKOUT_VALUE_OTP_EXPIRY_MS);
39
47
  const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
48
+ if (channel === "whatsapp") {
49
+ if (!addressId) {
50
+ throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
51
+ }
52
+ // A failed settings read makes the WhatsApp channel look deliberately
53
+ // switched off ("send the code by email instead") — a defensible fallback,
54
+ // but one an operator has to be able to see, since it is indistinguishable
55
+ // from an admin having disabled it.
56
+ const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
57
+ route: "checkout/value-otp",
58
+ key: "checkoutValueOtp.siteSettings",
59
+ fallback: null,
60
+ });
61
+ const creds = settings?.credentials ?? {};
62
+ const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
63
+ const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
64
+ const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
65
+ const credsReady = phoneNumberId && accessToken && !phoneNumberId.includes("PLACEHOLDER") && !accessToken.includes("PLACEHOLDER");
66
+ if (!whatsappOtpEnabled || !credsReady) {
67
+ throw new ValidationError("WhatsApp verification isn't available right now — send the code by email instead.");
68
+ }
69
+ const address = await addressesRepository.findById(addressId);
70
+ /*
71
+ * REVEALED: this phone receives the high-value checkout OTP, so it has to
72
+ * be the real number. `addressesRepository` is ciphertext-by-default, and
73
+ * `.trim()` on the stored value is what the nominal brand caught — the
74
+ * old intersection form let `Encrypted<string>` call string methods.
75
+ */
76
+ const addressPlain = address ? addressesRepository.revealAddress(address) : null;
77
+ const phone = addressPlain?.phone?.trim();
78
+ if (!phone) {
79
+ throw new ValidationError("The selected address has no phone number on file — send the code by email instead.");
80
+ }
81
+ if (!(await isChannelHealthy("whatsapp"))) {
82
+ serverLogger.warn("sendCheckoutValueOtp: WhatsApp channel unhealthy — skipping OTP gate", { uid: userId, channel });
83
+ return { skipped: true };
84
+ }
85
+ await checkoutValueOtpRef(db, userId).set({
86
+ codeHash,
87
+ expiresAt,
88
+ attempts: 0,
89
+ verified: false,
90
+ createdAt: new Date(),
91
+ });
92
+ const message = buildCheckoutValueOtpMessage(code, siteName);
93
+ // Same guard as the email branch below, same reasoning: transactional, so
94
+ // the kill switch does not apply (the buyer cannot check out without the
95
+ // code), but the daily ceiling does.
96
+ const waGuard = {
97
+ channel: "whatsapp",
98
+ feature: "checkout_value_otp",
99
+ audience: "user",
100
+ transactional: true,
101
+ };
102
+ const waDecision = await guardSend(waGuard);
103
+ if (!waDecision.allow) {
104
+ logSuppressedSend(waGuard, waDecision);
105
+ throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
106
+ }
107
+ try {
108
+ const sent = await withChannelRetry("whatsapp", async () => {
109
+ const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
110
+ if (!ok)
111
+ throw new Error("WhatsApp Cloud API returned a non-OK response");
112
+ return ok;
113
+ });
114
+ if (!sent)
115
+ throw new Error("WhatsApp send failed");
116
+ }
117
+ catch (err) {
118
+ void normalizeError(err);
119
+ serverLogger.error("Failed to send checkout value OTP via WhatsApp", { uid: userId, err });
120
+ throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
121
+ }
122
+ const maskedPhone = phone.length > 4 ? `${"*".repeat(phone.length - 4)}${phone.slice(-4)}` : "***";
123
+ serverLogger.info(`Checkout value OTP sent via WhatsApp: uid=${userId}`);
124
+ return { maskedPhone };
125
+ }
40
126
  if (!(await isChannelHealthy("email"))) {
41
127
  serverLogger.warn("sendCheckoutValueOtp: email channel unhealthy — skipping OTP gate", { uid: userId, channel });
42
128
  return { skipped: true };
@@ -259,7 +259,7 @@ const SECTION_REGISTRY = {
259
259
  return (_jsx(WhatsAppCommunitySection, { title: cleanTitle(cfg?.title) || SECTION_TITLE.whatsappCommunity, descriptionHtml: cfg?.description || SECTION_COPY.whatsappDescription,
260
260
  // No `|| 5000` fallback: inventing a member count the site cannot
261
261
  // verify is a fabricated social-proof number.
262
- memberCount: cfg?.memberCount, groupLink: cfg?.groupLink || undefined, benefits: cfg?.benefits ?? [], testimonial: cfg?.testimonial, buttonText: cfg?.buttonText }));
262
+ memberCount: cfg?.memberCount, groupLink: cfg?.groupLink || "https://chat.whatsapp.com/", benefits: cfg?.benefits ?? [], testimonial: cfg?.testimonial, buttonText: cfg?.buttonText }));
263
263
  },
264
264
  },
265
265
  faq: {
@@ -79,6 +79,7 @@ export async function issuePartialRefund(adminUid, orderId, deductFees, refundNo
79
79
  : `Your full refund of ₹${grossRefund} is being processed.`,
80
80
  relatedId: orderId,
81
81
  relatedType: "order",
82
+ storeId: order.storeId,
82
83
  });
83
84
  serverLogger.info("Admin partial refund initiated", {
84
85
  adminUid,
@@ -2,7 +2,7 @@ import React from "react";
2
2
  /**
3
3
  * Icon badges for an order's paid add-ons and applied coupon.
4
4
  *
5
- * These are fulfilment instructions, not decoration: gift wrap tells the
5
+ * These are fulfilment instructions, not decoration: WhatsApp tells the
6
6
  * status-change notifier to message this buyer, gift wrap tells the packer to
7
7
  * wrap the parcel and enclose the message, and either protection changes how a
8
8
  * claim is handled. They are recorded on the order (and filterable in the
@@ -14,6 +14,8 @@ import React from "react";
14
14
  * at once instead of being wired per screen.
15
15
  */
16
16
  export interface OrderAddonBadgesOrder {
17
+ whatsappNotifyAddon?: boolean;
18
+ whatsappNotifyFee?: number;
17
19
  giftWrapAddon?: boolean;
18
20
  giftWrapFee?: number;
19
21
  giftWrapMessage?: string;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Gift, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
3
+ import { Gift, MessageCircle, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
4
4
  import { Row, Span, Stack, Text } from "../../../ui/index.js";
5
5
  import { formatCurrency } from "../../../utils/number.formatter.js";
6
6
  const CHIP_BASE = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[length:var(--appkit-text-xs)] font-medium";
@@ -15,14 +15,15 @@ function Chip({ icon, label, tone, title, }) {
15
15
  export function OrderAddonBadges({ order, variant = "compact", showCoupon = true, currency = "INR", className = "", }) {
16
16
  const money = (v) => (typeof v === "number" && v > 0 ? formatCurrency(v, currency) : undefined);
17
17
  const hasCoupon = showCoupon && !!order.couponCode;
18
- const hasAny = order.giftWrapAddon ||
18
+ const hasAny = order.whatsappNotifyAddon ||
19
+ order.giftWrapAddon ||
19
20
  order.shipmentProtectionAddon ||
20
21
  order.saleProtectionAddon ||
21
22
  hasCoupon;
22
23
  if (!hasAny)
23
24
  return null;
24
25
  const isDetail = variant === "detail";
25
- return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
26
+ return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.whatsappNotifyAddon && (_jsx(Chip, { icon: _jsx(MessageCircle, { size: 12 }), label: isDetail && money(order.whatsappNotifyFee) ? `WhatsApp updates · ${money(order.whatsappNotifyFee)}` : "WhatsApp updates", tone: "success", title: "Buyer opted into WhatsApp order-status updates \u2014 status changes on this order should notify them on WhatsApp." })), order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
26
27
  ? `${order.couponCode} · −${money(order.couponDiscount)}`
27
28
  : order.couponCode, tone: "success", title: `Coupon ${order.couponCode} applied to this order.` }))] }), isDetail && order.giftWrapAddon && order.giftWrapMessage && (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { size: "xs", color: "muted", weight: "semibold", transform: "uppercase", className: "tracking-wide", children: "Gift message" }), _jsx(Text, { size: "sm", color: "primary", className: "min-w-0 whitespace-pre-wrap break-words", children: order.giftWrapMessage })] }))] }));
28
29
  }
@@ -215,6 +215,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
215
215
  canFilter: boolean;
216
216
  canSort: boolean;
217
217
  };
218
+ whatsappNotifyAddon: {
219
+ canFilter: boolean;
220
+ canSort: boolean;
221
+ };
218
222
  giftWrapAddon: {
219
223
  canFilter: boolean;
220
224
  canSort: boolean;
@@ -335,6 +339,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
335
339
  canFilter: boolean;
336
340
  canSort: boolean;
337
341
  };
342
+ whatsappNotifyAddon: {
343
+ canFilter: boolean;
344
+ canSort: boolean;
345
+ };
338
346
  giftWrapAddon: {
339
347
  canFilter: boolean;
340
348
  canSort: boolean;
@@ -1364,6 +1364,7 @@ OrderRepository.SELLER_SIEVE_FIELDS = {
1364
1364
  // Fulfilment flags. Filterable so "which of my orders need gift wrap?" is a
1365
1365
  // query against the orders themselves — the whole point of recording the
1366
1366
  // add-on on the order rather than keeping a side list of who asked for what.
1367
+ whatsappNotifyAddon: { canFilter: true, canSort: false },
1367
1368
  giftWrapAddon: { canFilter: true, canSort: false },
1368
1369
  shipmentProtectionAddon: { canFilter: true, canSort: false },
1369
1370
  saleProtectionAddon: { canFilter: true, canSort: false },
@@ -1396,6 +1397,7 @@ OrderRepository.ADMIN_SIEVE_FIELDS = {
1396
1397
  // Same fulfilment flags as SELLER_SIEVE_FIELDS — admin needs to answer
1397
1398
  // "who opted into WhatsApp updates?" when a status change goes out, without
1398
1399
  // maintaining a parallel tracker.
1400
+ whatsappNotifyAddon: { canFilter: true, canSort: false },
1399
1401
  giftWrapAddon: { canFilter: true, canSort: false },
1400
1402
  shipmentProtectionAddon: { canFilter: true, canSort: false },
1401
1403
  saleProtectionAddon: { canFilter: true, canSort: false },