@mohasinac/appkit 4.42.18 → 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 (263) hide show
  1. package/dist/_internal/client/features/lottery/LotteryDetailView.js +22 -1
  2. package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
  3. package/dist/_internal/server/features/categories/adapters.js +235 -0
  4. package/dist/_internal/server/features/checkout/actions.js +161 -22
  5. package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
  6. package/dist/_internal/server/features/orders/adapters.js +21 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +24 -0
  8. package/dist/_internal/server/features/products/list-public.js +10 -2
  9. package/dist/_internal/server/features/refunds/actions.js +1 -1
  10. package/dist/_internal/server/features/seo/sitemap.js +54 -15
  11. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/stores/adapters.js +6 -0
  13. package/dist/_internal/server/functions/scheduled.d.ts +4 -2
  14. package/dist/_internal/server/functions/scheduled.js +64 -6
  15. package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
  16. package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
  17. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
  18. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
  19. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
  20. package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
  21. package/dist/_internal/server/jobs/core/onCategoryWrite.js +59 -0
  22. package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
  23. package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
  24. package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
  26. package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
  27. package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
  28. package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
  29. package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
  30. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
  31. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
  32. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
  33. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
  34. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
  35. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
  36. package/dist/_internal/server/jobs/handlers/index.js +3 -1
  37. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
  38. package/dist/_internal/server/jobs/handlers/messages.js +5 -2
  39. package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
  40. package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
  41. package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
  42. package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
  43. package/dist/_internal/shared/actions/action-registry.js +34 -0
  44. package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
  45. package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
  46. package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
  47. package/dist/_internal/shared/checkout/order-math.js +194 -7
  48. package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
  49. package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
  50. package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
  51. package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
  52. package/dist/_internal/shared/fees/calculator.d.ts +105 -0
  53. package/dist/_internal/shared/fees/calculator.js +143 -0
  54. package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
  55. package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
  56. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
  57. package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
  58. package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
  59. package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
  60. package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
  61. package/dist/_internal/shared/templating/placeholders.js +73 -0
  62. package/dist/client.d.ts +2 -0
  63. package/dist/client.js +1 -0
  64. package/dist/constants/api-endpoints.d.ts +42 -0
  65. package/dist/constants/api-endpoints.js +14 -0
  66. package/dist/features/admin/actions/admin-actions.d.ts +1 -1
  67. package/dist/features/admin/actions/admin-actions.js +1 -1
  68. package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
  69. package/dist/features/admin/components/AdminProductEditorView.js +8 -1
  70. package/dist/features/admin/components/AdminProductsView.js +78 -7
  71. package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
  72. package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
  73. package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
  74. package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
  75. package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
  76. package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
  77. package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
  78. package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
  79. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  80. package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
  81. package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
  82. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  83. package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
  84. package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
  85. package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
  86. package/dist/features/auth/schemas/firestore.d.ts +0 -1
  87. package/dist/features/blog/api/route.js +16 -1
  88. package/dist/features/blog/schemas/firestore.d.ts +2 -2
  89. package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
  90. package/dist/features/categories/actions/category-actions.d.ts +2 -2
  91. package/dist/features/categories/actions/category-actions.js +22 -10
  92. package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
  93. package/dist/features/categories/api/[id]/description-template/route.js +252 -0
  94. package/dist/features/categories/api/route.js +156 -11
  95. package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
  96. package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
  97. package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
  98. package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
  99. package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
  100. package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
  101. package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
  102. package/dist/features/categories/constants/listing-categories.d.ts +17 -0
  103. package/dist/features/categories/constants/listing-categories.js +53 -0
  104. package/dist/features/categories/hooks/useCategoryTree.js +28 -2
  105. package/dist/features/categories/repository/categories.repository.d.ts +73 -12
  106. package/dist/features/categories/repository/categories.repository.js +203 -42
  107. package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
  108. package/dist/features/categories/repository/price-index.repository.js +85 -0
  109. package/dist/features/categories/schemas/category-content.d.ts +206 -0
  110. package/dist/features/categories/schemas/category-content.js +28 -0
  111. package/dist/features/categories/schemas/firestore.d.ts +74 -5
  112. package/dist/features/categories/schemas/firestore.js +74 -3
  113. package/dist/features/categories/schemas/index.d.ts +1047 -0
  114. package/dist/features/categories/schemas/index.js +96 -0
  115. package/dist/features/categories/server.d.ts +1 -0
  116. package/dist/features/categories/server.js +7 -0
  117. package/dist/features/categories/types/index.d.ts +9 -0
  118. package/dist/features/events/schemas/firestore.d.ts +2 -2
  119. package/dist/features/faq/schemas/firestore.d.ts +2 -1
  120. package/dist/features/faq/schemas/firestore.js +20 -8
  121. package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
  122. package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
  123. package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
  124. package/dist/features/homepage/lib/live-stats.js +18 -8
  125. package/dist/features/media/MediaImage.js +19 -2
  126. package/dist/features/orders/schemas/firestore.d.ts +70 -0
  127. package/dist/features/orders/types/index.d.ts +55 -0
  128. package/dist/features/products/actions/product-actions.d.ts +0 -9
  129. package/dist/features/products/actions/product-actions.js +25 -0
  130. package/dist/features/products/columns/index.d.ts +0 -1
  131. package/dist/features/products/columns/index.js +5 -1
  132. package/dist/features/products/components/ArtStickersListView.js +7 -1
  133. package/dist/features/products/components/PrizeDrawsSection.js +5 -0
  134. package/dist/features/products/components/ProductFilters.d.ts +0 -9
  135. package/dist/features/products/components/ProductForm.d.ts +27 -1
  136. package/dist/features/products/components/ProductForm.js +26 -12
  137. package/dist/features/products/components/ProductsIndexPageView.js +1 -1
  138. package/dist/features/products/constants/sieve.d.ts +0 -6
  139. package/dist/features/products/constants/sieve.js +35 -2
  140. package/dist/features/products/repository/products.repository.d.ts +115 -1
  141. package/dist/features/products/repository/products.repository.js +491 -39
  142. package/dist/features/products/schemas/firestore.d.ts +65 -5
  143. package/dist/features/products/schemas/firestore.js +9 -0
  144. package/dist/features/products/schemas/product-features.d.ts +140 -2
  145. package/dist/features/products/schemas/product-features.js +62 -1
  146. package/dist/features/promotions/repository/coupons.repository.js +6 -1
  147. package/dist/features/promotions/schemas/firestore.d.ts +44 -1
  148. package/dist/features/promotions/schemas/firestore.js +76 -2
  149. package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
  150. package/dist/features/reviews/repository/reviews.repository.js +35 -0
  151. package/dist/features/seller/actions/seller-actions.js +17 -2
  152. package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
  153. package/dist/features/seller/components/PrintCenterView.js +3 -2
  154. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
  155. package/dist/features/seller/components/SellerOrdersView.js +6 -1
  156. package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
  157. package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
  158. package/dist/features/seller/components/SellerProductShell.js +101 -9
  159. package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
  160. package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
  161. package/dist/features/seller/components/SellerProductsView.js +108 -3
  162. package/dist/features/seller/types/index.d.ts +17 -0
  163. package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
  164. package/dist/features/seller/utils/product-draft-mapping.js +17 -0
  165. package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
  166. package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
  167. package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
  168. package/dist/features/stores/schemas/firestore.d.ts +31 -2
  169. package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
  170. package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
  171. package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
  172. package/dist/features/tax-codes/schemas/firestore.js +82 -0
  173. package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
  174. package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
  175. package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
  176. package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
  177. package/dist/features/tester/actions/index.d.ts +0 -1
  178. package/dist/features/tester/actions/index.js +0 -1
  179. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
  180. package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
  181. package/dist/features/tester/components/index.d.ts +0 -4
  182. package/dist/features/tester/components/index.js +0 -2
  183. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
  184. package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
  185. package/dist/features/tester/schemas/firestore.d.ts +0 -6
  186. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
  187. package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
  188. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
  189. package/dist/features/tester/seed-data/authored/index.js +0 -2
  190. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
  191. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +10 -29
  192. package/dist/index.d.ts +21 -7
  193. package/dist/index.js +53 -7
  194. package/dist/next/routing/route-map.d.ts +2 -4
  195. package/dist/next/routing/route-map.js +1 -2
  196. package/dist/repositories/index.d.ts +1 -0
  197. package/dist/repositories/index.js +1 -0
  198. package/dist/schemas/registry.d.ts +506 -0
  199. package/dist/seed/_helpers/brand-rows.d.ts +35 -0
  200. package/dist/seed/_helpers/brand-rows.js +576 -0
  201. package/dist/seed/_helpers/category-content.d.ts +39 -0
  202. package/dist/seed/_helpers/category-content.js +634 -0
  203. package/dist/seed/_helpers/category-forest.d.ts +2 -0
  204. package/dist/seed/_helpers/category-forest.js +585 -243
  205. package/dist/seed/_helpers/category-models.d.ts +5 -0
  206. package/dist/seed/_helpers/category-models.js +1364 -0
  207. package/dist/seed/_helpers/category-tree.js +17 -1
  208. package/dist/seed/_helpers/character-features.d.ts +10 -0
  209. package/dist/seed/_helpers/character-features.js +118 -0
  210. package/dist/seed/_helpers/media.d.ts +1 -1
  211. package/dist/seed/_helpers/media.js +44 -8
  212. package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
  213. package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
  214. package/dist/seed/categories-seed-data.js +147 -169
  215. package/dist/seed/index.d.ts +1 -0
  216. package/dist/seed/index.js +1 -0
  217. package/dist/seed/manifest.d.ts +1 -1
  218. package/dist/seed/manifest.js +5 -0
  219. package/dist/seed/orders-seed-data.js +5 -3
  220. package/dist/seed/product-features-seed-data.d.ts +1 -8
  221. package/dist/seed/product-features-seed-data.js +358 -154
  222. package/dist/seed/products-art-seed-data.js +16 -4
  223. package/dist/seed/products-auctions-seed-data.js +6 -6
  224. package/dist/seed/products-classifieds-seed-data.js +17 -7
  225. package/dist/seed/products-digital-codes-seed-data.js +12 -12
  226. package/dist/seed/products-preorders-seed-data.js +8 -8
  227. package/dist/seed/products-prize-draws-seed-data.js +25 -8
  228. package/dist/seed/products-standard-seed-data.js +10 -60
  229. package/dist/seed/products-stickers-seed-data.js +6 -6
  230. package/dist/seed/site-settings-seed-data.js +24 -0
  231. package/dist/seed/store-extensions-seed-data.js +10 -4
  232. package/dist/seed/tax-codes-seed-data.d.ts +34 -0
  233. package/dist/seed/tax-codes-seed-data.js +100 -0
  234. package/dist/seed/test-data-ttl.d.ts +29 -0
  235. package/dist/seed/test-data-ttl.js +31 -0
  236. package/dist/server-entry.d.ts +1 -1
  237. package/dist/server-entry.js +6 -1
  238. package/dist/server.d.ts +4 -1
  239. package/dist/server.js +11 -2
  240. package/dist/styles.css +40 -0
  241. package/dist/ui/components/PaginatedSelect.d.ts +14 -0
  242. package/dist/ui/components/PaginatedSelect.js +0 -0
  243. package/dist/ui/components/PaginatedSelect.style.css +40 -0
  244. package/dist/utils/media-url.d.ts +26 -0
  245. package/dist/utils/media-url.js +144 -0
  246. package/dist/utils/physical-location.d.ts +23 -0
  247. package/dist/utils/physical-location.js +52 -0
  248. package/dist/utils/search-txt-builders.d.ts +20 -0
  249. package/dist/utils/search-txt-builders.js +36 -0
  250. package/package.json +1 -1
  251. package/scripts/seed-cli.mjs +4 -1
  252. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
  253. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
  254. package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
  255. package/dist/features/tester/actions/leaderboard-actions.js +0 -5
  256. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
  257. package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
  258. package/dist/features/tester/components/TesterHubView.d.ts +0 -5
  259. package/dist/features/tester/components/TesterHubView.js +0 -110
  260. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
  261. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
  262. package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
  263. package/dist/features/tester/seed-data/tester-ttl.js +0 -6
@@ -124,13 +124,37 @@ export interface ProductPrintMeta {
124
124
  /** Limited-edition print run size; omitted for open editions. */
125
125
  editionSize?: number;
126
126
  }
127
+ /**
128
+ * Licensed manufacture vs. an unbranded 1st copy vs. not established.
129
+ *
130
+ * 🛑 THERE IS NO "fake" VALUE, and that is a product decision rather than an
131
+ * omission. A poor-quality counterfeit — BAS's own buying guide names cadmium
132
+ * and lead in the metal parts — is not a listing state we offer. It is a
133
+ * report, and it belongs in the scam registry, which already has 27 scam
134
+ * types and an evidence model.
135
+ *
136
+ * "reproduction" is the midfake tier: largely Chinese, no manufacturer's
137
+ * marque, generally decent quality, and some WBO formats permit certain repro
138
+ * parts — so it is sellable WHEN LABELLED. "unverified" exists because the
139
+ * alternative is far worse: without it, an honest seller who does not know
140
+ * would have to pick "original", and silence would be indistinguishable from
141
+ * a genuine claim.
142
+ *
143
+ * 🛑 And the buyer-facing framing to design against is NOT "fake". The
144
+ * counterfeit seller in the crawl makes no authenticity claim at all — zero
145
+ * occurrences of original/Takara/authentic/replica/fake across 1,277 products
146
+ * — and sells itself as an "aftermarket brand" with "upgraded components".
147
+ * "Aftermarket" and "custom" are the words our buyers will be sold, so the
148
+ * help copy has to answer that, not a word nobody uses.
149
+ */
150
+ export type ProductAuthenticity = "original" | "reproduction" | "unverified";
127
151
  export interface ProductDocument extends BaseDocument {
128
152
  title: string;
129
153
  description: string;
130
154
  slug?: string;
131
- /** Tester sandbox flag — set on shared admin-seeded test products; swept by testerSandboxCleanup. */
155
+ /** Test-data flag — set on shared admin-seeded test products; swept by testDataCleanup. */
132
156
  isTestData?: boolean;
133
- /** When isTestData, the cutoff after which testerSandboxCleanup deletes this doc (cascades to bids). */
157
+ /** When isTestData, the cutoff after which testDataCleanup deletes this doc (cascades to bids). */
134
158
  testDataExpiresAt?: Date;
135
159
  seoTitle?: string;
136
160
  seoDescription?: string;
@@ -251,10 +275,46 @@ export interface ProductDocument extends BaseDocument {
251
275
  isSold?: boolean;
252
276
  /** When true, an EMI order for this product ships as soon as it's confirmed instead of waiting for every installment to be paid. Default false. */
253
277
  allowShipBeforeEmiComplete?: boolean;
254
- /** P-8 GST — buyer-facing tax rate on this product (%). Unset/0 = exempt. */
278
+ /**
279
+ * P-8 GST — buyer-facing tax rate on this product (%).
280
+ *
281
+ * 🛑 `undefined` and `0` MEAN DIFFERENT THINGS. Zero is a deliberate
282
+ * exemption (live plants and animals); undefined is "nobody has said".
283
+ * So the test is `== null`, NEVER `input.gstRate || default`, which would
284
+ * silently overwrite every exempt row with the default rate. And there is
285
+ * deliberately no entry for this in `DEFAULT_PRODUCT_DATA` — a default
286
+ * there makes `undefined` unreachable in new rows while existing rows
287
+ * still lack it, which is the `finalSale` trap one field over.
288
+ *
289
+ * Written by `deriveTaxonomy` from the category's `taxCodeId`, but the
290
+ * SCALAR is what every reader and the order-item snapshot use: a tax-code
291
+ * edit must change future derivations, never retroactively re-rate an
292
+ * invoice already issued.
293
+ */
255
294
  gstRate?: 0 | 5 | 12 | 18 | 28;
256
- /** P-8 GST — Harmonized System of Nomenclature code for GST-compliant invoices. */
295
+ /** P-8 GST — HSN code for GST-compliant invoices. Resolved alongside `gstRate`. */
257
296
  hsnCode?: string;
297
+ /**
298
+ * WHO MADE IT — distinct from `condition` (how worn) and from `brand`
299
+ * (whose marque is on it, which a reproduction has none of). The two axes
300
+ * are independent: an original can be broken and a reproduction can be
301
+ * mint.
302
+ *
303
+ * 🛑 This is BOTH a field and a feature, deliberately, and it is the only
304
+ * place in this schema where that duplication is justified. Structurally
305
+ * it is a classifier and belongs in the `authenticity` FeatureGroup — but
306
+ * a feature can be OMITTED and nothing notices. A mislabelled
307
+ * reproduction is a refund; a mislabelled counterfeit is a child handling
308
+ * leaded metal. The enum makes the question unskippable and writes the
309
+ * matching feature, so the facet, the badge and search all read one
310
+ * vocabulary.
311
+ *
312
+ * Validated by the competitor crawl: worldhobbyshop ships a nav category
313
+ * called "MidFake Beyblades" and an FAQ promising that authenticity and
314
+ * condition are both disclosed per listing. Hence "Midfake" as the LABEL
315
+ * on the reproduction tier — it is the word buyers search.
316
+ */
317
+ authenticity?: ProductAuthenticity;
258
318
  /** Print-specific metadata for "art" / "stickers" listings — printed-only physical goods. Optional on every listing type; only populated for art/stickers. */
259
319
  printMeta?: ProductPrintMeta;
260
320
  promotionEndDate?: Date;
@@ -423,7 +483,7 @@ export declare const PRODUCT_COLLECTION: "products";
423
483
  export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId"];
424
484
  export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
425
485
  export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
426
- export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
486
+ export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "gstRate", "hsnCode", "authenticity", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
427
487
  export type ProductCreateInput = Omit<ProductDocument, "id" | "createdAt" | "updatedAt" | "availableQuantity" | "bidCount" | "currentBid" | "auctionOriginalEndDate">;
428
488
  export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_UPDATABLE_FIELDS)[number]>>;
429
489
  export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
@@ -173,6 +173,15 @@ export const PRODUCT_UPDATABLE_FIELDS = [
173
173
  "isOnSale",
174
174
  "isSold",
175
175
  "allowShipBeforeEmiComplete",
176
+ /*
177
+ * B3 — these three were MISSING, so the fields existed on the document
178
+ * and were not type-legal to update: ProductUpdateInput is derived from
179
+ * this list, so an admin or seller form could accept a GST rate and the
180
+ * repository would refuse to write it.
181
+ */
182
+ "gstRate",
183
+ "hsnCode",
184
+ "authenticity",
176
185
  "printMeta",
177
186
  "seoTitle",
178
187
  "seoDescription",
@@ -17,7 +17,22 @@
17
17
  export declare const PRODUCT_FEATURES_COLLECTION: "productFeatures";
18
18
  export declare const PRODUCT_FEATURE_PREFIX: "feature-";
19
19
  export declare const MAX_STORE_CUSTOM_FEATURES = 20;
20
- export declare const MAX_FEATURES_PER_PRODUCT = 10;
20
+ /**
21
+ * Hard cap on `product.features[].length`.
22
+ *
23
+ * 🛑 RAISED 10 → 24 (B3). Ten was sized for *badges* — a handful of
24
+ * "Free Shipping"-style trust marks. This collection is now the platform's one
25
+ * controlled VOCABULARY (`tags` is retired), and a single realistic listing
26
+ * spends six before anything optional: a reproduction, new-in-box,
27
+ * japan-import, attack-type, right-spin, limited-release bey. A TCG single
28
+ * adds language + format + grade on top.
29
+ *
30
+ * 24 rather than unbounded because `features[]` is `array-contains`-indexed and
31
+ * feeds `buildProductSearchTxt`, which truncates at 600 tokens SILENTLY — an
32
+ * unbounded feature list would quietly evict real title/description tokens
33
+ * from the search index.
34
+ */
35
+ export declare const MAX_FEATURES_PER_PRODUCT = 24;
21
36
  export type ProductFeatureScope = "platform" | "store";
22
37
  export type ProductFeatureCategory = "shipping" | "seller" | "condition" | "platform" | "auction" | "preorder" | "custom";
23
38
  export type ProductFeatureProductType = "product" | "auction" | "preorder" | "prize-draw" | "classified" | "digital-code" | "live" | "art" | "stickers" | "all";
@@ -27,6 +42,90 @@ export type ProductFeatureProductType = "product" | "auction" | "preorder" | "pr
27
42
  */
28
43
  export type ProductFeatureIcon = string;
29
44
  export declare function isFeatureIconPath(icon: string): boolean;
45
+ /**
46
+ * The vocabulary's namespace.
47
+ *
48
+ * 🛑 NOT the same axis as `category` above, which groups features by the
49
+ * SURFACE that shows them (shipping/seller/platform/auction/preorder) and is
50
+ * what drives badge placement. `group` namespaces what a feature MEANS, and
51
+ * drives the filter drawer: one facet section per group, rather than one flat
52
+ * 60-value checkbox list.
53
+ *
54
+ * Namespacing is load-bearing because **the same word means different things
55
+ * per vertical**: "sealed" on a TCG booster box is a factory seal, on a bey it
56
+ * is an unopened blister; "premium" is a Hot Wheels sub-line, not a quality
57
+ * claim. A single flat vocabulary would have to pick one meaning.
58
+ *
59
+ * Values derived from the measured competitor crawl — see
60
+ * `docs/research/competitor-crawl-2026-10/vocabulary.md`, which records the
61
+ * `n` behind each group.
62
+ */
63
+ export type FeatureGroup =
64
+ /** How worn / how packaged. Independent of `authenticity`. */
65
+ "condition"
66
+ /** Who made it. Mirrors `ProductDocument.authenticity`; see the note there. */
67
+ | "authenticity"
68
+ /** Regular / limited / early / random-booster / RLC — a real price multiplier. */
69
+ | "release"
70
+ /** Beyblade battle type: attack · defense · stamina · balance. */
71
+ | "type"
72
+ /** right-spin · left-spin · dual-spin. */
73
+ | "spin"
74
+ /** Merchandising state: clearance, on-sale, best-seller. */
75
+ | "commercial"
76
+ /** Where it came from: japan-import · imported · domestic. */
77
+ | "sourcing"
78
+ /** Hot Wheels chase variants, incl. the India-only blister card. */
79
+ | "chase"
80
+ /** PSA/BGS/CGC grade. Pairs with the `sublisting` + `itemCode` axis. */
81
+ | "grade"
82
+ /** TCG print language. */
83
+ | "tcg-lang"
84
+ /** TCG product format — booster box vs single differs ~20× in price. */
85
+ | "tcg-format"
86
+ /** Figure scale, finish and packaging state. */
87
+ | "figure"
88
+ /** WBO / limited format legality. */
89
+ | "tournament"
90
+ /**
91
+ * The CHARACTER the item is of, or belongs to.
92
+ *
93
+ * ONE axis across every vertical here, which is the point: a bey's anime
94
+ * blader (Dragoon G -> Tyson Granger), a comic figure's hero (a Marvel
95
+ * Legends Spider-Man -> Spider-Man), an anime figure's character
96
+ * (Nendoroid Luffy -> Monkey D. Luffy), a card's franchise face. A
97
+ * collector who wants "everything Spider-Man" wants the figure, the card
98
+ * and the die-cast together, and no category tree can give them that —
99
+ * each of those three lives under a different root.
100
+ *
101
+ * 93 bladers ship with B5, GENERATED from the corpus. Figure and card
102
+ * characters join in B6, as the same group with the same mechanics; only
103
+ * the slug prefix differs (`blader-` vs `character-`), so a URL says which
104
+ * kind of name it is.
105
+ *
106
+ * 🛑 A FEATURE and never a tree node, for the reason §4.4 gives: a
107
+ * character crosses every generation. Tyson Granger owns nine beys spanning
108
+ * Spin Gear, Magnacore, Engine Gear and HMS, so a `category-tyson-granger`
109
+ * node would either duplicate all nine under a second parent or break the
110
+ * one-chain-per-product invariant that lets a category page match on its
111
+ * own id alone.
112
+ *
113
+ * As a feature it is clickable from any listing surface
114
+ * (`?features=feature-blader-tyson-granger`), it faceted-filters, it is
115
+ * indexed into `searchTxt` so "Tyson" finds Dragoon, and it composes with
116
+ * the generation tree rather than competing with it — browse Beyblade
117
+ * Original, then narrow to Tyson's.
118
+ */
119
+ | "character";
120
+ export declare const FEATURE_GROUP_VALUES: readonly ["condition", "authenticity", "release", "type", "spin", "commercial", "sourcing", "chase", "grade", "tcg-lang", "tcg-format", "figure", "tournament", "character"];
121
+ /**
122
+ * Human-facing section headings for the filter drawer, one per group.
123
+ *
124
+ * A `Record<FeatureGroup, string>` on purpose: a fourteenth group cannot
125
+ * compile without a label, which is the failure mode Root Cause #61 describes
126
+ * (ten hand-written enumerations of one union, nine of which drifted).
127
+ */
128
+ export declare const FEATURE_GROUP_LABEL: Record<FeatureGroup, string>;
30
129
  export interface ProductFeatureDocument {
31
130
  id: string;
32
131
  slug: string;
@@ -37,17 +136,48 @@ export interface ProductFeatureDocument {
37
136
  /** CSS variable token key, e.g. "--appkit-color-primary". Optional — defaults to neutral. */
38
137
  iconColor?: string;
39
138
  category: ProductFeatureCategory;
139
+ /**
140
+ * The vocabulary namespace — see `FeatureGroup`. Optional only so the 10
141
+ * pre-B3 rows keep parsing; the seed sets it on every row and the admin form
142
+ * requires it. An ungrouped feature renders in no facet section.
143
+ */
144
+ group?: FeatureGroup;
40
145
  scope: ProductFeatureScope;
41
146
  productTypes: ProductFeatureProductType[];
42
147
  /** Required when scope === "store". */
43
148
  storeId?: string;
44
149
  isActive: boolean;
45
150
  displayOrder: number;
151
+ /**
152
+ * Alternative spellings that should FIND this feature.
153
+ *
154
+ * Three sources, all measured — see
155
+ * `docs/research/competitor-crawl-2026-10/counterfeit-signals.md`:
156
+ * · part-code pairs, so "rubber flat" finds `RF` and `RF` finds "rubber flat"
157
+ * · JP / romaji names
158
+ * · 🛑 the counterfeit seller's systematic MISSPELLINGS (`Pegasis`,
159
+ * `Jupitar`, `Fireblase`). A counterfeiter's trademark-evasion spelling
160
+ * is exactly what a real buyer mistypes, so indexing them makes our
161
+ * search tolerant of real typos at zero query-time cost.
162
+ *
163
+ * 🛑 Budgeted, not unbounded: these fold into `searchTxt`, which truncates at
164
+ * 600 tokens SILENTLY, so an over-long alias list evicts tokens nobody chose
165
+ * to drop.
166
+ */
167
+ aliases?: string[];
168
+ /**
169
+ * Edge-n-gram search index for this collection, so the picker can search at
170
+ * all. 🛑 Must be built from the LABEL and `aliases`, never from the slug:
171
+ * `feature-nib` tokenises to `feature`/`nib`, so the label "New in Box" is
172
+ * unfindable, which is the same defect `buildProductSearchTxt` has today for
173
+ * `features[]` (it indexes the raw ids).
174
+ */
175
+ searchTxt?: string[];
46
176
  createdAt: Date;
47
177
  updatedAt: Date;
48
178
  }
49
179
  export type ProductFeatureCreateInput = Omit<ProductFeatureDocument, "id" | "slug" | "createdAt" | "updatedAt">;
50
- export type ProductFeatureUpdateInput = Partial<Pick<ProductFeatureDocument, "label" | "description" | "icon" | "iconColor" | "category" | "productTypes" | "isActive" | "displayOrder">>;
180
+ export type ProductFeatureUpdateInput = Partial<Pick<ProductFeatureDocument, "label" | "description" | "icon" | "iconColor" | "category" | "group" | "aliases" | "productTypes" | "isActive" | "displayOrder">>;
51
181
  export declare const PRODUCT_FEATURE_SIEVE_FIELDS: {
52
182
  readonly label: {
53
183
  readonly canFilter: true;
@@ -69,6 +199,14 @@ export declare const PRODUCT_FEATURE_SIEVE_FIELDS: {
69
199
  readonly canFilter: true;
70
200
  readonly canSort: false;
71
201
  };
202
+ readonly group: {
203
+ readonly canFilter: true;
204
+ readonly canSort: false;
205
+ };
206
+ readonly searchTxt: {
207
+ readonly canFilter: true;
208
+ readonly canSort: false;
209
+ };
72
210
  readonly isActive: {
73
211
  readonly canFilter: true;
74
212
  readonly canSort: false;
@@ -17,16 +17,77 @@
17
17
  export const PRODUCT_FEATURES_COLLECTION = "productFeatures";
18
18
  export const PRODUCT_FEATURE_PREFIX = "feature-";
19
19
  export const MAX_STORE_CUSTOM_FEATURES = 20;
20
- export const MAX_FEATURES_PER_PRODUCT = 10;
20
+ /**
21
+ * Hard cap on `product.features[].length`.
22
+ *
23
+ * 🛑 RAISED 10 → 24 (B3). Ten was sized for *badges* — a handful of
24
+ * "Free Shipping"-style trust marks. This collection is now the platform's one
25
+ * controlled VOCABULARY (`tags` is retired), and a single realistic listing
26
+ * spends six before anything optional: a reproduction, new-in-box,
27
+ * japan-import, attack-type, right-spin, limited-release bey. A TCG single
28
+ * adds language + format + grade on top.
29
+ *
30
+ * 24 rather than unbounded because `features[]` is `array-contains`-indexed and
31
+ * feeds `buildProductSearchTxt`, which truncates at 600 tokens SILENTLY — an
32
+ * unbounded feature list would quietly evict real title/description tokens
33
+ * from the search index.
34
+ */
35
+ export const MAX_FEATURES_PER_PRODUCT = 24;
21
36
  export function isFeatureIconPath(icon) {
22
37
  return icon.trimStart().startsWith("M ") || icon.trimStart().startsWith("m ");
23
38
  }
39
+ export const FEATURE_GROUP_VALUES = [
40
+ "condition",
41
+ "authenticity",
42
+ "release",
43
+ "type",
44
+ "spin",
45
+ "commercial",
46
+ "sourcing",
47
+ "chase",
48
+ "grade",
49
+ "tcg-lang",
50
+ "tcg-format",
51
+ "figure",
52
+ "tournament",
53
+ "character",
54
+ ];
55
+ /**
56
+ * Human-facing section headings for the filter drawer, one per group.
57
+ *
58
+ * A `Record<FeatureGroup, string>` on purpose: a fourteenth group cannot
59
+ * compile without a label, which is the failure mode Root Cause #61 describes
60
+ * (ten hand-written enumerations of one union, nine of which drifted).
61
+ */
62
+ export const FEATURE_GROUP_LABEL = {
63
+ condition: "Condition",
64
+ authenticity: "Authenticity",
65
+ release: "Release type",
66
+ type: "Battle type",
67
+ spin: "Spin direction",
68
+ commercial: "Offers",
69
+ sourcing: "Sourcing",
70
+ chase: "Chase & rarity",
71
+ grade: "Grading",
72
+ "tcg-lang": "Card language",
73
+ "tcg-format": "Card format",
74
+ figure: "Figure details",
75
+ tournament: "Tournament legality",
76
+ character: "Blader & character",
77
+ };
24
78
  export const PRODUCT_FEATURE_SIEVE_FIELDS = {
25
79
  label: { canFilter: true, canSort: true },
26
80
  slug: { canFilter: true, canSort: false },
27
81
  scope: { canFilter: true, canSort: false },
28
82
  storeId: { canFilter: true, canSort: false },
29
83
  category: { canFilter: true, canSort: false },
84
+ group: { canFilter: true, canSort: false },
85
+ /*
86
+ * The picker's search. `array-contains` on an edge-n-gram index, the same
87
+ * shape every other searchable collection uses — a flat Checkbox grid is
88
+ * fine for 10 rows and unusable at ~60.
89
+ */
90
+ searchTxt: { canFilter: true, canSort: false },
30
91
  isActive: { canFilter: true, canSort: false },
31
92
  displayOrder: { canFilter: false, canSort: true },
32
93
  createdAt: { canFilter: false, canSort: true },
@@ -458,7 +458,12 @@ export class CouponsRepository extends BaseRepository {
458
458
  message: "Minimum purchase requirement not met",
459
459
  };
460
460
  }
461
- const discountAmount = calculateDiscount(coupon, eligibleSubtotal);
461
+ /*
462
+ * `eligible` is passed through, not just its subtotal — `buy_x_get_y`
463
+ * needs the individual unit prices to know which units are free, and
464
+ * without them it silently returned 0 for every BOGO coupon ever created.
465
+ */
466
+ const discountAmount = calculateDiscount(coupon, eligibleSubtotal, eligible);
462
467
  return {
463
468
  valid: true,
464
469
  coupon,
@@ -163,5 +163,48 @@ export declare function sellerCouponCodePrefix(storeSlug: string): string;
163
163
  export declare function buildSellerCouponCode(storeSlug: string, sellerCode: string): string;
164
164
  export declare function isCouponValid(coupon: CouponDocument): boolean;
165
165
  export declare function canUserUseCoupon(coupon: CouponDocument, userUsageCount: number): boolean;
166
- export declare function calculateDiscount(coupon: CouponDocument, orderTotal: number): number;
166
+ /** One cart line, as much of it as a BOGO calculation needs. */
167
+ export interface DiscountableLine {
168
+ price: number;
169
+ quantity: number;
170
+ }
171
+ /**
172
+ * "Buy X, get Y free" over the ELIGIBLE lines.
173
+ *
174
+ * 🛑 THE BUG THIS CLOSES: a BOGO coupon discounted ₹0.
175
+ *
176
+ * `calculateDiscount` had `case "buy_x_get_y": discountAmount = 0`, sharing a
177
+ * branch with `free_shipping`. That zero is CORRECT for free shipping — the
178
+ * waiver is honoured downstream as a shipping line, not as a discount — but
179
+ * `buy_x_get_y` had no second path anywhere: `coupon.bxgy` had **zero read
180
+ * sites in the repo**. So an admin could create and save a BOGO coupon,
181
+ * `validateCouponForCart` would answer "Coupon is valid", `CouponCard` would
182
+ * render a BOGO badge, and the buyer paid full price.
183
+ *
184
+ * WHY THIS NEEDS ITS OWN FUNCTION: BOGO cannot be computed from a total. Which
185
+ * units are free depends on individual unit prices, so the line items are
186
+ * required input. `calculateDiscount(coupon, total)` structurally could not
187
+ * express it, which is how the `= 0` came to look like a complete branch.
188
+ *
189
+ * THE RULE: units are grouped in blocks of `buyQuantity + getQuantity`, and in
190
+ * each complete block the CHEAPEST `getQuantity` units are free. Cheapest-free
191
+ * is the conventional retail reading of "buy 2 get 1 free" and the one a buyer
192
+ * expects to be charged; awarding the dearest would make the same promotion
193
+ * worth a different amount depending on basket order.
194
+ *
195
+ * 🛑 An INCOMPLETE block earns nothing. "Buy 2 get 1" on 2 units is a block of
196
+ * 2 against a required 3, so the discount is 0 — not a partial credit.
197
+ */
198
+ export declare function calculateBxgyDiscount(bxgy: BXGYConfig | undefined, lines: readonly DiscountableLine[]): number;
199
+ export declare function calculateDiscount(coupon: CouponDocument, orderTotal: number,
200
+ /**
201
+ * The eligible lines, for the types that need them.
202
+ *
203
+ * Optional because one of the two callers — `validateCoupon(code, userId,
204
+ * orderTotal)` — only ever has a total. That caller gets 0 for a BOGO
205
+ * coupon, which is honest: the discount is genuinely unknowable without the
206
+ * basket. `validateCouponForCart` DOES hold the eligible lines (it already
207
+ * reduces them to `eligibleSubtotal`) and passes them.
208
+ */
209
+ lines?: readonly DiscountableLine[]): number;
167
210
  export declare function getTieredDiscount(tiers: TieredDiscount[] | undefined, orderTotal: number): number;
@@ -133,7 +133,75 @@ export function canUserUseCoupon(coupon, userUsageCount) {
133
133
  return true;
134
134
  return userUsageCount < coupon.usage.perUserLimit;
135
135
  }
136
- export function calculateDiscount(coupon, orderTotal) {
136
+ /**
137
+ * "Buy X, get Y free" over the ELIGIBLE lines.
138
+ *
139
+ * 🛑 THE BUG THIS CLOSES: a BOGO coupon discounted ₹0.
140
+ *
141
+ * `calculateDiscount` had `case "buy_x_get_y": discountAmount = 0`, sharing a
142
+ * branch with `free_shipping`. That zero is CORRECT for free shipping — the
143
+ * waiver is honoured downstream as a shipping line, not as a discount — but
144
+ * `buy_x_get_y` had no second path anywhere: `coupon.bxgy` had **zero read
145
+ * sites in the repo**. So an admin could create and save a BOGO coupon,
146
+ * `validateCouponForCart` would answer "Coupon is valid", `CouponCard` would
147
+ * render a BOGO badge, and the buyer paid full price.
148
+ *
149
+ * WHY THIS NEEDS ITS OWN FUNCTION: BOGO cannot be computed from a total. Which
150
+ * units are free depends on individual unit prices, so the line items are
151
+ * required input. `calculateDiscount(coupon, total)` structurally could not
152
+ * express it, which is how the `= 0` came to look like a complete branch.
153
+ *
154
+ * THE RULE: units are grouped in blocks of `buyQuantity + getQuantity`, and in
155
+ * each complete block the CHEAPEST `getQuantity` units are free. Cheapest-free
156
+ * is the conventional retail reading of "buy 2 get 1 free" and the one a buyer
157
+ * expects to be charged; awarding the dearest would make the same promotion
158
+ * worth a different amount depending on basket order.
159
+ *
160
+ * 🛑 An INCOMPLETE block earns nothing. "Buy 2 get 1" on 2 units is a block of
161
+ * 2 against a required 3, so the discount is 0 — not a partial credit.
162
+ */
163
+ export function calculateBxgyDiscount(bxgy, lines) {
164
+ if (!bxgy)
165
+ return 0;
166
+ const buy = Math.floor(bxgy.buyQuantity);
167
+ const get = Math.floor(bxgy.getQuantity);
168
+ // A non-positive `get` awards nothing; a non-positive `buy` would make every
169
+ // unit free, which is a misconfiguration, not a promotion.
170
+ if (!Number.isFinite(buy) || !Number.isFinite(get) || buy <= 0 || get <= 0)
171
+ return 0;
172
+ // Expand to one entry per UNIT — a line of quantity 3 contributes 3 prices,
173
+ // because the free units are counted in units, not in lines.
174
+ const unitPrices = [];
175
+ for (const line of lines) {
176
+ const qty = Math.floor(line.quantity);
177
+ if (!Number.isFinite(qty) || qty <= 0)
178
+ continue;
179
+ if (!Number.isFinite(line.price) || line.price < 0)
180
+ continue;
181
+ for (let i = 0; i < qty; i++)
182
+ unitPrices.push(line.price);
183
+ }
184
+ const blockSize = buy + get;
185
+ const freeUnits = Math.floor(unitPrices.length / blockSize) * get;
186
+ if (freeUnits <= 0)
187
+ return 0;
188
+ unitPrices.sort((a, b) => a - b);
189
+ let discount = 0;
190
+ for (let i = 0; i < freeUnits; i++)
191
+ discount += unitPrices[i];
192
+ return discount;
193
+ }
194
+ export function calculateDiscount(coupon, orderTotal,
195
+ /**
196
+ * The eligible lines, for the types that need them.
197
+ *
198
+ * Optional because one of the two callers — `validateCoupon(code, userId,
199
+ * orderTotal)` — only ever has a total. That caller gets 0 for a BOGO
200
+ * coupon, which is honest: the discount is genuinely unknowable without the
201
+ * basket. `validateCouponForCart` DOES hold the eligible lines (it already
202
+ * reduces them to `eligibleSubtotal`) and passes them.
203
+ */
204
+ lines) {
137
205
  if (coupon.discount.minPurchase && orderTotal < coupon.discount.minPurchase)
138
206
  return 0;
139
207
  let discountAmount = 0;
@@ -149,8 +217,14 @@ export function calculateDiscount(coupon, orderTotal) {
149
217
  case "fixed":
150
218
  discountAmount = Math.min(coupon.discount.value, orderTotal);
151
219
  break;
152
- case "free_shipping":
153
220
  case "buy_x_get_y":
221
+ // Needs the basket — see calculateBxgyDiscount. Capped at the total so a
222
+ // misconfigured coupon cannot discount more than is being bought.
223
+ discountAmount = Math.min(calculateBxgyDiscount(coupon.bxgy, lines ?? []), orderTotal);
224
+ break;
225
+ case "free_shipping":
226
+ // Genuinely 0 HERE. The waiver is applied as a shipping line downstream,
227
+ // not as a discount — this is the one type for which zero is the answer.
154
228
  discountAmount = 0;
155
229
  break;
156
230
  }
@@ -42,6 +42,28 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
42
42
  findApprovedByUser(userId: string): Promise<ReviewDocument[]>;
43
43
  findPending(): Promise<ReviewDocument[]>;
44
44
  findByStatus(status: ReviewStatus): Promise<ReviewDocument[]>;
45
+ /**
46
+ * How many approved reviews there are site-wide, and their mean rating.
47
+ *
48
+ * 🛑 Replaces `reviewRepository.findAll()` on the homepage render path, which
49
+ * was an **unbounded full-collection scan** whose entire output was a count
50
+ * and an average: two numbers, billed one read per document, on every cold
51
+ * render. It was the single largest Firestore read on the site and it existed
52
+ * to print `"35+"` and `"4.6★"`.
53
+ *
54
+ * A Firestore aggregation is billed at **one read per 1000 index entries
55
+ * matched**, so this is ~1 read at any catalogue size this project will see,
56
+ * and it is EXACT rather than a daily-stale rollup — which is why no scheduled
57
+ * job was added for it (the three sibling presets in `live-stats.ts` already
58
+ * use `count()` for the same reason).
59
+ *
60
+ * Returns `average: null` when there are no approved reviews, so the caller
61
+ * renders nothing rather than `0.0★` — an empty catalogue is not a bad rating.
62
+ */
63
+ getApprovedSiteRatingSummary(): Promise<{
64
+ count: number;
65
+ average: number | null;
66
+ }>;
45
67
  findFeatured(limit?: number): Promise<ReviewDocument[]>;
46
68
  approve(reviewId: string, moderatorId: string, moderatorNote?: string): Promise<ReviewDocument>;
47
69
  reject(reviewId: string, moderatorId: string, rejectionReason: string, moderatorNote?: string): Promise<ReviewDocument>;
@@ -130,6 +130,41 @@ class ReviewRepository extends BaseRepository {
130
130
  async findByStatus(status) {
131
131
  return this.findBy(REVIEW_FIELDS.STATUS, status);
132
132
  }
133
+ /**
134
+ * How many approved reviews there are site-wide, and their mean rating.
135
+ *
136
+ * 🛑 Replaces `reviewRepository.findAll()` on the homepage render path, which
137
+ * was an **unbounded full-collection scan** whose entire output was a count
138
+ * and an average: two numbers, billed one read per document, on every cold
139
+ * render. It was the single largest Firestore read on the site and it existed
140
+ * to print `"35+"` and `"4.6★"`.
141
+ *
142
+ * A Firestore aggregation is billed at **one read per 1000 index entries
143
+ * matched**, so this is ~1 read at any catalogue size this project will see,
144
+ * and it is EXACT rather than a daily-stale rollup — which is why no scheduled
145
+ * job was added for it (the three sibling presets in `live-stats.ts` already
146
+ * use `count()` for the same reason).
147
+ *
148
+ * Returns `average: null` when there are no approved reviews, so the caller
149
+ * renders nothing rather than `0.0★` — an empty catalogue is not a bad rating.
150
+ */
151
+ async getApprovedSiteRatingSummary() {
152
+ const base = this.getCollection().where(REVIEW_FIELDS.STATUS, "==", "approved");
153
+ const buckets = [];
154
+ for (let r = REVIEW_MIN_RATING; r <= REVIEW_MAX_RATING; r++)
155
+ buckets.push(r);
156
+ const counts = await Promise.all(buckets.map((rating) => getFirestoreCount(base.where(REVIEW_FIELDS.RATING, "==", rating))));
157
+ let count = 0;
158
+ let weighted = 0;
159
+ buckets.forEach((rating, i) => {
160
+ const n = counts[i] ?? 0;
161
+ count += n;
162
+ weighted += n * rating;
163
+ });
164
+ // null, not 0 — an empty catalogue has no rating, and rendering "0.0★" would
165
+ // state the opposite of the truth.
166
+ return { count, average: count > 0 ? weighted / count : null };
167
+ }
133
168
  async findFeatured(limit = 18) {
134
169
  const snapshot = await this.db
135
170
  .collection(this.collection)
@@ -26,6 +26,7 @@ import { couponsRepository } from "../../promotions/repository/coupons.repositor
26
26
  import { generateStoreSlug } from "../../stores/schemas/firestore";
27
27
  import { siteSettingsRepository } from "../../admin/repository/site-settings.repository";
28
28
  import { computePayoutDeduction } from "../../../_internal/shared/fees/calculator";
29
+ import { ordersTaxableValue } from "../../../_internal/shared/checkout/order-math";
29
30
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
30
31
  import { finalizeStagedMediaUrl, finalizeStagedMediaField, finalizeStagedMediaArray, } from "../../media/finalize";
31
32
  import { StoreStatusValues } from "../../stores/schemas/firestore";
@@ -222,7 +223,18 @@ async function computeSellerEarnings(sellerId) {
222
223
  }
223
224
  const paidOutIds = await payoutRepository.getPaidOutOrderIds(sellerId);
224
225
  const eligibleOrders = deliveredOrders.filter((o) => !paidOutIds.has(o.id));
225
- const grossAmount = eligibleOrders.reduce((sum, o) => sum + (o.totalPrice ?? 0), 0);
226
+ /*
227
+ * The TAXABLE VALUE — see `orderTaxableValue`. This figure is what the
228
+ * seller sees as their earnings, so it must match what the two payout JOBS
229
+ * compute to the rupee; a seller shown one number and paid another is worse
230
+ * than either number being wrong on its own.
231
+ *
232
+ * Prices are tax-inclusive, so a seller listing at ₹999 @5% earns from a
233
+ * ₹951.43 taxable value — the ₹47.57 is GST we remit. A seller who would
234
+ * rather net ₹999 sets `pricesIncludeGst: false` and their typed price is
235
+ * grossed up at write time.
236
+ */
237
+ const grossAmount = ordersTaxableValue(eligibleOrders);
226
238
  const siteSettings = await siteSettingsRepository.getSingleton();
227
239
  const deduction = computePayoutDeduction(grossAmount, siteSettings.commissions);
228
240
  return {
@@ -319,7 +331,10 @@ export async function bulkSellerOrder(userId, userRole, userDisplayName, userEma
319
331
  }
320
332
  if (eligible.length === 0)
321
333
  throw new ValidationError("No eligible orders found.");
322
- const grossAmount = eligible.reduce((sum, o) => sum + (o.totalPrice ?? 0), 0);
334
+ /* The TAXABLE VALUE — see `orderTaxableValue`. Fourth and last of the four
335
+ * paths C0 converged; a half-migration means the manual button and the
336
+ * scheduled job pay differently for the same orders. */
337
+ const grossAmount = ordersTaxableValue(eligible);
323
338
  const bulkSiteSettings = await siteSettingsRepository.getSingleton();
324
339
  const bulkDeduction = computePayoutDeduction(grossAmount, bulkSiteSettings.commissions);
325
340
  const payoutDoc = await payoutRepository.create({