@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
@@ -5,14 +5,30 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
5
5
  import { cacheManager } from "../../../core";
6
6
  import { serverLogger } from "../../../monitoring";
7
7
  import { generateUniqueId, slugify, generateBarcodeId } from "../../../utils";
8
- import { matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt";
8
+ import { parseSearchTxtQuery } from "../../../utils/search-txt";
9
+ // The shared search plan/refine the other eleven migrated collections use.
10
+ // `planSearchTxt` carries the `empty` guard that keeps a punctuation-only query
11
+ // from returning the whole catalogue.
12
+ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
9
13
  // One definition of "which fields feed searchTxt", shared with the seed
10
14
  // wrapper. Three copies existed and two were missing the same three fields.
11
15
  import { buildProductSearchTxt } from "../../../utils/search-txt-builders";
12
16
  import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
13
- // Collection NAME only — deliberately not `categoriesRepository`. See
14
- // deriveTaxonomy()'s header for why this is a raw read.
17
+ // Collection NAMES only — deliberately not `categoriesRepository` or
18
+ // `taxCodesRepository`. See deriveTaxonomy()'s header for why these are raw
19
+ // reads: no repository in this codebase imports another feature's, because of
20
+ // the Turbopack import-chain trap (Root Cause #6/#18).
15
21
  import { CATEGORIES_COLLECTION } from "../../categories/schemas/firestore";
22
+ import { TAX_CODES_COLLECTION } from "../../tax-codes/schemas/firestore";
23
+ // Collection id only, never the stores repository — no repository in this
24
+ // codebase imports another feature's (the Turbopack import-chain trap).
25
+ import { STORE_COLLECTION } from "../../stores/schemas/firestore";
26
+ // Pure money math — no Firestore, no server-only imports.
27
+ import { DEFAULT_GST_RATE, grossFromTaxable } from "../../../_internal/shared/fees/calculator";
28
+ import { MAX_FEATURES_PER_PRODUCT } from "../schemas/product-features";
29
+ // The ONE `{{placeholder}}` implementation, promoted out of the FAQ schema in
30
+ // B3. A second regex here would be Root Cause #75's shape.
31
+ import { interpolate } from "../../../_internal/shared/templating/placeholders";
16
32
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
17
33
  /**
18
34
  * Canonical listing-kind tokens used in `listingType`. Note: the public Sieve
@@ -52,6 +68,41 @@ function buildListingKindClause(kind, inverted) {
52
68
  const op = inverted ? "!=" : "==";
53
69
  return `${PRODUCT_FIELDS.LISTING_TYPE}${op}${canonical}`;
54
70
  }
71
+ /**
72
+ * Fill an SEO template's slots from what the write actually carries.
73
+ *
74
+ * 🛑 A SERVER-SIDE subset on purpose, and smaller than the picker's.
75
+ *
76
+ * The seller form's template picker resolves nine slots because it has the
77
+ * whole draft in hand — the condition LABEL, the category path, the tier-1
78
+ * series, the tier-2 system. This runs inside a repository write with only the
79
+ * input and what `deriveTaxonomy` has just derived, and fetching more to fill
80
+ * an SEO title would spend a round trip on a field a seller can overwrite in
81
+ * one keystroke.
82
+ *
83
+ * So the slots it cannot fill resolve to EMPTY and the sentence closes around
84
+ * them, which `interpolate` guarantees — never the literal `{{series}}`. The
85
+ * templates in `category-content.ts` are written to read correctly with any
86
+ * slot missing for exactly this reason.
87
+ *
88
+ * 🛑 No `{{price}}` slot, here or anywhere: a price in free text is an
89
+ * ungated public price `<GatedPrice>` cannot wrap, and `audit-guest-price-leak`
90
+ * R2 blocks it.
91
+ */
92
+ function interpolateDefaults(template, input, derived) {
93
+ const names = derived.categoryNames ?? input.categoryNames ?? [];
94
+ return interpolate(template, {
95
+ title: input.title,
96
+ brand: derived.brand ?? input.brand,
97
+ condition: input.condition,
98
+ // `categoryNames` is self-first / root-last, so [0] is the leaf and the
99
+ // path reads root-first the way a breadcrumb does.
100
+ category: names[0],
101
+ categoryPath: names.length ? [...names].reverse().join(" › ") : undefined,
102
+ series: names[names.length - 2],
103
+ siteName: "LetItRip",
104
+ }).text;
105
+ }
55
106
  export class ProductRepository extends BaseRepository {
56
107
  constructor() {
57
108
  super(PRODUCT_COLLECTION);
@@ -127,7 +178,21 @@ export class ProductRepository extends BaseRepository {
127
178
  * feature's repository, and the reason is the Turbopack import-chain trap
128
179
  * (Root Cause #6/#18) — a raw collection read has neither problem.
129
180
  */
130
- async deriveTaxonomy(input) {
181
+ async deriveTaxonomy(input,
182
+ /**
183
+ * Whether this is the product's FIRST write.
184
+ *
185
+ * 🛑 Load-bearing for one field — see `applyCategoryDefaults`' features
186
+ * block. Inheriting a merge-shaped default on every save makes a seller's
187
+ * REMOVAL un-stickable: they untick an inherited feature, the next save
188
+ * that touches the category silently puts it back, and nothing reports it.
189
+ * Merge is right for create and wrong for update.
190
+ *
191
+ * Defaults to `false`, which is the safe direction: the worst a missed
192
+ * `true` can do is skip an inheritance the seller can tick themselves,
193
+ * whereas a wrongly-passed `true` resurrects a value they deleted.
194
+ */
195
+ isCreate = false) {
131
196
  const out = {};
132
197
  const cats = this.db.collection(CATEGORIES_COLLECTION);
133
198
  /*
@@ -206,8 +271,379 @@ export class ProductRepository extends BaseRepository {
206
271
  out.brand = brand.name;
207
272
  }
208
273
  }
274
+ if (catSnap?.exists) {
275
+ await this.applyCategoryDefaults(input, out, catSnap, isCreate);
276
+ }
277
+ await this.applyInclusivePricing(input, out);
209
278
  return out;
210
279
  }
280
+ /**
281
+ * Resolve the GST rate floor, and gross up an ex-tax price if the seller
282
+ * authors that way.
283
+ *
284
+ * ## The rate resolution order, in full
285
+ *
286
+ * 1. the product's own `gstRate`, if a human set one — including `0`, a
287
+ * deliberate exemption;
288
+ * 2. the CATEGORY's rate via `taxCodeId` → `taxCodes`, applied just above
289
+ * by `applyCategoryDefaults`. **This is the mechanism**, and the ~330-node
290
+ * forest carries a tax code on every listing branch, so it answers for
291
+ * effectively the whole catalogue;
292
+ * 3. `DEFAULT_GST_RATE` — a floor, warned about, never a feature.
293
+ *
294
+ * 🛑 Step 3 fires only when steps 1 and 2 both came up empty, which means
295
+ * the category has no tax code — and THAT is the defect to fix. The measure
296
+ * of success is that this warning never appears. It exists because the
297
+ * alternative fails asymmetrically: with prices tax-inclusive, treating an
298
+ * unrated item as 0% hands the seller the full amount and leaves us owing
299
+ * tax on money already paid out, which is C0's bug in miniature.
300
+ *
301
+ * ## The gross-up
302
+ *
303
+ * A store with `pricesIncludeGst: false` types EX-TAX prices, so the typed
304
+ * number is converted ONCE here and stored tax-inclusive like every other
305
+ * price. No money path branches on the flag; see `taxableFromInclusive`.
306
+ *
307
+ * Gated on the write actually carrying a price, so a stock decrement or a
308
+ * status flip pays nothing for this — the same discipline `update` already
309
+ * applies to the taxonomy derivation itself.
310
+ */
311
+ async applyInclusivePricing(input, out) {
312
+ const rate = out.gstRate ?? input.gstRate;
313
+ if (rate == null) {
314
+ out.gstRate = DEFAULT_GST_RATE;
315
+ serverLogger.warn("[gst] no rate from the product or its category — applying DEFAULT_GST_RATE; the category is missing a taxCodeId", { category: input.category, defaultRate: DEFAULT_GST_RATE });
316
+ }
317
+ /*
318
+ * Only a write that sets a price can need converting. `input.price` is the
319
+ * merged current-document-plus-patch (see `update`), so an unrelated save
320
+ * would re-convert an already-converted price — which is why the guard is
321
+ * on the PATCH carrying a price, resolved by the callers below.
322
+ */
323
+ if (input.price == null || !input.storeId)
324
+ return;
325
+ const storeSnap = await this.db
326
+ .collection(STORE_COLLECTION)
327
+ .doc(input.storeId)
328
+ .get();
329
+ const store = storeSnap.exists
330
+ ? storeSnap.data()
331
+ : null;
332
+ // Absent means inclusive — the documented default, and the one that keeps
333
+ // every existing listing's price meaning exactly what it meant before.
334
+ if (store?.pricesIncludeGst !== false)
335
+ return;
336
+ const effectiveRate = out.gstRate ?? input.gstRate ?? DEFAULT_GST_RATE;
337
+ if (effectiveRate <= 0)
338
+ return; // an exempt item has nothing to gross up
339
+ out.price = grossFromTaxable(input.price, effectiveRate);
340
+ /*
341
+ * 🛑 `compareAtPrice` is deliberately NOT converted here, because it does
342
+ * not exist. `SellerProductShell`'s pricing section lists it among its
343
+ * `fields` and renders an input for it, but `ProductDocument` has no such
344
+ * property — so whatever a seller types there is stripped by the schema and
345
+ * never stored. A declared-but-unpersisted form field is its own defect
346
+ * (and a strike-through price is a money figure, so it would need the same
347
+ * gross-up the day it becomes real); logged rather than silently worked
348
+ * around, since converting a field that is discarded would be theatre.
349
+ */
350
+ serverLogger.info("[gst] grossed up an ex-tax price for a pricesIncludeGst:false store", {
351
+ storeId: input.storeId,
352
+ rate: effectiveRate,
353
+ });
354
+ }
355
+ /**
356
+ * Hand down what the category declares: the tax code, the spec rows, the
357
+ * in-the-box list, the inherited features and the SEO templates.
358
+ *
359
+ * ## 🛑 Why this is server-side and not a form default
360
+ *
361
+ * GST is tax CORRECTNESS. A client-side default is bypassable by all ~14
362
+ * write paths, and the defect this closes is recorded in our own source:
363
+ * *"0 of 40 live orders had a `gstAmount`, and the order page's Tax row had
364
+ * never rendered."* The rate has to be applied where every path funnels, and
365
+ * that is here.
366
+ *
367
+ * ## 🛑 The three behaviours are different, and the difference is the design
368
+ *
369
+ * WRITTEN tax, specifications, inTheBox, features — written when the
370
+ * product omits them.
371
+ * FILL-BLANK seo* — fills only an empty field, never overwrites a
372
+ * seller's own words.
373
+ * ADVISORY priceGuidance — NEVER touched here. It renders as a hint
374
+ * beside the price input and nothing else. Writing a suggested
375
+ * price onto `price` would be the platform setting a seller's
376
+ * price, which is not a default, it is a decision.
377
+ *
378
+ * ## 🛑 Resolution is PER FIELD across the ancestor chain
379
+ *
380
+ * Not "the nearest category that declares a `productDefaults` object wins".
381
+ * B5's named-model leaves carry `defaultFeatures` (the blader, the battle
382
+ * type, the spin) while the tax code, the spec rows and the price band live
383
+ * on the tier-3 LINE above them. An object-level "nearest wins" would hand a
384
+ * Dragoon G its blader and NO TAX CODE — silently deriving no GST, which is
385
+ * the quiet under-charging failure the whole tax-code indirection exists to
386
+ * prevent.
387
+ *
388
+ * ## Cost
389
+ *
390
+ * One `getAll` over at most four ancestors, fired only when the leaf leaves
391
+ * something unresolved, plus one read for the tax code. With the leaf+brand
392
+ * read that is three round trips worst case against Rule #6's ~3 budget —
393
+ * and `update` only re-derives when a write names a taxonomy field, so a
394
+ * stock decrement or a status flip pays none of it.
395
+ */
396
+ async applyCategoryDefaults(input, out,
397
+ /*
398
+ * The real snapshot type, not a `{ data(): unknown }` shape. The first
399
+ * draft used the latter and `audit-unknown-leakage` was right to reject
400
+ * it: `unknown` at a parameter boundary is how a callee ends up casting,
401
+ * and the one cast here is already narrow and stated.
402
+ */
403
+ catSnap, isCreate) {
404
+ const cats = this.db.collection(CATEGORIES_COLLECTION);
405
+ const leafData = catSnap.data();
406
+ const resolved = { ...(leafData.productDefaults ?? {}) };
407
+ /*
408
+ * Which fields still need an answer. `priceGuidance` is deliberately absent
409
+ * from this list — it is never applied, so walking ancestors to find one
410
+ * would be a round trip spent on a value this function will not use.
411
+ */
412
+ const WANTED = [
413
+ "taxCodeId",
414
+ "specifications",
415
+ "defaultFeatures",
416
+ "defaultCondition",
417
+ "defaultAuthenticity",
418
+ "inTheBox",
419
+ "seoTitleTemplate",
420
+ "seoDescriptionTemplate",
421
+ "seoKeywords",
422
+ ];
423
+ const isEmpty = (v) => v == null || (Array.isArray(v) && v.length === 0);
424
+ const unresolved = () => WANTED.filter((k) => isEmpty(resolved[k]));
425
+ if (unresolved().length > 0 && (leafData.parentIds ?? []).length > 0) {
426
+ /*
427
+ * `parentIds` is root-first / nearest-last, so walk it REVERSED —
428
+ * nearest ancestor first. One `getAll`, not a read per level: four
429
+ * sequential `doc().get()` calls would be four round trips and breach
430
+ * Rule #6 on a path that already spends two.
431
+ */
432
+ const nearestFirst = [...(leafData.parentIds ?? [])].reverse();
433
+ const snaps = await this.db.getAll(...nearestFirst.map((id) => cats.doc(id)));
434
+ for (const snap of snaps) {
435
+ if (unresolved().length === 0)
436
+ break;
437
+ if (!snap.exists)
438
+ continue;
439
+ const d = snap.data()
440
+ .productDefaults;
441
+ if (!d)
442
+ continue;
443
+ for (const k of unresolved()) {
444
+ if (!isEmpty(d[k]))
445
+ resolved[k] = d[k];
446
+ }
447
+ }
448
+ }
449
+ // ── tax: resolve the REFERENCE to a SNAPSHOT on the product ────────────
450
+ /*
451
+ * 🛑 `input.gstRate == null`, NEVER `!input.gstRate`.
452
+ *
453
+ * Zero is a DELIBERATE exemption — `tax-exempt-0` exists for live plants
454
+ * and animals — and a falsy test would overwrite every one of them with
455
+ * the category's rate. The schema's own comment at `gstRate` says so.
456
+ */
457
+ const needsRate = input.gstRate == null;
458
+ const needsHsn = !input.hsnCode;
459
+ if (resolved.taxCodeId && (needsRate || needsHsn)) {
460
+ const taxSnap = await this.db
461
+ .collection(TAX_CODES_COLLECTION)
462
+ .doc(resolved.taxCodeId)
463
+ .get();
464
+ const tax = taxSnap.exists
465
+ ? taxSnap.data()
466
+ : null;
467
+ if (tax?.isActive) {
468
+ if (needsRate && tax.gstRate != null)
469
+ out.gstRate = tax.gstRate;
470
+ if (needsHsn && tax.hsnCode)
471
+ out.hsnCode = tax.hsnCode;
472
+ }
473
+ else {
474
+ /*
475
+ * 🛑 A dangling or deactivated tax code must be LOUD, because its
476
+ * silent failure mode is under-charging.
477
+ *
478
+ * `findResolvable` answers null for both cases and the correct
479
+ * handling is "leave `gstRate` alone" — so every later listing under
480
+ * that category derives NO TAX and the order invoices zero. Nothing
481
+ * errors, the save succeeds, and the shortfall only surfaces in a
482
+ * filing. DELETE already refuses while a category references a code;
483
+ * this covers a code deactivated after the fact.
484
+ */
485
+ serverLogger.warn("Category tax code does not resolve to an active taxCodes row", {
486
+ taxCodeId: resolved.taxCodeId,
487
+ productId: input.id,
488
+ });
489
+ }
490
+ }
491
+ // ── WRITTEN: only when the product omits them ──────────────────────────
492
+ if (resolved.specifications?.length && !input.specifications?.length) {
493
+ out.specifications = resolved.specifications.map((s) => ({ ...s }));
494
+ }
495
+ /*
496
+ * 🛑 `inTheBox` is deliberately NOT written to the product.
497
+ *
498
+ * `ProductDocument` has no such field, and the plan's suggestion —
499
+ * `out.features = d.inTheBox` — predates the vocabulary merge: `features`
500
+ * now holds feature IDS, so writing "Beyblade top, Manual (where
501
+ * supplied)" into it would put prose where `array-contains` expects
502
+ * `feature-nib`, breaking every facet on that product and tokenising
503
+ * "where" and "supplied" into its search index.
504
+ *
505
+ * It stays a CATEGORY-side value, read by the description-template
506
+ * endpoint (C2b) to fill the "What's in the box?" block the new-in-box
507
+ * body already renders. That is the only consumer it ever needed.
508
+ */
509
+ if (resolved.defaultCondition && !input.condition) {
510
+ out.condition = resolved.defaultCondition;
511
+ }
512
+ if (resolved.defaultAuthenticity && !input.authenticity) {
513
+ out.authenticity = resolved.defaultAuthenticity;
514
+ }
515
+ /*
516
+ * Features MERGE rather than replace — the field's own contract, and the
517
+ * reason the blader linkage works: a seller who ticks "Japan Import" keeps
518
+ * it and also gets `feature-blader-tyson-granger` from the Dragoon G leaf.
519
+ *
520
+ * 🛑 BUT ONLY ON CREATE, OR WHEN THE PRODUCT CARRIES NONE. This was a real
521
+ * defect in C2a as shipped.
522
+ *
523
+ * `update` calls `deriveTaxonomy({ ...current, ...data })`, so `input`
524
+ * holds the stored document merged with the patch — which is exactly what
525
+ * makes every other guard here correct, because a seller's edited
526
+ * `specifications` or `condition` is present and the `!input.X` test is
527
+ * false. Features were the one field that did not test presence: they
528
+ * merged unconditionally. So a seller who UNTICKED an inherited feature —
529
+ * removing `feature-type-attack` because their build is actually defense —
530
+ * had it silently re-added by the next save that touched the category or
531
+ * the brand. The removal could not stick and nothing reported it.
532
+ *
533
+ * On update the seller's array IS the answer. They have had the chance to
534
+ * curate it, and a removal is a decision. An update that carries no
535
+ * features at all still inherits, so a listing created before its category
536
+ * had defaults picks them up the first time it is edited.
537
+ *
538
+ * Capped at MAX_FEATURES_PER_PRODUCT because `features[]` feeds
539
+ * `buildProductSearchTxt`, which truncates at 600 tokens SILENTLY — an
540
+ * unbounded list would evict real title tokens nobody chose to drop. The
541
+ * product's OWN features are kept first so it is always the inherited ones
542
+ * that fall off the end.
543
+ */
544
+ const mayInheritFeatures = isCreate || !input.features?.length;
545
+ if (resolved.defaultFeatures?.length && mayInheritFeatures) {
546
+ const merged = [...(input.features ?? [])];
547
+ for (const f of resolved.defaultFeatures) {
548
+ if (!merged.includes(f))
549
+ merged.push(f);
550
+ }
551
+ if (merged.length > MAX_FEATURES_PER_PRODUCT) {
552
+ serverLogger.warn("Category default features exceeded the per-product cap", {
553
+ productId: input.id,
554
+ kept: MAX_FEATURES_PER_PRODUCT,
555
+ dropped: merged.length - MAX_FEATURES_PER_PRODUCT,
556
+ });
557
+ }
558
+ out.features = merged.slice(0, MAX_FEATURES_PER_PRODUCT);
559
+ }
560
+ // ── FILL-BLANK: a template never overwrites what a seller wrote ─────────
561
+ const title = input.title ?? "";
562
+ if (resolved.seoTitleTemplate && !input.seoTitle && title) {
563
+ out.seoTitle = interpolateDefaults(resolved.seoTitleTemplate, input, out);
564
+ }
565
+ if (resolved.seoDescriptionTemplate && !input.seoDescription && title) {
566
+ out.seoDescription = interpolateDefaults(resolved.seoDescriptionTemplate, input, out);
567
+ }
568
+ /*
569
+ * 🛑 Same create-only gate as features, and for the same reason — plus
570
+ * this one also contradicted the stated contract three lines above it.
571
+ * "FILL-BLANK: seo* fills only an empty field, never overwrites a
572
+ * seller's own words" was true of `seoTitle` and `seoDescription` and
573
+ * false of `seoKeywords`, which merged on every save. A seller who
574
+ * deleted an inherited keyword got it back.
575
+ *
576
+ * Lower stakes than features — nobody watches their keyword list the way
577
+ * they watch a type chip — which is precisely why it would have gone
578
+ * unnoticed indefinitely.
579
+ */
580
+ if (resolved.seoKeywords?.length && (isCreate || !input.seoKeywords?.length)) {
581
+ const merged = [...(input.seoKeywords ?? [])];
582
+ for (const k of resolved.seoKeywords)
583
+ if (!merged.includes(k))
584
+ merged.push(k);
585
+ out.seoKeywords = merged;
586
+ }
587
+ }
588
+ /**
589
+ * Keep `availableQuantity` in step when a seller edits `stockQuantity`.
590
+ *
591
+ * 🛑 THE BUG THIS CLOSES: A SELLER COULD NOT RESTOCK.
592
+ *
593
+ * There are two stock numbers. `stockQuantity` is seller-authored and is the
594
+ * only one any of the three product forms exposes. `availableQuantity` is
595
+ * system-owned, seeded from `stockQuantity` **once, at create**, and
596
+ * thereafter driven by checkout decrements and timeout restores.
597
+ *
598
+ * Nothing re-synced them. `updateProduct` is a bare spread, and grep for
599
+ * `availableQuantity` across `src/app/api/store/products/**` and
600
+ * `seller-actions.ts` returns ZERO. So: a product sells out
601
+ * (`availableQuantity: 0`), the seller edits `stockQuantity` 0 → 10, and the
602
+ * product STAYS UNBUYABLE — purchasability gates on `availableQuantity > 0`
603
+ * in three independent places (`service.ts`, the cart route, and the public
604
+ * list filter). The only field the seller can reach is the one that does not
605
+ * control availability.
606
+ *
607
+ * With 76–93% of competitor catalogues sold out, restock is the main
608
+ * lifecycle event, not an edge case.
609
+ *
610
+ * WHY A DELTA AND NOT `availableQuantity = stockQuantity`: the difference
611
+ * between the two IS the outstanding reservation — units held by placed-but-
612
+ * unpaid orders. Overwriting would hand those units back to the shelf and
613
+ * oversell them. So carry the reservation forward:
614
+ *
615
+ * reserved = prevStock - prevAvailable (clamped at 0)
616
+ * available = nextStock - reserved (clamped at 0)
617
+ *
618
+ * Examples: 10 stock / 10 available, edit to 12 → 12. 10 stock / 7 available
619
+ * (3 reserved), edit to 12 → 9. Sold out at 0/0, edit to 10 → 10.
620
+ *
621
+ * 🛑 Returns `{}` — not a value — whenever the write does not name
622
+ * `stockQuantity`, or names `availableQuantity` explicitly. The second case
623
+ * is what keeps the checkout decrement, the stock-restore job and
624
+ * `updateAvailableQuantity` authoritative: they write `availableQuantity`
625
+ * directly and must never have it recomputed from a stale `stockQuantity`
626
+ * underneath them.
627
+ */
628
+ static resyncAvailableQuantity(current, data) {
629
+ if (!current)
630
+ return {};
631
+ if (!("stockQuantity" in data))
632
+ return {};
633
+ // An explicit availableQuantity write wins — see the 🛑 above.
634
+ if ("availableQuantity" in data)
635
+ return {};
636
+ const nextStock = data.stockQuantity;
637
+ if (typeof nextStock !== "number" || !Number.isFinite(nextStock))
638
+ return {};
639
+ const prevStock = typeof current.stockQuantity === "number" ? current.stockQuantity : 0;
640
+ const prevAvailable = typeof current.availableQuantity === "number" ? current.availableQuantity : prevStock;
641
+ const reserved = Math.max(0, prevStock - prevAvailable);
642
+ const nextAvailable = Math.max(0, nextStock - reserved);
643
+ if (nextAvailable === prevAvailable)
644
+ return {};
645
+ return { availableQuantity: nextAvailable };
646
+ }
211
647
  async update(id, data) {
212
648
  this.cacheInvalidateForId(id);
213
649
  const current = await super.findById(id);
@@ -221,10 +657,12 @@ export class ProductRepository extends BaseRepository {
221
657
  const derived = touchesTaxonomy
222
658
  ? await this.deriveTaxonomy({ ...current, ...data })
223
659
  : {};
224
- const merged = { ...current, ...data, ...derived };
660
+ const stock = ProductRepository.resyncAvailableQuantity(current, data);
661
+ const merged = { ...current, ...data, ...derived, ...stock };
225
662
  const updated = await super.update(id, {
226
663
  ...data,
227
664
  ...derived,
665
+ ...stock,
228
666
  searchTxt: buildProductSearchTxt(merged),
229
667
  });
230
668
  this.cacheSet(updated);
@@ -248,7 +686,7 @@ export class ProductRepository extends BaseRepository {
248
686
  // what makes a UI-created product reachable from its own category page at
249
687
  // all. A seed row already carrying the correct chain derives to the same
250
688
  // value, so this is a no-op for every existing fixture.
251
- const derived = await this.deriveTaxonomy(input);
689
+ const derived = await this.deriveTaxonomy(input, true);
252
690
  const withTaxonomy = { ...input, ...derived };
253
691
  const productData = {
254
692
  ...withTaxonomy,
@@ -528,6 +966,31 @@ export class ProductRepository extends BaseRepository {
528
966
  return baseQuery;
529
967
  }
530
968
  async list(model, opts) {
969
+ /*
970
+ * 🛑 THE EMPTY GUARD — a non-blank query that yields NO usable token must
971
+ * return nothing, not everything.
972
+ *
973
+ * `parseSearchTxtQuery` normalises away punctuation, so `"!!!"`, `"—"`,
974
+ * `"???"` and `"@#$"` all reduce to `[]`. `buildScopedQuery` then takes its
975
+ * `searchTxt.length > 0` branch as FALSE and emits **no clause at all**, so
976
+ * the query falls back to the caller's other filters — on the public
977
+ * listing that is `status==published` — and **the entire published
978
+ * catalogue is returned as search results.**
979
+ *
980
+ * `products` was the only one of the twelve MIGRATED collections still
981
+ * hand-rolling this. The other eleven go through `planSearchTxt`, whose
982
+ * whole reason for existing is this guard (see the header of
983
+ * `utils/search-txt-query.ts`); adopting it here closes the gap rather
984
+ * than adding a twelfth private copy of the rule.
985
+ *
986
+ * `planSearchTxt` is built on the same `parseSearchTxtQuery` this method
987
+ * already called, and `plan.head`/`plan.rest` are exactly the previous
988
+ * `searchTxt[0]`/`.slice(1)` — so apart from the guard this is behaviour-
989
+ * identical, which is what makes it safe for a path four executors share.
990
+ */
991
+ const plan = planSearchTxt(opts?.search);
992
+ if (plan.empty)
993
+ return emptySearchResult();
531
994
  const result = await this.sieveQuery(model, ProductRepository.SIEVE_FIELDS, {
532
995
  baseQuery: this.buildScopedQuery(opts),
533
996
  defaultPageSize: 20,
@@ -540,24 +1003,13 @@ export class ProductRepository extends BaseRepository {
540
1003
  // nothing actually refined them — so "red dranzer" returned everything
541
1004
  // matching "dranzer" and the second word did nothing at all.
542
1005
  //
543
- // Same shape as faqsRepository.list, deliberately: one implementation of
544
- // "AND the remaining terms" that both collections read the same way.
545
- const terms = parseSearchTxtQuery(opts?.search ?? "");
546
- const extraTerms = terms.slice(1);
547
- if (extraTerms.length === 0)
548
- return result;
549
- const items = result.items.filter((p) => matchesAllSearchTerms(p.searchTxt, extraTerms));
550
- // `total` becomes a FLOOR once rows are dropped after the page was cut —
551
- // it counts this page only. That is the known post-pagination-filter debt
552
- // (Phase 5), not a new invention: reporting the pre-filter total here would
553
- // promise pages that render empty.
554
- return {
555
- ...result,
556
- items,
557
- total: items.length,
558
- totalPages: items.length === 0 ? 0 : 1,
559
- hasMore: false,
560
- };
1006
+ // `refineSearchTxt` is that AND-refine, shared with the other eleven
1007
+ // migrated collections. It also owns the `total`-becomes-a-FLOOR
1008
+ // bookkeeping: once rows are dropped after the page was cut, `total` counts
1009
+ // this page only. That is the known post-pagination-filter debt, not a new
1010
+ // invention — reporting the pre-filter total would promise pages that
1011
+ // render empty.
1012
+ return refineSearchTxt(result, plan.rest);
561
1013
  }
562
1014
  /**
563
1015
  * How many products of each listing type exist within a scope — the numbers
@@ -577,21 +1029,21 @@ export class ProductRepository extends BaseRepository {
577
1029
  aliases: ProductRepository.FILTER_ALIASES,
578
1030
  });
579
1031
  }
580
- async incrementViewCount(productId) {
581
- try {
582
- await this.db
583
- .collection(this.collection)
584
- .doc(productId)
585
- .update({
586
- [PRODUCT_FIELDS.VIEW_COUNT]: increment(1),
587
- });
588
- // Fire-and-forget analytics: a lost view increment changes no rendered
589
- // content, so it must never break the product read path it rides with.
590
- }
591
- catch (_err) {
592
- void normalizeError(_err);
593
- }
594
- }
1032
+ /*
1033
+ * 🛑 `incrementViewCount` DELETED 2026-10-10 — zero callers since
1034
+ * 2026-08-31.
1035
+ *
1036
+ * It was removed from the product-detail render path then, for two good
1037
+ * reasons recorded in `_internal/server/features/products/data.ts`: it was a
1038
+ * Firestore WRITE on every render (Rule #6), and it double-counted against
1039
+ * `pageViews`, which is now the single counter. The method itself was left
1040
+ * behind, so `viewCount` has had a writer in the codebase and none in
1041
+ * practice ever since — which is how a "Most Viewed" sort and a "0 views"
1042
+ * column both shipped against a field no document has.
1043
+ *
1044
+ * Keeping a writer nobody calls is what makes the dead field look alive.
1045
+ * `pageViews` is the counter; roll it up rather than reviving this.
1046
+ */
595
1047
  /**
596
1048
  * Cloud Functions: find published auctions whose end date has already passed.
597
1049
  * Used by the auction settlement job to finalize expired auctions.