@mohasinac/appkit 4.43.0 → 4.44.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 (220) hide show
  1. package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
  2. package/dist/_internal/server/features/categories/adapters.js +235 -0
  3. package/dist/_internal/server/features/checkout/actions.js +161 -22
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
  5. package/dist/_internal/server/features/orders/adapters.js +21 -0
  6. package/dist/_internal/server/features/refunds/actions.js +1 -1
  7. package/dist/_internal/server/features/seo/sitemap.js +54 -15
  8. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  9. package/dist/_internal/server/features/stores/adapters.js +6 -0
  10. package/dist/_internal/server/functions/scheduled.d.ts +4 -2
  11. package/dist/_internal/server/functions/scheduled.js +64 -6
  12. package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
  13. package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
  14. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
  15. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
  16. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
  17. package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
  18. package/dist/_internal/server/jobs/core/onCategoryWrite.js +54 -0
  19. package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
  20. package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
  21. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
  22. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
  23. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
  24. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
  25. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
  26. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
  27. package/dist/_internal/server/jobs/handlers/index.js +3 -1
  28. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
  29. package/dist/_internal/server/jobs/handlers/messages.js +5 -2
  30. package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
  31. package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
  32. package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
  33. package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
  34. package/dist/_internal/shared/actions/action-registry.js +34 -0
  35. package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
  36. package/dist/_internal/shared/checkout/order-math.js +194 -7
  37. package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
  38. package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
  39. package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
  40. package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
  41. package/dist/_internal/shared/fees/calculator.d.ts +105 -0
  42. package/dist/_internal/shared/fees/calculator.js +143 -0
  43. package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
  44. package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
  45. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
  46. package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
  47. package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
  48. package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
  49. package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
  50. package/dist/_internal/shared/templating/placeholders.js +73 -0
  51. package/dist/client.d.ts +2 -0
  52. package/dist/client.js +1 -0
  53. package/dist/constants/api-endpoints.d.ts +42 -0
  54. package/dist/constants/api-endpoints.js +14 -0
  55. package/dist/features/admin/actions/admin-actions.d.ts +1 -1
  56. package/dist/features/admin/actions/admin-actions.js +1 -1
  57. package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
  58. package/dist/features/admin/components/AdminProductEditorView.js +8 -1
  59. package/dist/features/admin/components/AdminProductsView.js +78 -7
  60. package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
  61. package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
  62. package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
  63. package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
  64. package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
  65. package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
  66. package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
  67. package/dist/features/auth/schemas/firestore.d.ts +0 -1
  68. package/dist/features/blog/schemas/firestore.d.ts +2 -2
  69. package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
  70. package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
  71. package/dist/features/categories/api/[id]/description-template/route.js +252 -0
  72. package/dist/features/categories/api/route.js +156 -11
  73. package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
  74. package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
  75. package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
  76. package/dist/features/categories/constants/listing-categories.d.ts +17 -0
  77. package/dist/features/categories/constants/listing-categories.js +53 -0
  78. package/dist/features/categories/hooks/useCategoryTree.js +28 -2
  79. package/dist/features/categories/repository/categories.repository.d.ts +62 -11
  80. package/dist/features/categories/repository/categories.repository.js +178 -35
  81. package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
  82. package/dist/features/categories/repository/price-index.repository.js +85 -0
  83. package/dist/features/categories/schemas/category-content.d.ts +206 -0
  84. package/dist/features/categories/schemas/category-content.js +28 -0
  85. package/dist/features/categories/schemas/firestore.d.ts +74 -5
  86. package/dist/features/categories/schemas/firestore.js +74 -3
  87. package/dist/features/categories/schemas/index.d.ts +1047 -0
  88. package/dist/features/categories/schemas/index.js +96 -0
  89. package/dist/features/categories/server.d.ts +1 -0
  90. package/dist/features/categories/server.js +7 -0
  91. package/dist/features/categories/types/index.d.ts +9 -0
  92. package/dist/features/events/schemas/firestore.d.ts +2 -2
  93. package/dist/features/faq/schemas/firestore.d.ts +2 -1
  94. package/dist/features/faq/schemas/firestore.js +20 -8
  95. package/dist/features/orders/schemas/firestore.d.ts +70 -0
  96. package/dist/features/orders/types/index.d.ts +55 -0
  97. package/dist/features/products/columns/index.d.ts +0 -1
  98. package/dist/features/products/columns/index.js +5 -1
  99. package/dist/features/products/components/ProductFilters.d.ts +0 -9
  100. package/dist/features/products/components/ProductForm.d.ts +27 -1
  101. package/dist/features/products/components/ProductForm.js +26 -12
  102. package/dist/features/products/constants/sieve.d.ts +0 -6
  103. package/dist/features/products/constants/sieve.js +35 -2
  104. package/dist/features/products/repository/products.repository.d.ts +115 -1
  105. package/dist/features/products/repository/products.repository.js +491 -39
  106. package/dist/features/products/schemas/firestore.d.ts +65 -5
  107. package/dist/features/products/schemas/firestore.js +9 -0
  108. package/dist/features/products/schemas/product-features.d.ts +140 -2
  109. package/dist/features/products/schemas/product-features.js +62 -1
  110. package/dist/features/promotions/repository/coupons.repository.js +6 -1
  111. package/dist/features/promotions/schemas/firestore.d.ts +44 -1
  112. package/dist/features/promotions/schemas/firestore.js +76 -2
  113. package/dist/features/seller/actions/seller-actions.js +17 -2
  114. package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
  115. package/dist/features/seller/components/PrintCenterView.js +3 -2
  116. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
  117. package/dist/features/seller/components/SellerOrdersView.js +6 -1
  118. package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
  119. package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
  120. package/dist/features/seller/components/SellerProductShell.js +101 -9
  121. package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
  122. package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
  123. package/dist/features/seller/components/SellerProductsView.js +108 -3
  124. package/dist/features/seller/types/index.d.ts +17 -0
  125. package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
  126. package/dist/features/seller/utils/product-draft-mapping.js +17 -0
  127. package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
  128. package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
  129. package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
  130. package/dist/features/stores/schemas/firestore.d.ts +31 -2
  131. package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
  132. package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
  133. package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
  134. package/dist/features/tax-codes/schemas/firestore.js +82 -0
  135. package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
  136. package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
  137. package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
  138. package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
  139. package/dist/features/tester/actions/index.d.ts +0 -1
  140. package/dist/features/tester/actions/index.js +0 -1
  141. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
  142. package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
  143. package/dist/features/tester/components/index.d.ts +0 -4
  144. package/dist/features/tester/components/index.js +0 -2
  145. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
  146. package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
  147. package/dist/features/tester/schemas/firestore.d.ts +0 -6
  148. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
  149. package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
  150. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
  151. package/dist/features/tester/seed-data/authored/index.js +0 -2
  152. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
  153. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +8 -27
  154. package/dist/index.d.ts +21 -7
  155. package/dist/index.js +53 -7
  156. package/dist/next/routing/route-map.d.ts +2 -4
  157. package/dist/next/routing/route-map.js +1 -2
  158. package/dist/repositories/index.d.ts +1 -0
  159. package/dist/repositories/index.js +1 -0
  160. package/dist/schemas/registry.d.ts +506 -0
  161. package/dist/seed/_helpers/brand-rows.d.ts +35 -0
  162. package/dist/seed/_helpers/brand-rows.js +576 -0
  163. package/dist/seed/_helpers/category-content.d.ts +39 -0
  164. package/dist/seed/_helpers/category-content.js +634 -0
  165. package/dist/seed/_helpers/category-forest.d.ts +2 -0
  166. package/dist/seed/_helpers/category-forest.js +585 -243
  167. package/dist/seed/_helpers/category-models.d.ts +5 -0
  168. package/dist/seed/_helpers/category-models.js +1364 -0
  169. package/dist/seed/_helpers/category-tree.js +17 -1
  170. package/dist/seed/_helpers/character-features.d.ts +10 -0
  171. package/dist/seed/_helpers/character-features.js +118 -0
  172. package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
  173. package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
  174. package/dist/seed/categories-seed-data.js +147 -169
  175. package/dist/seed/index.d.ts +1 -0
  176. package/dist/seed/index.js +1 -0
  177. package/dist/seed/manifest.d.ts +1 -1
  178. package/dist/seed/manifest.js +5 -0
  179. package/dist/seed/product-features-seed-data.d.ts +1 -8
  180. package/dist/seed/product-features-seed-data.js +358 -154
  181. package/dist/seed/products-art-seed-data.js +16 -4
  182. package/dist/seed/products-auctions-seed-data.js +6 -6
  183. package/dist/seed/products-classifieds-seed-data.js +17 -7
  184. package/dist/seed/products-digital-codes-seed-data.js +12 -12
  185. package/dist/seed/products-preorders-seed-data.js +8 -8
  186. package/dist/seed/products-prize-draws-seed-data.js +25 -8
  187. package/dist/seed/products-standard-seed-data.js +10 -60
  188. package/dist/seed/products-stickers-seed-data.js +6 -6
  189. package/dist/seed/site-settings-seed-data.js +24 -0
  190. package/dist/seed/store-extensions-seed-data.js +10 -4
  191. package/dist/seed/tax-codes-seed-data.d.ts +34 -0
  192. package/dist/seed/tax-codes-seed-data.js +100 -0
  193. package/dist/seed/test-data-ttl.d.ts +29 -0
  194. package/dist/seed/test-data-ttl.js +31 -0
  195. package/dist/server-entry.d.ts +1 -1
  196. package/dist/server-entry.js +6 -1
  197. package/dist/server.d.ts +2 -1
  198. package/dist/server.js +5 -2
  199. package/dist/styles.css +40 -0
  200. package/dist/ui/components/PaginatedSelect.d.ts +14 -0
  201. package/dist/ui/components/PaginatedSelect.js +0 -0
  202. package/dist/ui/components/PaginatedSelect.style.css +40 -0
  203. package/dist/utils/physical-location.d.ts +23 -0
  204. package/dist/utils/physical-location.js +52 -0
  205. package/dist/utils/search-txt-builders.d.ts +20 -0
  206. package/dist/utils/search-txt-builders.js +36 -0
  207. package/package.json +1 -1
  208. package/scripts/seed-cli.mjs +4 -1
  209. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
  210. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
  211. package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
  212. package/dist/features/tester/actions/leaderboard-actions.js +0 -5
  213. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
  214. package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
  215. package/dist/features/tester/components/TesterHubView.d.ts +0 -5
  216. package/dist/features/tester/components/TesterHubView.js +0 -110
  217. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
  218. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
  219. package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
  220. package/dist/features/tester/seed-data/tester-ttl.js +0 -6
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Core: evaluate the alert rules sellers and admins have configured — C3b.
3
+ *
4
+ * ## 🛑 The defect this closes: there was no evaluator at all
5
+ *
6
+ * `analyticsAlerts` had a schema, a seller CRUD UI, two API routes and four
7
+ * seeded fixtures — and **zero references across all 59 job files and
8
+ * `functions/`**. `lastTriggeredAt` was declared and written nowhere. So every
9
+ * alert a seller configured, and every one the seed created, could never fire.
10
+ *
11
+ * CLAUDE.md overstates this feature for exactly that reason: the parts that
12
+ * exist look complete, and the one that was missing is the one nothing points
13
+ * at. It also hid the metric-vocabulary split that C3a closed — two disjoint
14
+ * lists of metric names cannot be noticed while nothing reads the field.
15
+ *
16
+ * ## The switch is EXHAUSTIVE over `AlertMetric`
17
+ *
18
+ * `satisfies never` on the default branch, so adding a vocabulary member
19
+ * without an implementation does not compile. That is the mechanism behind the
20
+ * vocabulary's rule — *a metric is offerable only if the evaluator computes
21
+ * it* — rather than a convention someone has to remember.
22
+ *
23
+ * ## Read cost, and why it is bounded
24
+ *
25
+ * Metrics are computed ONCE PER OWNER, not once per alert: a seller with four
26
+ * alerts on orders pays for one orders query. Each query is bounded by
27
+ * `QUERY_LIMIT` and every count is reported as saturated when it hits the cap,
28
+ * because a count that silently stopped at 500 is a threshold comparison
29
+ * against a number that is not the number.
30
+ *
31
+ * 🛑 It does NOT scan per-product for `low_stock`. `availableQuantity <= N` is
32
+ * an inequality, and pairing one with an arbitrary sort is what demands an
33
+ * index nobody declares (Root Cause #59) — so the window is bounded and the
34
+ * predicate runs in memory, the same asymmetry § Availability describes.
35
+ */
36
+ import { serverLogger } from "../../../../monitoring";
37
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
38
+ import { normalizeError } from "../../../../errors/normalize";
39
+ import { ORDER_FIELDS, PAYOUT_FIELDS, PRODUCT_FIELDS, } from "../../../../constants/field-names";
40
+ import { ProductStatusValues, PRODUCT_COLLECTION, } from "../../../../features/products/schemas/firestore";
41
+ import { ALERT_METRICS, LOW_STOCK_THRESHOLD, } from "../../../../features/store-extensions/constants/alert-metrics";
42
+ /** Bound on every count query. Reported when it saturates. */
43
+ const QUERY_LIMIT = 500;
44
+ /*
45
+ * Collection names and the two field names that have no entry in
46
+ * `constants/field-names.ts`.
47
+ *
48
+ * Declared here rather than inlined at the call site because
49
+ * `audit-registry-constants` is right about the general rule — a hand-typed
50
+ * field name in a query clause is how a rename passes a typecheck and returns
51
+ * zero rows. These two (`reports.status`, `analyticsAlerts.isActive`) belong
52
+ * in the shared registry the day a second reader appears; one local constant
53
+ * is the honest intermediate rather than adding a registry entry nothing else
54
+ * uses.
55
+ */
56
+ const ORDERS_COLLECTION = "orders";
57
+ const REVIEWS_COLLECTION = "reviews";
58
+ const PAYOUTS_COLLECTION = "payouts";
59
+ const REPORTS_COLLECTION = "reports";
60
+ const USERS_COLLECTION = "users";
61
+ const REPORT_STATUS_FIELD = "status";
62
+ const ANALYTICS_ALERT_IS_ACTIVE_FIELD = "isActive";
63
+ /**
64
+ * An alert that is still true must not re-notify every run.
65
+ *
66
+ * Without this a seller with five products below five units gets a mail every
67
+ * hour until they restock — and the first thing anyone does with a channel
68
+ * that cries wolf is mute it, which costs the alerts that matter.
69
+ */
70
+ const RENOTIFY_COOLDOWN_HOURS = 24;
71
+ /** Compare, with the operator the alert carries. */
72
+ export function alertFires(value, operator, threshold) {
73
+ switch (operator) {
74
+ case ">":
75
+ return value > threshold;
76
+ case "<":
77
+ return value < threshold;
78
+ case ">=":
79
+ return value >= threshold;
80
+ case "<=":
81
+ return value <= threshold;
82
+ case "==":
83
+ return value === threshold;
84
+ case "!=":
85
+ return value !== threshold;
86
+ default: {
87
+ /*
88
+ * Exhaustive. A seventh operator would not compile rather than silently
89
+ * never firing — which is the failure mode of a `default: return false`
90
+ * and is how an alert can look configured and be inert.
91
+ */
92
+ const unreachable = operator;
93
+ return unreachable;
94
+ }
95
+ }
96
+ }
97
+ /**
98
+ * Whether enough time has passed to notify again.
99
+ *
100
+ * An alert that has NEVER fired always notifies — absence of
101
+ * `lastTriggeredAt` means "not yet", never "recently".
102
+ */
103
+ export function mayRenotify(alert, now) {
104
+ if (!alert.lastTriggeredAt)
105
+ return true;
106
+ const last = new Date(alert.lastTriggeredAt).getTime();
107
+ if (Number.isNaN(last))
108
+ return true;
109
+ return now.getTime() - last >= RENOTIFY_COOLDOWN_HOURS * 60 * 60 * 1000;
110
+ }
111
+ /**
112
+ * 🛑 GOODS VALUE of a completed order, never `totalPrice`.
113
+ *
114
+ * C0 established that the grand total folds in GST, the platform fee,
115
+ * shipping and add-ons — and an alert reporting a seller's revenue as the
116
+ * buyer's grand total tells them they earned money we collected on somebody
117
+ * else's behalf. This is the same base a payout is computed from.
118
+ *
119
+ * An unpaid or refunded order contributes **zero**: delivered-then-refunded
120
+ * has a realised value of nothing, which is the same rule `priceIndexRollup`
121
+ * applies to the index.
122
+ *
123
+ * Extracted because `audit-code-quality` flagged the inline version at
124
+ * brace-depth 6 and it was right — "for each order, if paid, if not refunded,
125
+ * for each item, if live" is four questions in one block.
126
+ */
127
+ function completedOrderGoodsValue(order) {
128
+ if (order.paymentStatus !== "paid")
129
+ return 0;
130
+ if (order.refunds && order.refunds.length > 0)
131
+ return 0;
132
+ return (order.items ?? []).reduce((sum, it) => {
133
+ const live = (it.quantity ?? 0) - (it.cancelledQuantity ?? 0);
134
+ return live > 0 ? sum + live * (it.unitPrice ?? 0) : sum;
135
+ }, 0);
136
+ }
137
+ function buildMetricCache(ctx, bucket, now, onSaturated) {
138
+ const cache = new Map();
139
+ const since = (windowHours) => new Date(now.getTime() - windowHours * 60 * 60 * 1000);
140
+ /** A bounded count, flagged when it hits the cap. */
141
+ const boundedCount = async (build) => {
142
+ const snap = await build().limit(QUERY_LIMIT).get();
143
+ return { value: snap.size, saturated: snap.size >= QUERY_LIMIT };
144
+ };
145
+ function buildQuery() {
146
+ return ctx.db.collection(PRODUCT_COLLECTION);
147
+ }
148
+ /** Seller metrics scope to the store; admin metrics span the platform. */
149
+ const storeScoped = (q) => bucket.scope === "seller" ? q.where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId) : q;
150
+ const compute = async (metric, windowHours) => {
151
+ switch (metric) {
152
+ case "low_stock": {
153
+ /*
154
+ * Bounded fetch + in-memory predicate, never `availableQuantity <= N`
155
+ * in the query. An inequality pushed alongside any other clause
156
+ * demands a composite index in an order nobody declares, and the
157
+ * failure mode is `FAILED_PRECONDITION` swallowed into a zero —
158
+ * i.e. an alert that silently never fires (Root Cause #59).
159
+ */
160
+ const snap = await storeScoped(ctx.db.collection(PRODUCT_COLLECTION).where(PRODUCT_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED))
161
+ .limit(QUERY_LIMIT)
162
+ .get();
163
+ if (snap.size >= QUERY_LIMIT)
164
+ onSaturated(metric);
165
+ return snap.docs.filter((d) => {
166
+ const q = d.data().availableQuantity ?? 0;
167
+ return q > 0 && q <= LOW_STOCK_THRESHOLD;
168
+ }).length;
169
+ }
170
+ case "out_of_stock": {
171
+ const snap = await storeScoped(ctx.db.collection(PRODUCT_COLLECTION).where(PRODUCT_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED))
172
+ .limit(QUERY_LIMIT)
173
+ .get();
174
+ if (snap.size >= QUERY_LIMIT)
175
+ onSaturated(metric);
176
+ return snap.docs.filter((d) => (d.data().availableQuantity ?? 0) <= 0).length;
177
+ }
178
+ case "orders_count": {
179
+ const r = await boundedCount(() => storeScoped(ctx.db.collection(ORDERS_COLLECTION).where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours))));
180
+ if (r.saturated)
181
+ onSaturated(metric);
182
+ return r.value;
183
+ }
184
+ case "cancelled_orders": {
185
+ const r = await boundedCount(() => storeScoped(ctx.db
186
+ .collection("orders")
187
+ .where(ORDER_FIELDS.STATUS, "==", ORDER_FIELDS.STATUS_VALUES.CANCELLED)
188
+ .where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours))));
189
+ if (r.saturated)
190
+ onSaturated(metric);
191
+ return r.value;
192
+ }
193
+ case "revenue_total": {
194
+ const snap = await storeScoped(ctx.db
195
+ .collection("orders")
196
+ .where(ORDER_FIELDS.STATUS, "==", ORDER_FIELDS.STATUS_VALUES.DELIVERED)
197
+ .where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours)))
198
+ .limit(QUERY_LIMIT)
199
+ .get();
200
+ if (snap.size >= QUERY_LIMIT)
201
+ onSaturated(metric);
202
+ /*
203
+ * 🛑 GOODS VALUE, not `totalPrice`. C0 established that the grand
204
+ * total folds in GST, the platform fee, shipping and add-ons — and an
205
+ * alert that reports a seller's revenue as the buyer's grand total
206
+ * tells them they earned money we collected on somebody else's
207
+ * behalf. Same base the payout is computed from.
208
+ */
209
+ const total = snap.docs.reduce((sum, d) => sum + completedOrderGoodsValue(d.data()), 0);
210
+ return Math.round(total * 100) / 100;
211
+ }
212
+ case "pending_payout_total": {
213
+ const snap = await (bucket.scope === "seller"
214
+ ? ctx.db.collection(PAYOUTS_COLLECTION).where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId)
215
+ : ctx.db.collection(PAYOUTS_COLLECTION))
216
+ .where(PAYOUT_FIELDS.STATUS, "in", ["PENDING", "PROCESSING"])
217
+ .limit(QUERY_LIMIT)
218
+ .get();
219
+ if (snap.size >= QUERY_LIMIT)
220
+ onSaturated(metric);
221
+ let total = 0;
222
+ for (const d of snap.docs)
223
+ total += d.data().amount ?? 0;
224
+ return Math.round(total * 100) / 100;
225
+ }
226
+ case "avg_rating": {
227
+ const snap = await ctx.db
228
+ .collection("reviews")
229
+ .where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId)
230
+ .limit(QUERY_LIMIT)
231
+ .get();
232
+ if (snap.size >= QUERY_LIMIT)
233
+ onSaturated(metric);
234
+ const ratings = snap.docs
235
+ .map((d) => d.data().rating)
236
+ .filter((r) => typeof r === "number");
237
+ /*
238
+ * 🛑 No reviews returns 0, and 0 would FIRE a `< 4` alert — telling a
239
+ * brand-new store its rating has collapsed when it has no rating at
240
+ * all. `NaN` instead, which every comparison answers false to, so the
241
+ * alert stays quiet until there is something to judge.
242
+ */
243
+ if (ratings.length === 0)
244
+ return Number.NaN;
245
+ return Math.round((ratings.reduce((a, b) => a + b, 0) / ratings.length) * 100) / 100;
246
+ }
247
+ case "reviews_count": {
248
+ const r = await boundedCount(() => ctx.db
249
+ .collection("reviews")
250
+ .where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId)
251
+ .where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours)));
252
+ if (r.saturated)
253
+ onSaturated(metric);
254
+ return r.value;
255
+ }
256
+ case "open_reports": {
257
+ const r = await boundedCount(() => ctx.db.collection(REPORTS_COLLECTION).where(REPORT_STATUS_FIELD, "==", "pending"));
258
+ if (r.saturated)
259
+ onSaturated(metric);
260
+ return r.value;
261
+ }
262
+ case "pending_moderation": {
263
+ const r = await boundedCount(() => ctx.db.collection(PRODUCT_COLLECTION).where(PRODUCT_FIELDS.STATUS, "==", "in_review"));
264
+ if (r.saturated)
265
+ onSaturated(metric);
266
+ return r.value;
267
+ }
268
+ default: {
269
+ /*
270
+ * 🛑 Exhaustive over `AlertMetric`. A vocabulary member with no
271
+ * implementation does NOT compile — which is what makes the
272
+ * vocabulary's rule ("offerable only if computable") a mechanism
273
+ * rather than a note. The original feature had the opposite: seven
274
+ * offerable metrics, none computable.
275
+ */
276
+ const unreachable = metric;
277
+ return unreachable;
278
+ }
279
+ }
280
+ };
281
+ return {
282
+ get(metric, windowHours) {
283
+ /*
284
+ * Keyed on metric AND window, because two alerts on `orders_count` with
285
+ * 24h and 48h windows are genuinely two different numbers. Keying on the
286
+ * metric alone would serve one alert the other's answer.
287
+ */
288
+ const key = `${metric}:${windowHours}`;
289
+ let p = cache.get(key);
290
+ if (!p) {
291
+ p = compute(metric, windowHours);
292
+ cache.set(key, p);
293
+ }
294
+ return p;
295
+ },
296
+ };
297
+ }
298
+ /**
299
+ * The owner's contact details, resolved once per owner.
300
+ *
301
+ * 🛑 This is how the seller's `notifyChannels` is HONOURED rather than
302
+ * ignored. `sendNotification` decides email and WhatsApp eligibility itself
303
+ * (type gate → admin allow-list → user prefs → daily budget) and takes no
304
+ * channel list — but it cannot email an address it was not given. So omitting
305
+ * `userEmail` when the seller did not tick email is a real narrowing through
306
+ * the existing gate, with no second channel-routing mechanism invented beside
307
+ * it.
308
+ *
309
+ * The in-app row is always written, which matches the UI: "in-app" is the
310
+ * default channel and there is no way to turn the bell off.
311
+ */
312
+ async function resolveOwnerContact(ctx, bucket) {
313
+ /*
314
+ * A seller alert's `ownerId` is the OWNER'S UID (the seed writes
315
+ * `user-tyson-blader`), not the store slug — unlike the metric queries
316
+ * above, which scope on `storeId`. Those are two different identifiers and
317
+ * conflating them is Root Cause #98's shape, so they are read from two
318
+ * different places on purpose.
319
+ */
320
+ const snap = await ctx.db.collection(USERS_COLLECTION).doc(bucket.ownerId).get();
321
+ if (!snap.exists)
322
+ return {};
323
+ const u = snap.data();
324
+ return {
325
+ ...(u.email ? { email: u.email } : {}),
326
+ ...(u.phoneNumber ? { phone: u.phoneNumber } : {}),
327
+ };
328
+ }
329
+ export async function runAnalyticsAlertEvaluate(ctx) {
330
+ const now = new Date();
331
+ const snap = await ctx.db
332
+ .collection("analyticsAlerts")
333
+ .where(ANALYTICS_ALERT_IS_ACTIVE_FIELD, "==", true)
334
+ .limit(QUERY_LIMIT)
335
+ .get();
336
+ if (snap.size >= QUERY_LIMIT) {
337
+ ctx.logger.warn("analyticsAlertEvaluate: alert scan saturated — some alerts were not evaluated", {
338
+ limit: QUERY_LIMIT,
339
+ });
340
+ }
341
+ /* Group by owner so each metric is computed once, not once per alert. */
342
+ const buckets = new Map();
343
+ for (const d of snap.docs) {
344
+ const alert = { id: d.id, ...d.data() };
345
+ if (!alert.ownerId || !ALERT_METRICS[alert.metric]) {
346
+ /*
347
+ * A stored metric outside the vocabulary. Logged rather than skipped
348
+ * silently: these are the pre-C3a rows, and the only other way to see
349
+ * one is that the seller's list renders a raw token with no description.
350
+ */
351
+ serverLogger.warn("analyticsAlertEvaluate: alert carries an unknown metric", {
352
+ alertId: d.id,
353
+ metric: alert.metric,
354
+ });
355
+ continue;
356
+ }
357
+ const key = `${alert.scope}:${alert.ownerId}`;
358
+ const bucket = buckets.get(key) ?? { scope: alert.scope, ownerId: alert.ownerId, alerts: [] };
359
+ bucket.alerts.push(alert);
360
+ buckets.set(key, bucket);
361
+ }
362
+ let evaluated = 0;
363
+ let fired = 0;
364
+ let suppressed = 0;
365
+ let errored = 0;
366
+ for (const bucket of buckets.values()) {
367
+ const saturatedMetrics = new Set();
368
+ const cache = buildMetricCache(ctx, bucket, now, (m) => saturatedMetrics.add(m));
369
+ /** One read per owner, and only when something actually fires. */
370
+ let contact = null;
371
+ const notifyOwner = async (b, alert, value) => {
372
+ contact ?? (contact = await resolveOwnerContact(ctx, b));
373
+ const def = ALERT_METRICS[alert.metric];
374
+ await sendNotification({
375
+ /*
376
+ * `system`, deliberately, rather than a new `analytics_alert` type.
377
+ *
378
+ * A new type touches five maps and — more to the point — would need an
379
+ * `EMAIL_ELIGIBLE_TYPES` answer. Against a 100/day Resend ceiling, an
380
+ * email-eligible type driven by a user-configurable threshold is the
381
+ * fastest way to exhaust the allowance. `system` is already eligible
382
+ * and already carries operational messages, which is what this is.
383
+ */
384
+ type: "system",
385
+ userId: b.ownerId,
386
+ title: alert.label || def.label,
387
+ priority: "normal",
388
+ message: `${def.label} is ${value} (${alert.operator} ${alert.threshold} over the last ${alert.windowHours}h). ${def.describes}`,
389
+ ...(alert.notifyChannels?.includes("email") && contact.email
390
+ ? { userEmail: contact.email }
391
+ : {}),
392
+ ...(alert.notifyChannels?.includes("whatsapp") && contact.phone
393
+ ? { userPhone: contact.phone }
394
+ : {}),
395
+ audience: b.scope === "admin" ? "admin" : "seller",
396
+ });
397
+ };
398
+ for (const alert of bucket.alerts) {
399
+ try {
400
+ const value = await cache.get(alert.metric, alert.windowHours ?? 24);
401
+ evaluated += 1;
402
+ /*
403
+ * 🛑 A SATURATED count must not fire. The real number is at least the
404
+ * cap, so a `<` comparison against it is meaningless and a `>` one is
405
+ * true for the wrong reason. Reporting "we could not measure this" is
406
+ * the honest outcome — the same rule as `truncated` rendering "50+"
407
+ * rather than a false total.
408
+ */
409
+ if (saturatedMetrics.has(alert.metric)) {
410
+ suppressed += 1;
411
+ ctx.logger.warn("analyticsAlertEvaluate: skipped on a saturated metric", {
412
+ alertId: alert.id,
413
+ metric: alert.metric,
414
+ });
415
+ continue;
416
+ }
417
+ if (Number.isNaN(value)) {
418
+ suppressed += 1;
419
+ continue;
420
+ }
421
+ if (!alertFires(value, alert.operator, alert.threshold))
422
+ continue;
423
+ if (!mayRenotify(alert, now)) {
424
+ suppressed += 1;
425
+ continue;
426
+ }
427
+ /*
428
+ * The write happens FIRST, before the notification.
429
+ *
430
+ * If the notification throws, the cooldown has still been recorded —
431
+ * so a persistently failing channel cannot turn into one attempt per
432
+ * run forever. The inverse order would retry a broken WhatsApp send
433
+ * every hour against a 100/day email allowance.
434
+ */
435
+ await ctx.db
436
+ .collection("analyticsAlerts")
437
+ .doc(alert.id)
438
+ .update({ lastTriggeredAt: now, lastObservedValue: value, updatedAt: now });
439
+ fired += 1;
440
+ await notifyOwner(bucket, alert, value);
441
+ ctx.logger.info("analyticsAlertEvaluate: alert fired", {
442
+ alertId: alert.id,
443
+ ownerId: alert.ownerId,
444
+ metric: alert.metric,
445
+ value,
446
+ operator: alert.operator,
447
+ threshold: alert.threshold,
448
+ });
449
+ }
450
+ catch (err) {
451
+ const e = normalizeError(err);
452
+ errored += 1;
453
+ ctx.logger.error("analyticsAlertEvaluate: alert evaluation failed", {
454
+ alertId: alert.id,
455
+ metric: alert.metric,
456
+ message: e.message,
457
+ });
458
+ }
459
+ }
460
+ }
461
+ ctx.logger.info("analyticsAlertEvaluate: complete", {
462
+ owners: buckets.size,
463
+ evaluated,
464
+ fired,
465
+ suppressed,
466
+ errored,
467
+ });
468
+ }
@@ -1,6 +1,7 @@
1
1
  import { orderRepository, payoutRepository, storeRepository, userRepository, siteSettingsRepository, } from "../../../../repositories";
2
2
  import { getDefaultCurrency } from "../../../../core/index";
3
3
  import { computePayoutDeduction } from "../../../shared/fees/calculator";
4
+ import { ordersTaxableValue } from "../../../shared/checkout/order-math";
4
5
  import { BATCH_LIMIT } from "../handlers/messages";
5
6
  const AUTO_PAYOUT_WINDOW_DAYS = 7;
6
7
  function getBusinessDayCutoff(daysAgo) {
@@ -48,7 +49,19 @@ export async function runAutoPayoutEligibility(ctx) {
48
49
  continue;
49
50
  }
50
51
  const sellerId = store.ownerId;
51
- const grossAmount = orders.reduce((sum, o) => sum + (o.data.totalPrice ?? 0), 0);
52
+ /*
53
+ * The TAXABLE VALUE — see `orderTaxableValue`. Two narrowings, both
54
+ * needed, and the variable keeps the name `grossAmount` only because
55
+ * `PayoutDocument.grossAmount` does:
56
+ *
57
+ * 1. not `totalPrice`, the grand total, which folds in GST, the platform
58
+ * fee the buyer already paid, shipping, COD handling, add-ons and the
59
+ * EMI surcharge — so it paid out our GST, took the fee twice, and
60
+ * computed every percentage on an inflated base;
61
+ * 2. not the gross goods value either, because prices are tax-INCLUSIVE,
62
+ * so the goods value still has our GST inside it.
63
+ */
64
+ const grossAmount = ordersTaxableValue(orders.map((o) => o.data));
52
65
  const { platformFee, gatewayFee, gstOnFee: gstAmount, netAmount } = computePayoutDeduction(grossAmount, commissions);
53
66
  const payoutInput = {
54
67
  storeId,
@@ -203,22 +203,38 @@ async function reconcileStores(ctx) {
203
203
  let errors = 0;
204
204
  for (const storeId of storeIds) {
205
205
  try {
206
- const storeSnap = await ctx.db.collection(STORES_COLLECTION).doc(storeId).get();
207
- if (!storeSnap.exists)
208
- continue;
209
- const sellerId = storeSnap.data().ownerId;
210
- if (!sellerId)
211
- continue;
206
+ /*
207
+ * 🛑 `storeId`, NOT a `sellerId` resolved from `ownerId`. Every store on
208
+ * the platform has been getting 0 products and 0 delivered orders
209
+ * written to its stats, nightly, in silence.
210
+ *
211
+ * Two compounding defects were here. The field `sellerId` **exists on
212
+ * neither `ProductDocument` nor `OrderDocument`** — measured, zero
213
+ * occurrences in either schema; both carry `storeId`. And the value was
214
+ * `store.ownerId`, which is the Auth UID, while `storeId` IS the store
215
+ * slug (§ Store Identity Architecture). So the query named a field no
216
+ * document has, with a value from the wrong namespace.
217
+ *
218
+ * Firestore answers an equality on a missing field with ZERO rows and
219
+ * raises nothing, so `setStats(storeId, 0, 0, …)` wrote confident zeros
220
+ * over whatever the live triggers had computed. That is Root Cause
221
+ * #100's family: a reader whose field no document carries returns
222
+ * nothing and reads as "this store has sold nothing".
223
+ *
224
+ * The store document read is gone with it — the loop already holds the
225
+ * id it needed, so resolving `ownerId` was a read per store spent on
226
+ * getting the answer wrong.
227
+ */
212
228
  const [productsSnap, ordersSnap, reviewStats] = await Promise.all([
213
229
  ctx.db
214
230
  .collection(PRODUCT_COLLECTION)
215
- .where(ORDER_FIELDS.SELLER_ID, "==", sellerId)
231
+ .where(PRODUCT_FIELDS.STORE_ID, "==", storeId)
216
232
  .where(ORDER_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED)
217
233
  .limit(QUERY_LIMIT)
218
234
  .get(),
219
235
  ctx.db
220
236
  .collection(ORDERS_COLLECTION)
221
- .where(ORDER_FIELDS.SELLER_ID, "==", sellerId)
237
+ .where(ORDER_FIELDS.STORE_ID, "==", storeId)
222
238
  .where(ORDER_FIELDS.STATUS, "==", ORDER_FIELDS.STATUS_VALUES.DELIVERED)
223
239
  .limit(QUERY_LIMIT)
224
240
  .get(),
@@ -38,6 +38,12 @@ import { normalizeError } from "../../../../errors/normalize";
38
38
  * itself, and each of those writes spawns a no-op invocation. A full reseed of the
39
39
  * 58-category forest therefore costs on the order of thousands of invocations, all
40
40
  * of them terminating. That is a cost worth knowing before a reseed, not a runaway.
41
+ *
42
+ * 🛑 AND THAT COST IS QUADRATIC, which is why the CREATE branch now has a
43
+ * precomputed-coordinates escape hatch (see the block at the top of `isCreate`).
44
+ * ~n²/4 writes: 58 nodes ≈ 840, 330 ≈ 27,000, 1,500 ≈ 560,000 — the last two
45
+ * being 1.4x and 28x the 20k/day Firestore write budget. A seed that already
46
+ * carries correct DFS coordinates skips the branch entirely.
41
47
  */
42
48
  import { FieldValue } from "firebase-admin/firestore";
43
49
  import { BATCH_LIMIT } from "../handlers/messages";
@@ -97,6 +103,54 @@ export async function handleCategoryWrite(input, ctx) {
97
103
  const isUpdate = !!before && !!after;
98
104
  try {
99
105
  if (isCreate) {
106
+ /*
107
+ * 🛑 BULK-WRITE ESCAPE HATCH — skip everything when the writer already
108
+ * computed valid DFS coordinates.
109
+ *
110
+ * The branch below is O(n) reads + O(n) writes PER CATEGORY CREATED:
111
+ * `shiftPositions` runs an unbounded `where("position", ">=", t)` and
112
+ * rewrites every row it returns, because a new node is inserted as its
113
+ * parent's FIRST child and everything after it moves. Averaged over a
114
+ * build that is ~n/2 rows shifted per insert, so seeding N categories
115
+ * costs Σ(i/2) ≈ n²/4 writes:
116
+ *
117
+ * 58 nodes -> ~840 writes (tolerable, and what we pay today)
118
+ * 330 nodes -> ~27,000 writes (above the 20k/day Firestore budget)
119
+ * 1,500 nodes -> ~560,000 writes (28x the daily budget, in one run)
120
+ *
121
+ * `buildCategoryTree` already computes `position` and `subtreeSize` as
122
+ * a correct global DFS pre-order — the exact numbering this handler
123
+ * would otherwise recompute one insert at a time. Worse than wasteful:
124
+ * the CREATE branch OVERWRITES that work with `subtreeSize: 1` and a
125
+ * locally-derived position, leaving the collection wrong until the
126
+ * nightly reconcile repairs it.
127
+ *
128
+ * So a document that arrives already numbered is left alone.
129
+ *
130
+ * The test is `position >= 1`, which is unambiguous because BOTH
131
+ * writers of a precomputed position are 1-based: `buildCategoryTree`
132
+ * and `positionsReconcile`. A category created through the admin/store
133
+ * routes carries `position: 0` (the `createWithHierarchy` default) and
134
+ * therefore still gets the full treatment.
135
+ *
136
+ * 🛑 This is NOT the Root Cause #92 shape. It is not a value-equality
137
+ * guard on a field this handler writes — it is a precondition on the
138
+ * INCOMING document, and the handler takes no action at all when it
139
+ * holds. There is nothing to compare and nothing to re-trigger.
140
+ */
141
+ const precomputedPosition = after.position;
142
+ const precomputedSubtree = after.subtreeSize;
143
+ if (typeof precomputedPosition === "number" &&
144
+ precomputedPosition >= 1 &&
145
+ typeof precomputedSubtree === "number" &&
146
+ precomputedSubtree >= 1) {
147
+ ctx.logger.info("Category created with precomputed DFS coords — skipping position assignment", {
148
+ categoryId,
149
+ position: precomputedPosition,
150
+ subtreeSize: precomputedSubtree,
151
+ });
152
+ return;
153
+ }
100
154
  const parentIds = after.parentIds ?? [];
101
155
  const parentId = parentIds.length > 0 ? parentIds[parentIds.length - 1] : null;
102
156
  let insertPosition;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Core: the realised-price index — what things actually SOLD for here.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Every price signal we show a seller today is an ASK: the seeded `market`
7
+ * band is a competitor's listed price, and a competitor's sold-out row proves
8
+ * the item is GONE, not that it sold at that figure. With 76–93% of listings
9
+ * across the crawled catalogues never selling, the gap between an ask and a
10
+ * realised price is the single most useful thing we can tell a seller — and we
11
+ * are the only party who can measure it on our own sales.
12
+ *
13
+ * A seeded blog post already tells sellers to *"check what similar items sold
14
+ * for on LetItRip in the last 30 days"*, which has never been possible.
15
+ *
16
+ * ## 🛑 `market` and `sold` are NEVER blended
17
+ *
18
+ * They answer different questions and the gap IS the information. Averaging
19
+ * them would launder an inference into a measurement. This job writes only the
20
+ * sold half, into its own collection, and never touches the seeded band.
21
+ *
22
+ * ## The authoritative field
23
+ *
24
+ * **`OrderDocument.items[].unitPrice`** — and specifically not:
25
+ *
26
+ * `OrderDocument.unitPrice` the FIRST line's price (`unitPriceFor(items[0])`),
27
+ * wrong for every other line
28
+ * `OrderDocument.totalPrice` folds in GST, platform fee, shipping and add-ons
29
+ * `BidDocument.status==won` its own comment: `orderId` is absent on a
30
+ * won-but-unpaid bid
31
+ * `OfferDocument==accepted` an agreement, not a sale. `paid` is the sale
32
+ * `product.currentBid` a display mirror a pending buyout never writes
33
+ *
34
+ * Won auctions and accepted offers arrive through the same field via
35
+ * `lockedPrice`, so one field covers all six acquisition paths and
36
+ * `sourceContext` labels each for free.
37
+ *
38
+ * ## Why daily and never live
39
+ *
40
+ * A live index would scan orders per request — the unbounded-per-request
41
+ * pattern that paused this project on Hobby caps. One pre-computed read per
42
+ * row, with a visible "prices as of {date}". Deliberately one day stale.
43
+ */
44
+ import type { JobContext } from "../runtime/types";
45
+ /** How far back a band looks. */
46
+ export declare const PRICE_INDEX_WINDOW_DAYS = 90;
47
+ export declare function runPriceIndexRollup(ctx: JobContext): Promise<void>;