@mohasinac/appkit 4.63.4 → 4.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/categories/adapters.js +13 -0
  4. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  5. package/dist/_internal/server/features/checkout/actions.js +22 -5
  6. package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
  7. package/dist/_internal/server/features/events/like-actions.js +67 -0
  8. package/dist/_internal/server/features/events/likes.d.ts +65 -0
  9. package/dist/_internal/server/features/events/likes.js +146 -0
  10. package/dist/_internal/server/features/orders/adapters.js +3 -1
  11. package/dist/_internal/server/features/seo/index.d.ts +3 -0
  12. package/dist/_internal/server/features/seo/index.js +2 -0
  13. package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
  14. package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
  15. package/dist/_internal/server/features/seo/robots.js +20 -1
  16. package/dist/_internal/server/features/seo/sitemap.js +125 -27
  17. package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
  18. package/dist/_internal/server/features/seo/static-params.js +228 -0
  19. package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
  20. package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
  21. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  22. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  23. package/dist/_internal/server/features/stores/adapters.js +88 -1
  24. package/dist/_internal/server/functions/scheduled.d.ts +16 -1
  25. package/dist/_internal/server/functions/scheduled.js +23 -1
  26. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  27. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  28. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  29. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  30. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  31. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  32. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  33. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  34. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  35. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  36. package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
  37. package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
  38. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  39. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  40. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  41. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  42. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
  43. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
  44. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  45. package/dist/_internal/server/notifications/channel-health.js +2 -2
  46. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  47. package/dist/_internal/server/notifications/send-guard.js +4 -2
  48. package/dist/_internal/shared/actions/action-registry.js +89 -0
  49. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  50. package/dist/_internal/shared/checkout/order-math.js +1 -1
  51. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  52. package/dist/_internal/shared/features/checkout/config.js +2 -0
  53. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  54. package/dist/_internal/shared/features/messaging/config.js +4 -1
  55. package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
  56. package/dist/_internal/shared/features/seller-standing/config.js +153 -0
  57. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  58. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  59. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  60. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  61. package/dist/_internal/shared/fees/calculator.js +7 -0
  62. package/dist/cli/index.js +2 -0
  63. package/dist/client.d.ts +2 -2
  64. package/dist/client.js +1 -1
  65. package/dist/constants/api-endpoints.d.ts +39 -0
  66. package/dist/constants/api-endpoints.js +16 -0
  67. package/dist/constants/notification-types.d.ts +1 -1
  68. package/dist/constants/notification-types.js +1 -1
  69. package/dist/core/integration-keys.d.ts +5 -0
  70. package/dist/core/integration-keys.js +10 -0
  71. package/dist/features/about/components/FeesView.js +6 -0
  72. package/dist/features/about/components/HelpPageView.js +9 -1
  73. package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
  74. package/dist/features/account/components/BecomeSellerView.js +2 -2
  75. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  76. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  77. package/dist/features/account/types/index.d.ts +2 -0
  78. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  79. package/dist/features/admin/actions/notification-actions.js +154 -9
  80. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  81. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  82. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  83. package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
  84. package/dist/features/admin/components/AdminStoreEditorView.js +31 -2
  85. package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
  86. package/dist/features/admin/components/AdminStoresView.js +59 -2
  87. package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
  88. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  89. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  90. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  91. package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
  92. package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
  93. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  94. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  95. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  96. package/dist/features/admin/schemas/firestore.js +8 -3
  97. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  98. package/dist/features/auth/checkout-value-otp.js +1 -1
  99. package/dist/features/auth/permissions/constants.d.ts +2 -2
  100. package/dist/features/auth/permissions/constants.js +2 -1
  101. package/dist/features/blog/components/BlogIndexPageView.js +2 -2
  102. package/dist/features/blog/components/BlogListView.js +2 -1
  103. package/dist/features/blog/components/BlogPostView.js +2 -1
  104. package/dist/features/blog/constants/category-badge.d.ts +33 -0
  105. package/dist/features/blog/constants/category-badge.js +40 -0
  106. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  107. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  108. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  109. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  110. package/dist/features/cart/repository/cart.repository.js +2 -1
  111. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  112. package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
  113. package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
  114. package/dist/features/categories/schemas/firestore.d.ts +25 -0
  115. package/dist/features/categories/utils/load-category-options.d.ts +33 -0
  116. package/dist/features/categories/utils/load-category-options.js +50 -0
  117. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  118. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  119. package/dist/features/events/components/EventCard.js +4 -12
  120. package/dist/features/events/components/EventInsights.d.ts +27 -0
  121. package/dist/features/events/components/EventInsights.js +10 -0
  122. package/dist/features/events/components/EventLikeButton.d.ts +21 -0
  123. package/dist/features/events/components/EventLikeButton.js +59 -0
  124. package/dist/features/events/components/EventLikersRail.d.ts +23 -0
  125. package/dist/features/events/components/EventLikersRail.js +54 -0
  126. package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
  127. package/dist/features/events/components/EventTypeBadge.js +7 -0
  128. package/dist/features/events/components/EventsListPageView.js +2 -2
  129. package/dist/features/events/components/SpinWheel.d.ts +41 -0
  130. package/dist/features/events/components/SpinWheel.js +128 -0
  131. package/dist/features/events/components/SpinWheelView.js +36 -15
  132. package/dist/features/events/constants/event-type-meta.d.ts +54 -0
  133. package/dist/features/events/constants/event-type-meta.js +109 -0
  134. package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
  135. package/dist/features/events/repository/event-likes.repository.js +151 -0
  136. package/dist/features/events/schemas/firestore.d.ts +111 -0
  137. package/dist/features/events/schemas/likes.d.ts +81 -0
  138. package/dist/features/events/schemas/likes.js +59 -0
  139. package/dist/features/events/types/index.d.ts +7 -0
  140. package/dist/features/events/utils/carousel-slide.d.ts +42 -0
  141. package/dist/features/events/utils/carousel-slide.js +174 -0
  142. package/dist/features/events/utils/event-charts.d.ts +65 -0
  143. package/dist/features/events/utils/event-charts.js +172 -0
  144. package/dist/features/faq/constants/help-guides.js +9 -0
  145. package/dist/features/faq/schemas/firestore.js +1 -0
  146. package/dist/features/faq/types/index.d.ts +19 -0
  147. package/dist/features/homepage/components/BrandsSection.js +25 -1
  148. package/dist/features/homepage/components/HeroCarousel.js +47 -10
  149. package/dist/features/homepage/lib/section-renderer.js +1 -1
  150. package/dist/features/homepage/types/index.d.ts +7 -0
  151. package/dist/features/orders/actions/refund-actions.js +1 -0
  152. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  153. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  154. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  155. package/dist/features/orders/repository/orders.repository.js +2 -0
  156. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  157. package/dist/features/orders/types/index.d.ts +5 -2
  158. package/dist/features/search/action-index/settings-entries.js +9 -0
  159. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  160. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  161. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  162. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  163. package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
  164. package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
  165. package/dist/features/stores/components/SellerStandingPanel.js +25 -0
  166. package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
  167. package/dist/features/stores/components/SellerTierBadge.js +19 -0
  168. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +33 -2
  169. package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
  170. package/dist/features/stores/components/StoreCreateForm.js +107 -0
  171. package/dist/features/stores/components/StoreGuideHubView.js +18 -5
  172. package/dist/features/stores/components/StoreHeader.js +2 -1
  173. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  174. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  175. package/dist/features/stores/repository/store.repository.js +52 -1
  176. package/dist/features/stores/schemas/firestore.d.ts +76 -7
  177. package/dist/features/stores/schemas/firestore.js +21 -6
  178. package/dist/features/stores/schemas/index.d.ts +88 -2
  179. package/dist/features/stores/schemas/index.js +14 -0
  180. package/dist/features/stores/schemas/store-create.d.ts +47 -0
  181. package/dist/features/stores/schemas/store-create.js +86 -0
  182. package/dist/features/stores/types/index.d.ts +42 -0
  183. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  184. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  185. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  186. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  187. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  188. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  189. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  190. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  191. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  192. package/dist/features/whatsapp-bot/components/index.js +2 -0
  193. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  194. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  195. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  196. package/dist/features/whatsapp-bot/index.js +3 -0
  197. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  198. package/dist/features/whatsapp-bot/manifest.js +13 -0
  199. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  200. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  201. package/dist/features/whatsapp-bot/server.js +7 -0
  202. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  203. package/dist/features/whatsapp-bot/types/index.js +1 -0
  204. package/dist/index.d.ts +23 -6
  205. package/dist/index.js +38 -12
  206. package/dist/next/routing/route-map.d.ts +6 -1
  207. package/dist/next/routing/route-map.js +14 -0
  208. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  209. package/dist/providers/db-firebase/base.repository.js +1 -1
  210. package/dist/repositories/index.d.ts +1 -1
  211. package/dist/repositories/index.js +1 -1
  212. package/dist/schemas/registry.d.ts +115 -0
  213. package/dist/schemas/registry.js +2 -1
  214. package/dist/security/pii-encrypt.js +1 -1
  215. package/dist/server.d.ts +15 -2
  216. package/dist/server.js +32 -2
  217. package/dist/styles.css +36 -1
  218. package/dist/tailwind-utilities.css +1 -1
  219. package/dist/ui/components/HorizontalScroller.js +15 -2
  220. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  221. package/dist/ui/components/NavigationLoader.js +79 -30
  222. package/dist/ui/components/PageHeroHeading.d.ts +40 -0
  223. package/dist/ui/components/PageHeroHeading.js +15 -0
  224. package/dist/ui/components/Typography.d.ts +3 -3
  225. package/dist/ui/components/Typography.js +59 -31
  226. package/dist/ui/components/Typography.style.css +35 -0
  227. package/dist/ui/index.d.ts +2 -0
  228. package/dist/ui/index.js +1 -0
  229. package/package.json +1 -1
  230. package/scripts/seed-cli.mjs +4 -2
  231. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  232. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Core: MONTHLY seller-standing rollup — grants and revokes the Trusted
3
+ * Seller tier, and with it early payout.
4
+ *
5
+ * ## What it writes
6
+ *
7
+ * `stores/{id}.standing` — the cached metrics (this IS the cache; the seller's
8
+ * progress panel, the public badge and the admin view all read it off the
9
+ * store document they already load, and none of them recomputes anything).
10
+ * On a transition it also writes `sellerTier` and `trustedSeller` TOGETHER.
11
+ *
12
+ * 🛑 `trustedSeller` moves MONEY. It is read by both payout sweeps
13
+ * (`weeklyPayoutEligibility`, `autoPayoutEligibility`) via
14
+ * `storeRepository.findTrustedStoreIds()`, and it means the store is paid at
15
+ * SHIPPING instead of at delivery — so the platform carries a lost parcel with
16
+ * the money already gone. That is why this job is the only automatic writer,
17
+ * why every transition gets an audit row, and why an admin can pin a store
18
+ * with `standing.tierLockedByAdmin`.
19
+ *
20
+ * 🛑 The flag's author wrote, at `store.repository.ts`: *"trust is granted by
21
+ * hand, one store at a time, on risk grounds. If this ever saturates, the
22
+ * grant has stopped being deliberate."* Automatic granting is exactly that
23
+ * case, accepted deliberately by the owner. The mitigations are real and must
24
+ * not be quietly dropped: both a distinct-BUYER and a distinct-ADDRESS floor,
25
+ * self-bought orders excluded, test data excluded, and the 500-store bound on
26
+ * `findTrustedStoreIds` left in place so saturation is visible.
27
+ */
28
+ import type { JobContext } from "../runtime/types";
29
+ export interface SellerStandingRollupResult {
30
+ /** Stores examined. */
31
+ examined: number;
32
+ /** Stores whose `standing` cache was written. */
33
+ written: number;
34
+ granted: string[];
35
+ revoked: string[];
36
+ /** Stores skipped because an admin pinned their tier. */
37
+ locked: number;
38
+ /** True when the month-end guard declined to run. */
39
+ skippedNotMonthEnd: boolean;
40
+ }
41
+ /**
42
+ * 🛑 THIS IS WHY THE CRON IS `0 2 28-31 * *` AND NOT `0 0 31 * *`.
43
+ *
44
+ * Cloud Scheduler uses unix-cron, which has no `L` (last-day) token. A cron on
45
+ * day 31 **never fires in February, April, June, September or November** — it
46
+ * would silently skip 5 of 12 months, and the symptom is not an error but a
47
+ * tier that updates "sometimes". So the schedule fires on each of the last four
48
+ * candidate days and this guard keeps exactly the real one.
49
+ *
50
+ * Uses UTC parts throughout, matching the scheduler's declared `timeZone: UTC`.
51
+ * A local-time implementation would disagree with the trigger near midnight.
52
+ */
53
+ export declare function isLastDayOfMonth(d: Date): boolean;
54
+ /** The next month-end at or after `d`, for the seller-facing `nextComputeAt`. */
55
+ export declare function nextLastDayOfMonth(d: Date): Date;
56
+ export interface SellerStandingRollupOptions {
57
+ /**
58
+ * Run regardless of the date. For the admin "recompute now" path and for
59
+ * verification — NOT for the schedule.
60
+ */
61
+ force?: boolean;
62
+ }
63
+ export declare function runSellerStandingRollup(ctx: JobContext, opts?: SellerStandingRollupOptions): Promise<SellerStandingRollupResult>;
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Core: MONTHLY seller-standing rollup — grants and revokes the Trusted
3
+ * Seller tier, and with it early payout.
4
+ *
5
+ * ## What it writes
6
+ *
7
+ * `stores/{id}.standing` — the cached metrics (this IS the cache; the seller's
8
+ * progress panel, the public badge and the admin view all read it off the
9
+ * store document they already load, and none of them recomputes anything).
10
+ * On a transition it also writes `sellerTier` and `trustedSeller` TOGETHER.
11
+ *
12
+ * 🛑 `trustedSeller` moves MONEY. It is read by both payout sweeps
13
+ * (`weeklyPayoutEligibility`, `autoPayoutEligibility`) via
14
+ * `storeRepository.findTrustedStoreIds()`, and it means the store is paid at
15
+ * SHIPPING instead of at delivery — so the platform carries a lost parcel with
16
+ * the money already gone. That is why this job is the only automatic writer,
17
+ * why every transition gets an audit row, and why an admin can pin a store
18
+ * with `standing.tierLockedByAdmin`.
19
+ *
20
+ * 🛑 The flag's author wrote, at `store.repository.ts`: *"trust is granted by
21
+ * hand, one store at a time, on risk grounds. If this ever saturates, the
22
+ * grant has stopped being deliberate."* Automatic granting is exactly that
23
+ * case, accepted deliberately by the owner. The mitigations are real and must
24
+ * not be quietly dropped: both a distinct-BUYER and a distinct-ADDRESS floor,
25
+ * self-bought orders excluded, test data excluded, and the 500-store bound on
26
+ * `findTrustedStoreIds` left in place so saturation is visible.
27
+ */
28
+ import { storeRepository } from "../../../../repositories/index.js";
29
+ import { ORDER_FIELDS, REVIEW_FIELDS } from "../../../../constants/field-names.js";
30
+ import { recordAdminAction } from "../../features/audit-log/actions.js";
31
+ import { AdminAuditActionValues } from "../../../../features/audit-log/schemas/firestore.js";
32
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions.js";
33
+ import { normalizeError } from "../../../../errors/normalize.js";
34
+ import { evaluateSellerStanding, SELLER_STANDING_DEFAULTS, SELLER_TIER, } from "../../../shared/features/seller-standing/config.js";
35
+ const EMPTY_RESULT = {
36
+ examined: 0,
37
+ written: 0,
38
+ granted: [],
39
+ revoked: [],
40
+ locked: 0,
41
+ skippedNotMonthEnd: true,
42
+ };
43
+ /** Firestore page size for the two windowed scans. */
44
+ const SCAN_PAGE = 500;
45
+ // ---------------------------------------------------------------------------
46
+ // The month-end guard
47
+ // ---------------------------------------------------------------------------
48
+ /**
49
+ * 🛑 THIS IS WHY THE CRON IS `0 2 28-31 * *` AND NOT `0 0 31 * *`.
50
+ *
51
+ * Cloud Scheduler uses unix-cron, which has no `L` (last-day) token. A cron on
52
+ * day 31 **never fires in February, April, June, September or November** — it
53
+ * would silently skip 5 of 12 months, and the symptom is not an error but a
54
+ * tier that updates "sometimes". So the schedule fires on each of the last four
55
+ * candidate days and this guard keeps exactly the real one.
56
+ *
57
+ * Uses UTC parts throughout, matching the scheduler's declared `timeZone: UTC`.
58
+ * A local-time implementation would disagree with the trigger near midnight.
59
+ */
60
+ export function isLastDayOfMonth(d) {
61
+ const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1));
62
+ return next.getUTCMonth() !== d.getUTCMonth();
63
+ }
64
+ /** The next month-end at or after `d`, for the seller-facing `nextComputeAt`. */
65
+ export function nextLastDayOfMonth(d) {
66
+ // Day 0 of the following month is the last day of this one.
67
+ const endOfThis = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
68
+ if (endOfThis.getTime() > d.getTime())
69
+ return endOfThis;
70
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 2, 0));
71
+ }
72
+ const emptyTally = () => ({
73
+ deliveredOrders: 0,
74
+ buyers: new Set(),
75
+ addresses: new Set(),
76
+ ratingSum: 0,
77
+ ratingCount: 0,
78
+ });
79
+ function toMetrics(t) {
80
+ return {
81
+ deliveredOrders: t.deliveredOrders,
82
+ distinctBuyers: t.buyers.size,
83
+ distinctAddresses: t.addresses.size,
84
+ ratedOrders: t.ratingCount,
85
+ /*
86
+ * 0 when there are no ratings, never NaN. `ratingSum / 0` is NaN, and NaN
87
+ * fails every `>=` comparison — which happens to give the right verdict
88
+ * here but writes NaN into Firestore, where it is rejected outright on
89
+ * some paths and renders as "NaN" in the seller's panel on others.
90
+ */
91
+ averageRating: t.ratingCount === 0 ? 0 : t.ratingSum / t.ratingCount,
92
+ };
93
+ }
94
+ function isTestRow(data) {
95
+ /*
96
+ * Both spellings. `isTestData` is the legacy boolean and `seedPurpose` is
97
+ * the newer field it is derived from; a row may carry either, so testing one
98
+ * would let the other through and let a tester fixture count toward a real
99
+ * money-moving grant.
100
+ */
101
+ if (data.isTestData === true)
102
+ return true;
103
+ return data.seedPurpose === "test" || data.seedPurpose === "tester";
104
+ }
105
+ export async function runSellerStandingRollup(ctx, opts = {}) {
106
+ if (!opts.force && !isLastDayOfMonth(ctx.now)) {
107
+ ctx.logger.info("sellerStandingRollup: not month-end, skipping", {
108
+ now: ctx.now.toISOString(),
109
+ });
110
+ return { ...EMPTY_RESULT };
111
+ }
112
+ /*
113
+ * 🛑 The CONSTANT, not a settings read.
114
+ *
115
+ * This briefly folded an admin override from `siteSettings.sellerStanding`
116
+ * onto the defaults. That made the job disagree with `/seller-guide` and
117
+ * `/help/seller-badges`, which render their criteria from the code defaults
118
+ * — so retuning the numbers would have changed what the tier is granted on
119
+ * while both public pages went on promising the old figures. One constant,
120
+ * every surface, agreeing by construction.
121
+ */
122
+ const thresholds = SELLER_STANDING_DEFAULTS;
123
+ const cutoff = new Date(ctx.now.getTime() - thresholds.windowDays * 24 * 60 * 60 * 1000);
124
+ const nextComputeAt = nextLastDayOfMonth(ctx.now);
125
+ ctx.logger.info("sellerStandingRollup: starting", {
126
+ windowDays: thresholds.windowDays,
127
+ cutoff: cutoff.toISOString(),
128
+ forced: Boolean(opts.force),
129
+ });
130
+ // ── Stores ───────────────────────────────────────────────────────────────
131
+ /*
132
+ * Enumerate STORES, not the orders' distinct storeIds. A store that sold
133
+ * nothing this window must still be evaluated, because that is precisely the
134
+ * store whose tier should be REVOKED — a reconciler built from the events
135
+ * that exist can only ever add (Root Cause #102).
136
+ */
137
+ const stores = new Map();
138
+ const storeSnap = await ctx.db
139
+ .collection("stores")
140
+ .select("ownerId", "status", "sellerTier", "trustedSeller", "tierLockedByAdmin")
141
+ .get();
142
+ for (const doc of storeSnap.docs) {
143
+ const d = doc.data();
144
+ stores.set(doc.id, {
145
+ id: doc.id,
146
+ ownerId: d.ownerId,
147
+ status: d.status,
148
+ sellerTier: d.sellerTier,
149
+ trustedSeller: d.trustedSeller,
150
+ tierLockedByAdmin: d.tierLockedByAdmin === true,
151
+ });
152
+ }
153
+ const tallies = new Map();
154
+ const tally = (storeId) => {
155
+ let t = tallies.get(storeId);
156
+ if (!t) {
157
+ t = emptyTally();
158
+ tallies.set(storeId, t);
159
+ }
160
+ return t;
161
+ };
162
+ // ── Orders in the window ─────────────────────────────────────────────────
163
+ /*
164
+ * 🛑 Windowed on `orderDate`, which is REQUIRED, not on `deliveryDate`,
165
+ * which is optional and written by exactly one job (`obligationSweep`). A
166
+ * Firestore inequality EXCLUDES every document lacking the field, so a range
167
+ * on `deliveryDate` would silently drop every order a seller marked
168
+ * delivered by hand — returning a confident undercount with no error
169
+ * anywhere (Root Cause #100).
170
+ *
171
+ * One collection-wide windowed scan rather than a query per store: a
172
+ * per-store `storeId == X AND orderDate >= cutoff` needs a composite index,
173
+ * and a missing one fails as FAILED_PRECONDITION that every caller here
174
+ * would have to not-swallow (Root Cause #2). A single-field inequality is
175
+ * served by the automatic index. `status` is filtered in memory for the same
176
+ * reason.
177
+ */
178
+ let orderCursor = null;
179
+ let ordersScanned = 0;
180
+ for (;;) {
181
+ let q = ctx.db
182
+ .collection("orders")
183
+ .where(ORDER_FIELDS.ORDER_DATE, ">=", cutoff)
184
+ .orderBy(ORDER_FIELDS.ORDER_DATE, "asc")
185
+ .limit(SCAN_PAGE);
186
+ if (orderCursor)
187
+ q = q.startAfter(orderCursor);
188
+ const snap = await q.get();
189
+ if (snap.empty)
190
+ break;
191
+ for (const doc of snap.docs) {
192
+ const d = doc.data();
193
+ ordersScanned += 1;
194
+ if (d.status !== "delivered")
195
+ continue;
196
+ if (isTestRow(d))
197
+ continue;
198
+ const storeId = d.storeId;
199
+ if (!storeId)
200
+ continue;
201
+ const store = stores.get(storeId);
202
+ if (!store)
203
+ continue;
204
+ const userId = d.userId;
205
+ /*
206
+ * 🛑 Self-bought orders do not count. Without this a seller reaches the
207
+ * tier — and early payout — by buying from themselves 25 times, which is
208
+ * the cheapest possible attack on a criterion that releases money early.
209
+ */
210
+ if (userId && store.ownerId && userId === store.ownerId)
211
+ continue;
212
+ const t = tally(storeId);
213
+ t.deliveredOrders += 1;
214
+ if (userId)
215
+ t.buyers.add(userId);
216
+ /*
217
+ * 🛑 `deliveryAddressId` — the `addresses` row this order was taken
218
+ * from. It is a provenance pointer, NOT PII and NOT encrypted, so
219
+ * counting distinct values needs no decryption and no fingerprint of
220
+ * our own.
221
+ *
222
+ * Orders predating the delivery snapshot carry none and contribute
223
+ * nothing to this criterion. That is forward-only by design: inventing
224
+ * a key from `deliveryPincode` instead would count two different
225
+ * households in one pincode as one address, and the encrypted
226
+ * `deliveryAddressLine1` cannot be read here at all.
227
+ *
228
+ * Honest about its limit: a determined seller can save the same physical
229
+ * place as several address rows. The distinct-BUYER floor is the real
230
+ * defence; this one raises the cost.
231
+ */
232
+ if (d.deliveryAddressId)
233
+ t.addresses.add(d.deliveryAddressId);
234
+ }
235
+ orderCursor = snap.docs[snap.docs.length - 1];
236
+ if (snap.size < SCAN_PAGE)
237
+ break;
238
+ }
239
+ // ── Reviews in the window ────────────────────────────────────────────────
240
+ /*
241
+ * Windowed on `createdAt`, which every document carries via `BaseDocument`.
242
+ * `ReviewDocument` has no `publishedAt` field at all — CLAUDE.md's seed
243
+ * table says it does, and that is stale.
244
+ */
245
+ let reviewCursor = null;
246
+ for (;;) {
247
+ let q = ctx.db
248
+ .collection("reviews")
249
+ .where(REVIEW_FIELDS.CREATED_AT, ">=", cutoff)
250
+ .orderBy(REVIEW_FIELDS.CREATED_AT, "asc")
251
+ .limit(SCAN_PAGE);
252
+ if (reviewCursor)
253
+ q = q.startAfter(reviewCursor);
254
+ const snap = await q.get();
255
+ if (snap.empty)
256
+ break;
257
+ for (const doc of snap.docs) {
258
+ const d = doc.data();
259
+ if (d.status !== "approved")
260
+ continue;
261
+ if (isTestRow(d))
262
+ continue;
263
+ const storeId = d.storeId;
264
+ if (!storeId || !stores.has(storeId))
265
+ continue;
266
+ const rating = Number(d.rating);
267
+ if (!Number.isFinite(rating))
268
+ continue;
269
+ const t = tally(storeId);
270
+ t.ratingSum += rating;
271
+ t.ratingCount += 1;
272
+ }
273
+ reviewCursor = snap.docs[snap.docs.length - 1];
274
+ if (snap.size < SCAN_PAGE)
275
+ break;
276
+ }
277
+ // ── Evaluate + write ─────────────────────────────────────────────────────
278
+ /*
279
+ * 🛑 Through the REPOSITORY, not `ctx.db.update()`.
280
+ *
281
+ * `adminUpdate` runs `withStoreHistory`, and `sellerTier` / `trustedSeller`
282
+ * are both in `STORE_TRACKED_FIELDS` — so a transition earns a
283
+ * `statusHistory` entry stamped `actorRole: "system"`. A raw document write
284
+ * would move money with no timeline entry, which is the one thing the
285
+ * timeline exists for on this field.
286
+ *
287
+ * `prior` is the projected shell rather than a full document, which is safe
288
+ * and deliberate: `withHistory` diffs only `tracked` fields that appear in
289
+ * the PATCH, and the patch only ever carries `standing` (untracked),
290
+ * `sellerTier` and `trustedSeller` — both of which the shell selects. The
291
+ * cast says exactly that and nothing more.
292
+ */
293
+ const writeStore = (store, patch) => storeRepository.adminUpdate(store.id, patch, { actor: { role: "system" }, trigger: "sellerStandingRollup" }, store);
294
+ const granted = [];
295
+ const revoked = [];
296
+ let written = 0;
297
+ let locked = 0;
298
+ for (const store of stores.values()) {
299
+ const metrics = toMetrics(tallies.get(store.id) ?? emptyTally());
300
+ const verdict = evaluateSellerStanding(metrics, thresholds);
301
+ /*
302
+ * `standing` is a pure MEASUREMENT — no admin state in it. The pin lives
303
+ * at the top level precisely so this object can be rebuilt wholesale every
304
+ * month without carrying a decision forward or needing to be merged.
305
+ */
306
+ const standing = {
307
+ computedAt: ctx.now,
308
+ nextComputeAt,
309
+ windowDays: thresholds.windowDays,
310
+ ...metrics,
311
+ met: verdict.met,
312
+ };
313
+ /*
314
+ * An admin-pinned store still gets a REFRESHED `standing` — the seller
315
+ * should see their real figures — but its tier is left exactly where the
316
+ * admin put it. Writing the metrics while not acting on them is the whole
317
+ * point of the lock.
318
+ */
319
+ if (store.tierLockedByAdmin) {
320
+ locked += 1;
321
+ await writeStore(store, { standing });
322
+ written += 1;
323
+ continue;
324
+ }
325
+ /*
326
+ * 🛑 A store that is not ACTIVE can never hold the tier. Suspension
327
+ * already clears it immediately (see `storeRepository.setStatus`), and
328
+ * this is the belt: a suspended store must not be re-granted early payout
329
+ * by a month-end run just because its 90-day history still looks good.
330
+ */
331
+ const eligible = store.status === "active" && verdict.tier === SELLER_TIER.TRUSTED;
332
+ const currentTier = store.sellerTier ?? SELLER_TIER.SELLER;
333
+ const nextTier = eligible ? SELLER_TIER.TRUSTED : SELLER_TIER.SELLER;
334
+ const update = { standing };
335
+ const isTransition = nextTier !== currentTier || Boolean(store.trustedSeller) !== eligible;
336
+ if (isTransition) {
337
+ update.sellerTier = nextTier;
338
+ update.trustedSeller = eligible;
339
+ if (eligible)
340
+ granted.push(store.id);
341
+ else
342
+ revoked.push(store.id);
343
+ }
344
+ await writeStore(store, update);
345
+ written += 1;
346
+ if (!isTransition)
347
+ continue;
348
+ /*
349
+ * 🛑 Audit and notify on a REAL transition only. Re-saving the monthly
350
+ * `standing` on an unchanged store would otherwise write an audit row
351
+ * describing a decision nobody made and email a seller about nothing —
352
+ * and a log of non-events is how a log stops being read. Same rule the
353
+ * admin PATCH route applies to this exact field.
354
+ */
355
+ void recordAdminAction({
356
+ actorUid: "system",
357
+ action: AdminAuditActionValues.STORE_TRUST_CHANGE,
358
+ targetType: "store",
359
+ targetId: store.id,
360
+ targetLabel: store.id,
361
+ reason: eligible
362
+ ? "Met every Trusted Seller criterion at the monthly recompute"
363
+ : "Fell below a Trusted Seller criterion at the monthly recompute",
364
+ metadata: {
365
+ trustedSeller: eligible,
366
+ previous: Boolean(store.trustedSeller),
367
+ sellerTier: nextTier,
368
+ trigger: "sellerStandingRollup",
369
+ ...metrics,
370
+ },
371
+ });
372
+ if (!store.ownerId)
373
+ continue;
374
+ /*
375
+ * The seller is told, because the consequence is concrete in both
376
+ * directions: gaining the tier changes when they get paid, and losing it
377
+ * changes it back. Fire-and-forget — a notification failure must not
378
+ * abort a sweep that has already written the tier.
379
+ */
380
+ void sendNotification({
381
+ userId: store.ownerId,
382
+ type: "system",
383
+ priority: "normal",
384
+ title: eligible ? "You're now a Trusted Seller" : "Trusted Seller status paused",
385
+ message: eligible
386
+ ? "Your store met every Trusted Seller criterion this month. Your badge is live and your payouts now start when you ship, rather than on delivery."
387
+ : `Your store no longer meets every Trusted Seller criterion, so the badge is paused and payouts return to starting on delivery. ${verdict.unmetCount === 1 ? "One criterion" : `${verdict.unmetCount} criteria`} to regain it — see your seller guide for the current figures.`,
388
+ }).catch((err) => {
389
+ const e = normalizeError(err);
390
+ ctx.logger.warn("sellerStandingRollup: notification failed", {
391
+ storeId: store.id,
392
+ error: e.message,
393
+ });
394
+ });
395
+ }
396
+ ctx.logger.info("sellerStandingRollup: complete", {
397
+ examined: stores.size,
398
+ ordersScanned,
399
+ written,
400
+ granted: granted.length,
401
+ revoked: revoked.length,
402
+ locked,
403
+ });
404
+ return {
405
+ examined: stores.size,
406
+ written,
407
+ granted,
408
+ revoked,
409
+ locked,
410
+ skippedNotMonthEnd: false,
411
+ };
412
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Core: async WhatsApp notification dispatch (`whatsappNotify` job).
3
+ *
4
+ * `sendNotification()` (appkit/src/features/admin/actions/notification-actions.ts)
5
+ * no longer calls the Meta Cloud API synchronously in-request — it enqueues
6
+ * this job instead (CLAUDE.md Rule #6 async job primitive) and returns
7
+ * immediately. This runner does the actual send, with a bounded retry, and
8
+ * writes the outcome back onto `notifications/{notificationId}` so admins
9
+ * have visibility beyond the `jobs/{jobId}` doc itself.
10
+ *
11
+ * Retry note: `sendWhatsAppTemplateMessage`/`sendWhatsAppBusinessMessage`
12
+ * return a plain boolean (not a status code), so this runner cannot cleanly
13
+ * distinguish a transient 5xx from a terminal 4xx (bad token, unknown
14
+ * template) the way a status-code-aware client could. It retries any
15
+ * failure up to MAX_ATTEMPTS with a short backoff — an acceptable
16
+ * simplification since this runs off the critical path, well inside the
17
+ * Firebase Function's 300s budget, and a terminal error just burns two
18
+ * extra fast failures before giving up.
19
+ */
20
+ import type { JobContext } from "../runtime/types";
21
+ import type { JobRunResult } from "./jobRunners";
22
+ export interface WhatsAppNotifyPayload {
23
+ toPhone: string;
24
+ title: string;
25
+ message: string;
26
+ type: string;
27
+ /** Approved Meta template name for this notification type, if configured. */
28
+ templateName?: string;
29
+ /** BCP-47 language code the template was approved in. Defaults to "en". */
30
+ templateLanguage?: string;
31
+ notificationId: string;
32
+ phoneNumberId: string;
33
+ accessToken: string;
34
+ }
35
+ export declare function runWhatsAppNotify(payload: WhatsAppNotifyPayload, ctx: JobContext): Promise<JobRunResult>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Core: async WhatsApp notification dispatch (`whatsappNotify` job).
3
+ *
4
+ * `sendNotification()` (appkit/src/features/admin/actions/notification-actions.ts)
5
+ * no longer calls the Meta Cloud API synchronously in-request — it enqueues
6
+ * this job instead (CLAUDE.md Rule #6 async job primitive) and returns
7
+ * immediately. This runner does the actual send, with a bounded retry, and
8
+ * writes the outcome back onto `notifications/{notificationId}` so admins
9
+ * have visibility beyond the `jobs/{jobId}` doc itself.
10
+ *
11
+ * Retry note: `sendWhatsAppTemplateMessage`/`sendWhatsAppBusinessMessage`
12
+ * return a plain boolean (not a status code), so this runner cannot cleanly
13
+ * distinguish a transient 5xx from a terminal 4xx (bad token, unknown
14
+ * template) the way a status-code-aware client could. It retries any
15
+ * failure up to MAX_ATTEMPTS with a short backoff — an acceptable
16
+ * simplification since this runs off the critical path, well inside the
17
+ * Firebase Function's 300s budget, and a terminal error just burns two
18
+ * extra fast failures before giving up.
19
+ */
20
+ import { normalizeError } from "../../../../errors/normalize.js";
21
+ import { notificationRepository } from "../../../../features/admin/repository/notification.repository.js";
22
+ import { sendWhatsAppTemplateMessage, sendWhatsAppBusinessMessage, } from "../../../../features/whatsapp-bot/helpers/whatsapp.js";
23
+ import { isChannelHealthy, recordChannelOutcome } from "../../notifications/channel-health.js";
24
+ import { guardSend, logSuppressedSend } from "../../notifications/send-guard.js";
25
+ const MAX_ATTEMPTS = 3;
26
+ const RETRY_DELAY_MS = 1500;
27
+ function delay(ms) {
28
+ return new Promise((resolve) => setTimeout(resolve, ms));
29
+ }
30
+ export async function runWhatsAppNotify(payload, ctx) {
31
+ const { toPhone, title, message, type, templateName, templateLanguage, notificationId, phoneNumberId, accessToken } = payload;
32
+ /*
33
+ * The messaging guard, applied HERE rather than inside
34
+ * `sendWhatsAppBusinessMessage` itself.
35
+ *
36
+ * 🛑 That placement is deliberate and must not be "improved" by pushing a
37
+ * required guard parameter down into the sender. `features/whatsapp-bot/helpers/whatsapp.ts`
38
+ * is re-exported from `appkit/src/index.ts` (via `isAdminNumber`), and
39
+ * `guardSend` reaches `firebase-admin` through the settings and budget
40
+ * repositories. A static import of it from that file would put
41
+ * `firebase-admin` in the main entry's import graph, which is precisely the
42
+ * Turbopack client-bundle trap in Root Cause #6/#24 — it builds fine locally
43
+ * on webpack and fails every page in production.
44
+ *
45
+ * So the senders stay pure and each of the three real call sites guards
46
+ * itself; `scripts/audit-unguarded-send.mjs` enforces that a file calling a
47
+ * WhatsApp sender also calls `guardSend`.
48
+ *
49
+ * Note this job is only ever reached for an email/WhatsApp-ELIGIBLE type —
50
+ * `sendNotification` returns before enqueuing otherwise — so this is the
51
+ * ceiling and kill-switch check, not the type check.
52
+ */
53
+ const guard = {
54
+ channel: "whatsapp",
55
+ feature: type,
56
+ audience: "user",
57
+ };
58
+ const decision = await guardSend(guard);
59
+ if (!decision.allow) {
60
+ logSuppressedSend(guard, decision);
61
+ await notificationRepository
62
+ .update(notificationId, { whatsappStatus: "skipped" })
63
+ .catch((err) => {
64
+ void normalizeError(err);
65
+ });
66
+ return {
67
+ summary: { total: 1, succeeded: 0, skipped: 1, failed: 0 },
68
+ succeeded: [],
69
+ skipped: [notificationId],
70
+ failed: [],
71
+ };
72
+ }
73
+ if (!(await isChannelHealthy("whatsapp"))) {
74
+ ctx.logger.warn("whatsappNotify: WhatsApp channel circuit is open — skipping send", { notificationId });
75
+ await notificationRepository
76
+ .update(notificationId, { whatsappStatus: "skipped" })
77
+ .catch((err) => {
78
+ void normalizeError(err);
79
+ });
80
+ return {
81
+ summary: { total: 1, succeeded: 0, skipped: 1, failed: 0 },
82
+ succeeded: [],
83
+ skipped: [notificationId],
84
+ failed: [],
85
+ };
86
+ }
87
+ let sent = false;
88
+ let lastError;
89
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS && !sent; attempt++) {
90
+ try {
91
+ sent = templateName
92
+ ? await sendWhatsAppTemplateMessage({
93
+ toPhone,
94
+ phoneNumberId,
95
+ accessToken,
96
+ templateName,
97
+ languageCode: templateLanguage ?? "en",
98
+ bodyParams: [title, message],
99
+ })
100
+ : await sendWhatsAppBusinessMessage({
101
+ toPhone,
102
+ phoneNumberId,
103
+ accessToken,
104
+ message: `*${title}*\n${message}`,
105
+ });
106
+ if (!sent)
107
+ lastError = "Meta WhatsApp API returned a non-OK response";
108
+ }
109
+ catch (err) {
110
+ void normalizeError(err);
111
+ lastError = err instanceof Error ? err.message : String(err);
112
+ }
113
+ if (!sent && attempt < MAX_ATTEMPTS) {
114
+ ctx.logger.warn("whatsappNotify: send attempt failed, retrying", { notificationId, attempt, error: lastError ?? "" });
115
+ await delay(RETRY_DELAY_MS * attempt);
116
+ }
117
+ }
118
+ if (!templateName) {
119
+ ctx.logger.warn("whatsappNotify: no approved template configured for this notification type — sent as free-form text, which Meta rejects outside the 24h customer-service window", { notificationId });
120
+ }
121
+ await recordChannelOutcome("whatsapp", sent);
122
+ await notificationRepository
123
+ .update(notificationId, { whatsappStatus: sent ? "sent" : "failed" })
124
+ .catch((err) => {
125
+ void normalizeError(err);
126
+ ctx.logger.warn("whatsappNotify: failed to write back whatsappStatus onto notification doc", {
127
+ notificationId,
128
+ error: err instanceof Error ? err.message : String(err),
129
+ });
130
+ });
131
+ if (!sent) {
132
+ throw new Error(lastError ?? "WhatsApp send failed after retries");
133
+ }
134
+ return {
135
+ summary: { total: 1, succeeded: 1, skipped: 0, failed: 0 },
136
+ succeeded: [notificationId],
137
+ skipped: [],
138
+ failed: [],
139
+ };
140
+ }
@@ -32,6 +32,7 @@ export { hardBanReinstatementHandler } from "./hardBanReinstatement";
32
32
  export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove";
33
33
  export { productStatsSyncHandler } from "./productStatsSync";
34
34
  export { revenueRollupHandler } from "./revenueRollup";
35
+ export { sellerStandingRollupHandler } from "./sellerStandingRollup";
35
36
  export { priceIndexRollupHandler } from "./priceIndexRollup";
36
37
  export { marketPriceRollupHandler } from "./marketPriceRollup";
37
38
  export { searchIndexBuildHandler } from "./searchIndexBuild";
@@ -32,6 +32,7 @@ export { hardBanReinstatementHandler } from "./hardBanReinstatement.js";
32
32
  export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove.js";
33
33
  export { productStatsSyncHandler } from "./productStatsSync.js";
34
34
  export { revenueRollupHandler } from "./revenueRollup.js";
35
+ export { sellerStandingRollupHandler } from "./sellerStandingRollup.js";
35
36
  export { priceIndexRollupHandler } from "./priceIndexRollup.js";
36
37
  export { marketPriceRollupHandler } from "./marketPriceRollup.js";
37
38
  export { searchIndexBuildHandler } from "./searchIndexBuild.js";