@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
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { Stack, Row, Text, Heading, Badge, Section, Container, Div, Anchor, } from "../../../../ui";
5
5
  import { MediaImage } from "../../../../features/media/MediaImage";
6
+ import { useOptionalSession } from "../../../../react/contexts/SessionContext";
6
7
  import { ROUTES } from "../../../../next/routing/route-map";
7
8
  import { LotterySlotGrid } from "./LotterySlotGrid";
8
9
  import { LotteryPullForm } from "./LotteryPullForm";
@@ -13,10 +14,30 @@ import { slotsToCollageItems } from "./slot-collage-items";
13
14
  * Shows cover + description + slot grid + pull form (if active + logged in).
14
15
  */
15
16
  export function LotteryDetailView({ event, user, currentEntry }) {
17
+ /*
18
+ * 🛑 Viewer identity is read on the CLIENT, not taken from the `user` prop.
19
+ *
20
+ * The page used to `await getServerSessionUser()` to supply that prop, which
21
+ * cost twice over: `cookies()` forced dynamic rendering, so the route's
22
+ * `revalidate = 30` was silently inert (Root Cause #94) and every visit paid a
23
+ * full render; and the HTML then varied per viewer, so it could never be
24
+ * cached or shared even once the TTL was fixed.
25
+ *
26
+ * `useOptionalSession` — not `useSession` — because library code that adjusts
27
+ * for the viewer must not require a provider to be mounted (Root Cause #20);
28
+ * absent, it fails CLOSED to the signed-out branch.
29
+ *
30
+ * The `user` prop is kept as an optional SEED so a caller that genuinely has
31
+ * the viewer (an authenticated dashboard mount) still renders correctly on
32
+ * first paint.
33
+ */
34
+ const session = useOptionalSession();
35
+ const isAuthResolving = session?.loading ?? false;
36
+ const viewer = session?.user ?? user ?? null;
16
37
  const [pulledEntry, setPulledEntry] = useState(currentEntry?.assignedPrizeSlotNumber != null ? currentEntry : null);
17
38
  const config = event.lotteryConfig;
18
39
  const prizeItems = slotsToCollageItems(config?.slots ?? []);
19
40
  const isActive = event.status === "active";
20
41
  const isEnded = event.status === "ended" || event.status === "cancelled";
21
- return (_jsx(Container, { children: _jsx(Section, { children: _jsxs(Stack, { gap: "xl", children: [event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { rounded: "2xl", className: "relative overflow-hidden", style: { aspectRatio: "16/9", maxHeight: "16rem" }, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: event.title, size: "hero" }) })) : (_jsx(Div, { rounded: "2xl", surface: "subtle", padding: "xl", align: "center", justify: "center", children: _jsx(Text, { size: "5xl", children: "\uD83C\uDFB0" }) })), _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, children: [_jsx(Badge, { variant: isActive ? "success" : isEnded ? "danger" : "warning", children: event.status }), config && (_jsxs(Badge, { variant: "info", children: [config.slots.filter((s) => s.isBooked).length, "/", config.totalSlots, " slots claimed"] }))] }), _jsx(Heading, { level: 1, size: "3xl", weight: "bold", children: event.title }), event.description ? (_jsx(Text, { color: "muted", size: "sm", children: event.description.replace(/<[^>]+>/g, "").slice(0, 200) })) : null, event.endsAt ? (_jsxs(Text, { size: "sm", color: "muted", children: ["Ends: ", new Date(event.endsAt).toLocaleDateString("en-IN", { dateStyle: "long" })] })) : null] }), prizeItems.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Prizes" }), _jsx(PrizeDrawCollage, { items: prizeItems, wonLabel: "Claimed" })] })) : null, config && config.slots.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Slots" }), _jsx(LotterySlotGrid, { slots: config.slots, totalSlots: config.totalSlots })] })) : null, isActive && config && (_jsx(Div, { padding: "md", rounded: "2xl", surface: "card", className: "border border-[var(--appkit-color-border)]", children: !user ? (_jsxs(Stack, { gap: "md", className: "text-center", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Want to Participate?" }), _jsx(Text, { color: "muted", children: "Log in to submit your lottery entry." }), _jsx(Anchor, { href: String(ROUTES.AUTH.LOGIN), tone: "brand", children: "Log In to Enter" })] })) : pulledEntry ? (_jsxs(Stack, { gap: "sm", surface: "success-surface", padding: "md", rounded: "xl", className: "border border-success/20", children: [_jsxs(Heading, { level: 2, size: "lg", weight: "bold", className: "text-success", children: ["You're Lottery #", pulledEntry.userLotteryNumber, "!"] }), _jsxs(Text, { className: "text-success", size: "sm", children: ["Assigned to Slot #", pulledEntry.assignedPrizeSlotNumber, pulledEntry.slotName ? ` — ${pulledEntry.slotName}` : "", "."] })] })) : (_jsx(LotteryPullForm, { sourceType: "event", eventId: event.id, totalSlots: config.totalSlots, maxPullsPerUser: config.maxPullsPerUser, onSuccess: (res) => setPulledEntry(res) })) })), isEnded && !pulledEntry && (_jsx(Stack, { gap: "sm", surface: "muted", padding: "md", rounded: "xl", children: _jsx(Text, { color: "muted", weight: "semibold", children: "This lottery has ended." }) }))] }) }) }));
42
+ return (_jsx(Container, { children: _jsx(Section, { children: _jsxs(Stack, { gap: "xl", children: [event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { rounded: "2xl", className: "relative overflow-hidden", style: { aspectRatio: "16/9", maxHeight: "16rem" }, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: event.title, size: "hero" }) })) : (_jsx(Div, { rounded: "2xl", surface: "subtle", padding: "xl", align: "center", justify: "center", children: _jsx(Text, { size: "5xl", children: "\uD83C\uDFB0" }) })), _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, children: [_jsx(Badge, { variant: isActive ? "success" : isEnded ? "danger" : "warning", children: event.status }), config && (_jsxs(Badge, { variant: "info", children: [config.slots.filter((s) => s.isBooked).length, "/", config.totalSlots, " slots claimed"] }))] }), _jsx(Heading, { level: 1, size: "3xl", weight: "bold", children: event.title }), event.description ? (_jsx(Text, { color: "muted", size: "sm", children: event.description.replace(/<[^>]+>/g, "").slice(0, 200) })) : null, event.endsAt ? (_jsxs(Text, { size: "sm", color: "muted", children: ["Ends: ", new Date(event.endsAt).toLocaleDateString("en-IN", { dateStyle: "long" })] })) : null] }), prizeItems.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Prizes" }), _jsx(PrizeDrawCollage, { items: prizeItems, wonLabel: "Claimed" })] })) : null, config && config.slots.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Slots" }), _jsx(LotterySlotGrid, { slots: config.slots, totalSlots: config.totalSlots })] })) : null, isActive && config && (_jsx(Div, { padding: "md", rounded: "2xl", surface: "card", className: "border border-[var(--appkit-color-border)]", children: isAuthResolving ? (_jsx(Stack, { gap: "md", className: "text-center", children: _jsx(Text, { color: "muted", children: "Checking your session\u2026" }) })) : !viewer ? (_jsxs(Stack, { gap: "md", className: "text-center", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Want to Participate?" }), _jsx(Text, { color: "muted", children: "Log in to submit your lottery entry." }), _jsx(Anchor, { href: String(ROUTES.AUTH.LOGIN), tone: "brand", children: "Log In to Enter" })] })) : pulledEntry ? (_jsxs(Stack, { gap: "sm", surface: "success-surface", padding: "md", rounded: "xl", className: "border border-success/20", children: [_jsxs(Heading, { level: 2, size: "lg", weight: "bold", className: "text-success", children: ["You're Lottery #", pulledEntry.userLotteryNumber, "!"] }), _jsxs(Text, { className: "text-success", size: "sm", children: ["Assigned to Slot #", pulledEntry.assignedPrizeSlotNumber, pulledEntry.slotName ? ` — ${pulledEntry.slotName}` : "", "."] })] })) : (_jsx(LotteryPullForm, { sourceType: "event", eventId: event.id, totalSlots: config.totalSlots, maxPullsPerUser: config.maxPullsPerUser, onSuccess: (res) => setPulledEntry(res) })) })), isEnded && !pulledEntry && (_jsx(Stack, { gap: "sm", surface: "muted", padding: "md", rounded: "xl", children: _jsx(Text, { color: "muted", weight: "semibold", children: "This lottery has ended." }) }))] }) }) }));
22
43
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Category adapters — the single allow-list projection for every public
3
+ * category response.
4
+ *
5
+ * Every field of `CategoryDocument` is triaged into exactly one of
6
+ * `PUBLIC_CATEGORY_FIELDS` (mapped into the returned literal) or
7
+ * `PRIVATE_CATEGORY_FIELDS` (with a stated reason), mirroring the store and
8
+ * site-settings adapters. A field added to the schema and to neither list
9
+ * fails `scripts/audit-public-projection-parity.mjs`, so new fields are
10
+ * private by default.
11
+ *
12
+ * Why this exists (2026-10-10): `GET /api/categories` returned `result.data`
13
+ * raw — the whole document — on an unauthenticated, hour-edge-cached route.
14
+ * That published `createdBy` (a uid), `createdByStoreId`, `createdByStoreName`
15
+ * and, worst, `metrics.productIds[]` / `metrics.auctionIds[]`: two UNBOUNDED
16
+ * arrays that grow with the catalogue. `CategoryInlineSelect` calls that route
17
+ * with `pageSize=200` on every seller-form open, so the payload scaled with
18
+ * both the number of categories and the number of products in each.
19
+ *
20
+ * 🛑 There is an older `CATEGORIES_PUBLIC_FIELDS` in
21
+ * `features/categories/schemas/firestore.ts`. It has ZERO runtime consumers,
22
+ * predates `categoryType` / `highlights` / `faqs` / `itemCode` / the brand and
23
+ * bundle fields, and is a `.select()`-style dot-path list rather than a
24
+ * projection. It is deliberately not reused here; this module is the one the
25
+ * route actually calls.
26
+ */
27
+ import type { CategoryDocument } from "../../../../features/categories/schemas/firestore";
28
+ import type { CategoryItem } from "../../../../features/categories/types/index";
29
+ /**
30
+ * Document fields that may reach an unauthenticated client. Keep in sync with
31
+ * the object literal in `toCategoryListItem` — the audit asserts both
32
+ * directions.
33
+ */
34
+ export declare const PUBLIC_CATEGORY_FIELDS: readonly ["id", "name", "slug", "description", "rootId", "parentIds", "childrenIds", "tier", "path", "order", "isLeaf", "ancestors", "categoryType", "itemCode", "isFeatured", "featuredPriority", "seo", "display", "isActive", "highlights", "faqs", "brandWebsite", "brandCountry", "brandFounded", "metrics", "createdAt", "updatedAt"];
35
+ /**
36
+ * Fields withheld, each with the reason. Never remove one without replacing
37
+ * the reason — "it seemed unused" is how a field ends up public by accident.
38
+ */
39
+ export declare const PRIVATE_CATEGORY_FIELDS: readonly ["position", "subtreeSize", "createdBy", "createdByType", "createdByStoreId", "createdByStoreName", "viewCount", "isBrand", "showOnHomepage", "isSearchable", "searchTxt", "aliases", "brandSlug", "bundleKind", "bundlePrice", "bundleStockStatus", "bundleProductIds", "bundleCategorySlugs", "bundleOriginalTotal", "bundleItemDetails", "contentBody", "descriptionTemplates", "productDefaults", "isTestData", "testDataExpiresAt", "bundleQueryRule", "bundleQueryResolvedAt"];
40
+ /**
41
+ * Project a stored category into the public shape.
42
+ *
43
+ * Built by NAMING each field, never by spreading and deleting — a deny-list
44
+ * publishes every field nobody thought to remove, including ones the
45
+ * interface does not declare (Root Cause #70).
46
+ */
47
+ export declare function toCategoryListItem(doc: CategoryDocument): CategoryItem;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Document fields that may reach an unauthenticated client. Keep in sync with
3
+ * the object literal in `toCategoryListItem` — the audit asserts both
4
+ * directions.
5
+ */
6
+ export const PUBLIC_CATEGORY_FIELDS = [
7
+ "id",
8
+ "name",
9
+ "slug",
10
+ "description",
11
+ // Structure. The client builds its own tree and breadcrumbs from these.
12
+ "rootId",
13
+ "parentIds",
14
+ "childrenIds",
15
+ "tier",
16
+ "path",
17
+ "order",
18
+ "isLeaf",
19
+ "ancestors",
20
+ // Discriminator — public because every consumer must tell a listing
21
+ // category apart from a brand/bundle/sublisting row, and a plain category
22
+ // OMITS the field, so the test can only be done client-side.
23
+ "categoryType",
24
+ "itemCode",
25
+ // Presentation.
26
+ "isFeatured",
27
+ "featuredPriority",
28
+ "seo",
29
+ "display",
30
+ // Read by `AdminCategoriesView`'s Status column off this very route.
31
+ "isActive",
32
+ // Editorial copy — already rendered publicly by
33
+ // `CategoryHighlightsAndFaqSection` on the category and brand pages.
34
+ "highlights",
35
+ "faqs",
36
+ // Brand-row fields, rendered in the "About this brand" panel.
37
+ "brandWebsite",
38
+ "brandCountry",
39
+ "brandFounded",
40
+ // Rollup counts only — see `metrics` in the private list.
41
+ "metrics",
42
+ "createdAt",
43
+ "updatedAt",
44
+ ];
45
+ /**
46
+ * Fields withheld, each with the reason. Never remove one without replacing
47
+ * the reason — "it seemed unused" is how a field ends up public by accident.
48
+ */
49
+ export const PRIVATE_CATEGORY_FIELDS = [
50
+ // Internal DFS coordinates. Useful only to `onCategoryWrite` and
51
+ // `positionsReconcile`; publishing them invites a client to depend on an
52
+ // ordering we renumber on every reseed.
53
+ "position",
54
+ "subtreeSize",
55
+ // Operator identity. `createdBy` is a raw uid.
56
+ "createdBy",
57
+ "createdByType",
58
+ "createdByStoreId",
59
+ "createdByStoreName",
60
+ // Analytics, not content.
61
+ "viewCount",
62
+ // @deprecated duplicate of `categoryType === "brand"`.
63
+ "isBrand",
64
+ /*
65
+ * The next four are withheld NOT because they are sensitive but because
66
+ * no client reads them off THIS route — verified by grep, per consumer:
67
+ *
68
+ * showOnHomepage — homepage curation runs server-side; `CategoryItem`
69
+ * declares no such field and nothing reads one.
70
+ * isSearchable — a query-time flag. No client branches on it.
71
+ * brandSlug — a bundle's own brand tag. Its two readers are
72
+ * `AdminBundleEditorView` (via ADMIN_ENDPOINTS.BUNDLES,
73
+ * a different route) and `BrandDetailPageView` (which
74
+ * holds the raw `CategoryDocument` server-side).
75
+ * bundle* — same story: every bundle surface is fed by
76
+ * ADMIN_ENDPOINTS.BUNDLES or by a server-side read.
77
+ *
78
+ * 🛑 If a bundle card is ever fed from `/api/categories`, promote the
79
+ * bundle fields HERE and add them to `CategoryItem` — do not reach for a
80
+ * cast at the call site, which is Root Cause #70's exact mechanism.
81
+ */
82
+ "showOnHomepage",
83
+ "isSearchable",
84
+ /*
85
+ * The prefix-expanded search index. Withheld for two reasons, not one:
86
+ *
87
+ * - No client reads it. Searching is a SERVER concern — the term is pushed
88
+ * down as an `array-contains` on `CATEGORY_FIELDS.SEARCH_TXT`; a browser
89
+ * has no use for the token array.
90
+ * - It is 53–160 tokens per row (measured, cap 600). Publishing it would
91
+ * roughly double this payload on a route `CategoryInlineSelect` hits
92
+ * with `pageSize=200` on every seller-form open — the exact cost
93
+ * Root Cause #104 was about.
94
+ */
95
+ "searchTxt",
96
+ /*
97
+ * `aliases` — B5. The alternate names a leaf is searched by ("Lost
98
+ * Longinus" for the row stored as "Luinor L2", plus its product code).
99
+ *
100
+ * Withheld from THIS route because nothing reads it here: the aliases exist
101
+ * to be folded into `searchTxt` at write time, and the matching happens
102
+ * server-side. `CategoryItem` declares no such field, and the only consumer
103
+ * of this route at scale is `CategoryInlineSelect` at `pageSize=200`, which
104
+ * shows a name and nothing else.
105
+ *
106
+ * 🛑 Not secret, and the distinction matters for the next person: every
107
+ * alias is ALREADY inferable from search results, because it is tokenised
108
+ * into the index this route helps query. If a detail page ever wants to
109
+ * render an "also known as" line — which would be genuinely useful on a
110
+ * named-model leaf — promote it here AND add it to `CategoryItem`, rather
111
+ * than casting at the call site (Root Cause #70's exact mechanism).
112
+ */
113
+ "aliases",
114
+ "brandSlug",
115
+ "bundleKind",
116
+ "bundlePrice",
117
+ "bundleStockStatus",
118
+ "bundleProductIds",
119
+ "bundleCategorySlugs",
120
+ "bundleOriginalTotal",
121
+ "bundleItemDetails",
122
+ /*
123
+ * ── Category-owned content (B3) ──────────────────────────────────────────
124
+ *
125
+ * All three are PRIVATE on this route and that is a payload decision, not a
126
+ * secrecy one — they are genuinely public content, just not from here.
127
+ *
128
+ * This is the LIST projection, and `CategoryInlineSelect` calls it with
129
+ * `pageSize=200` on every seller-form open. `contentBody` alone is 400–900
130
+ * chars of HTML per row, so publishing it here would add ~180 KB to a
131
+ * response whose only job is to fill a picker — the Fast-Origin-Transfer
132
+ * shape of Root Cause #104, on the resource this project is most over on.
133
+ *
134
+ * Each has exactly one real reader and neither is this route:
135
+ *
136
+ * contentBody — the category DETAIL page, which holds the raw
137
+ * CategoryDocument server-side (getCategoryBySlug).
138
+ * descriptionTemplates — the seller form, via the narrow
139
+ * /api/categories/[id]/description-template
140
+ * endpoint (C2), one category at a time, edge-cached
141
+ * for an hour.
142
+ * productDefaults — `deriveTaxonomy`, server-side, on the product
143
+ * write path; plus the same narrow endpoint for the
144
+ * seller-facing price hint.
145
+ *
146
+ * 🛑 If a surface ever needs one of these from the list route, promote it
147
+ * here AND add it to `CategoryItem` AND to the literal in
148
+ * `toCategoryListItem` — do not cast at the call site, which is Root Cause
149
+ * #70's exact mechanism.
150
+ */
151
+ "contentBody",
152
+ "descriptionTemplates",
153
+ "productDefaults",
154
+ // Test-data plumbing. `hidePublicTestData()` filters on these server-side;
155
+ // a client has no business branching on them.
156
+ "isTestData",
157
+ "testDataExpiresAt",
158
+ // The rule that resolves a dynamic bundle's members. Publishing it hands
159
+ // out the query, and the resolved `bundleProductIds` is already public.
160
+ "bundleQueryRule",
161
+ "bundleQueryResolvedAt",
162
+ ];
163
+ /**
164
+ * Rollup counters only.
165
+ *
166
+ * 🛑 `metrics.productIds[]` and `metrics.auctionIds[]` are deliberately
167
+ * dropped. They are unbounded arrays — one entry per product in the category
168
+ * — and they were the bulk of the raw payload. Nothing client-side reads them
169
+ * that a query cannot answer, and a category page already fetches its own
170
+ * products.
171
+ */
172
+ function toPublicMetrics(m) {
173
+ return {
174
+ productCount: m?.productCount ?? 0,
175
+ auctionCount: m?.auctionCount ?? 0,
176
+ totalProductCount: m?.totalProductCount ?? 0,
177
+ totalAuctionCount: m?.totalAuctionCount ?? 0,
178
+ totalItemCount: m?.totalItemCount ?? 0,
179
+ };
180
+ }
181
+ /**
182
+ * Project a stored category into the public shape.
183
+ *
184
+ * Built by NAMING each field, never by spreading and deleting — a deny-list
185
+ * publishes every field nobody thought to remove, including ones the
186
+ * interface does not declare (Root Cause #70).
187
+ */
188
+ export function toCategoryListItem(doc) {
189
+ /*
190
+ * No `as CategoryItem` on the literal below, on purpose. The cast is what
191
+ * lets a shape drift silently; without it tsc checks every key, and it
192
+ * already caught `createdAt` being a Date where the contract wants a
193
+ * string.
194
+ */
195
+ return {
196
+ id: doc.id,
197
+ name: doc.name,
198
+ slug: doc.slug,
199
+ description: doc.description,
200
+ rootId: doc.rootId,
201
+ parentIds: doc.parentIds,
202
+ childrenIds: doc.childrenIds,
203
+ tier: doc.tier,
204
+ path: doc.path,
205
+ order: doc.order,
206
+ isLeaf: doc.isLeaf,
207
+ ancestors: doc.ancestors,
208
+ categoryType: doc.categoryType,
209
+ itemCode: doc.itemCode,
210
+ isFeatured: doc.isFeatured,
211
+ featuredPriority: doc.featuredPriority,
212
+ seo: doc.seo,
213
+ display: doc.display,
214
+ isActive: doc.isActive,
215
+ highlights: doc.highlights,
216
+ faqs: doc.faqs,
217
+ brandWebsite: doc.brandWebsite,
218
+ brandCountry: doc.brandCountry,
219
+ brandFounded: doc.brandFounded,
220
+ metrics: toPublicMetrics(doc.metrics),
221
+ // 🛑 `CategoryDocument` stores Dates; `CategoryItem` declares strings —
222
+ // this projection is also the serialisation boundary. Converting here
223
+ // rather than casting is deliberate: `as unknown as CategoryItem` has no
224
+ // runtime effect and would have shipped raw `Timestamp`/`Date` objects
225
+ // into an RSC payload, which is Root Cause #70's exact mechanism.
226
+ createdAt: toIsoString(doc.createdAt),
227
+ updatedAt: toIsoString(doc.updatedAt),
228
+ };
229
+ }
230
+ /** Firestore hands back `Date`; the client contract is an ISO string. */
231
+ function toIsoString(value) {
232
+ if (!value)
233
+ return undefined;
234
+ return value instanceof Date ? value.toISOString() : String(value);
235
+ }
@@ -13,6 +13,7 @@ import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../
13
13
  import { ORDER_FIELDS } from "../../../../constants/field-names";
14
14
  import { serverLogger } from "../../../../monitoring";
15
15
  import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
16
+ import { isIntraStateSupply } from "../../../shared/fees/calculator";
16
17
  import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math";
17
18
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
18
19
  import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
@@ -411,16 +412,37 @@ async function createOrderForGroup(group, orderType, ctx) {
411
412
  // unknown (digital-only carts have no shipping address).
412
413
  let gstBreakdown;
413
414
  if (gstSettings?.enabled && buyerState) {
414
- const intraState = !!storeState && storeState === buyerState;
415
+ /*
416
+ * 🛑 `isIntraStateSupply`, not `storeState === buyerState`. That raw
417
+ * comparison read "Karnataka" / "karnataka" / "KA" as three different
418
+ * states — the exact drift `constants/geo/subdivisions.ts` opens by
419
+ * documenting — and a store with no address resolved to `false`, i.e.
420
+ * silently inter-state. `null` means "cannot tell" and is logged rather
421
+ * than guessed; the AMOUNT is identical either way (IGST total ==
422
+ * CGST+SGST total), so what was wrong was the split on the invoice.
423
+ */
424
+ const intra = isIntraStateSupply(storeState, buyerState);
425
+ if (intra === null) {
426
+ serverLogger.warn("[gst] place of supply undetermined — defaulting to inter-state (IGST)", { storeId: firstItem.storeId, hasStoreState: !!storeState });
427
+ }
415
428
  // Per-member, not per-line: a bundle or grouped line spans several products
416
429
  // with several different rates, and taxing the whole line at one product's
417
430
  // rate is wrong in both directions. Shared with previewCheckoutPricing so
418
431
  // the figure shown and the figure charged cannot drift.
419
- const { taxableAmount, gstAmount } = sumGroupGst(group.map(({ item }) => item), ctx.productById, intraState);
420
- if (taxableAmount > 0) {
421
- gstBreakdown = intraState
422
- ? { taxableAmount, cgst: Math.round(gstAmount / 2), sgst: gstAmount - Math.round(gstAmount / 2), igst: 0, gstAmount }
423
- : { taxableAmount, cgst: 0, sgst: 0, igst: gstAmount, gstAmount };
432
+ const summary = sumGroupGst(group.map(({ item }) => item), ctx.productById, intra ?? false);
433
+ /*
434
+ * The split is taken AS COMPUTED, per slice, by `calculateGst` inside
435
+ * `sumGroupGst`. It used to be re-derived here as
436
+ * `Math.round(gstAmount / 2)` — whole rupees, so ₹161.82 became
437
+ * 81.00/80.82 rather than 80.91/80.91, and a ₹0.52 total rounded to 0 and
438
+ * was then dropped off the invoice entirely by the `|| undefined` below.
439
+ *
440
+ * The guard is on EITHER base: an order of purely exempt goods has a real
441
+ * taxable value to report and zero tax, and skipping it left the invoice
442
+ * unable to reconcile.
443
+ */
444
+ if (summary.taxableAmount > 0 || summary.exemptAmount > 0) {
445
+ gstBreakdown = summary;
424
446
  }
425
447
  }
426
448
  const isCodLike = paymentMethod === "cod" || paymentMethod === "upi_manual" || paymentMethod === "cash";
@@ -458,7 +480,19 @@ async function createOrderForGroup(group, orderType, ctx) {
458
480
  // The platform commission is now charged on EVERY payment method. It used to
459
481
  // be added only on the online-payment path, so COD / UPI-manual / cash / EMI
460
482
  // buyers silently never paid it.
461
- const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0) + (gstBreakdown?.gstAmount ?? 0);
483
+ const orderTotal =
484
+ /*
485
+ * 🛑 Product GST is NOT a term here, and its absence is the model.
486
+ *
487
+ * Prices are tax-INCLUSIVE: the GST is already inside `groupTotal`, backed
488
+ * out by `sumGroupGst` for reporting rather than added on top. A ₹999
489
+ * listing at 5% charges ₹999, of which ₹47.57 is tax we remit.
490
+ *
491
+ * `platformFeeGst` IS a term, and the asymmetry is correct: that is GST on
492
+ * OUR service fee to the seller, charged to the buyer on top of the fee —
493
+ * not tax on the goods.
494
+ */
495
+ Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0);
462
496
  const imageUrls = [
463
497
  ...new Set(group
464
498
  .map(({ product }) => product.mainImage)
@@ -535,6 +569,20 @@ async function createOrderForGroup(group, orderType, ctx) {
535
569
  shipmentProtectionAddon: !adminBypass && shipmentProtectionFee > 0 ? true : undefined,
536
570
  shipmentProtectionFee: !adminBypass && shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
537
571
  taxableAmount: gstBreakdown?.taxableAmount,
572
+ /* Exempt goods are a reportable nil-rated supply, kept OUT of
573
+ * `taxableAmount` so the taxed base stays the base the tax was charged on,
574
+ * and recorded here so the invoice's columns can reconcile. */
575
+ exemptAmount: gstBreakdown?.exemptAmount || undefined,
576
+ /* Our registration AS IT STANDS NOW. A tax invoice without the supplier's
577
+ * GSTIN is not a tax invoice, and the invoice page cannot read it from
578
+ * site settings — `gst` is in PRIVATE_SITE_FIELDS. See the schema. */
579
+ supplierGstin: gstSettings?.enabled ? gstSettings.gstin || undefined : undefined,
580
+ supplierLegalName: gstSettings?.enabled ? gstSettings.legalName || undefined : undefined,
581
+ supplierAddress: gstSettings?.enabled ? gstSettings.address || undefined : undefined,
582
+ /* Per-rate, for Rule 46. Computed by `sumGroupGst` rather than re-derived
583
+ * downstream — a mixed 5%+18% order cannot produce a compliant invoice
584
+ * from the aggregate triple alone. */
585
+ gstByRate: gstBreakdown?.byRate.length ? gstBreakdown.byRate : undefined,
538
586
  /*
539
587
  * 🛑 This must include the GST on the PLATFORM FEE, not just product GST.
540
588
  *
@@ -985,11 +1033,18 @@ export async function previewCheckoutPricing(input) {
985
1033
  const groupShipmentProtectionFee = computeShipmentProtectionFee(groupTotal, addons.shipmentProtectionAddon ?? false, commissions);
986
1034
  let groupGstAmount = 0;
987
1035
  if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
988
- const intraState = !!storeState && storeState === resolvedAddress.state;
1036
+ /*
1037
+ * `isIntraStateSupply`, matching `createOrderForGroup` exactly — this
1038
+ * was the same raw `===` and had to change with it, or the figure SHOWN
1039
+ * and the figure CHARGED would split on different state comparisons.
1040
+ * The total is unaffected by the answer (IGST total == CGST+SGST total),
1041
+ * but using one rule in both places is what keeps that true.
1042
+ */
1043
+ const intra = isIntraStateSupply(storeState, resolvedAddress.state);
989
1044
  // Same helper createOrderForGroup uses — the two disagreeing is the
990
1045
  // recurring defect here (Root Cause #59), and it is the buyer seeing one
991
1046
  // tax figure and being charged another.
992
- groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById, intraState).gstAmount;
1047
+ groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById, intra ?? false).gstAmount;
993
1048
  }
994
1049
  const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
995
1050
  const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
@@ -1002,6 +1057,15 @@ export async function previewCheckoutPricing(input) {
1002
1057
  whatsappNotifyFee: groupWhatsappFee,
1003
1058
  giftWrapFee: groupGiftWrapFee,
1004
1059
  shipmentProtectionFee: groupShipmentProtectionFee,
1060
+ /*
1061
+ * INFORMATIONAL — the tax already INSIDE these prices, so checkout can
1062
+ * render "incl. GST ₹47.57". 🛑 Deliberately NOT added to `total` below:
1063
+ * it was, under the old add-on-top math, and removing it from all five
1064
+ * money sites together is what the tax-inclusive model consists of. A
1065
+ * preview that still added it would show a total ₹47.57 higher than the
1066
+ * order it previews — the shown-vs-charged drift this field exists to
1067
+ * prevent, reintroduced from the other direction.
1068
+ */
1005
1069
  gstAmount: groupGstAmount,
1006
1070
  couponDiscount: groupCouponDiscount,
1007
1071
  total: Math.max(0, groupTotal - groupCouponDiscount) +
@@ -1009,8 +1073,7 @@ export async function previewCheckoutPricing(input) {
1009
1073
  groupCodHandlingFee +
1010
1074
  groupWhatsappFee +
1011
1075
  groupGiftWrapFee +
1012
- groupShipmentProtectionFee +
1013
- groupGstAmount,
1076
+ groupShipmentProtectionFee,
1014
1077
  });
1015
1078
  }
1016
1079
  const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
@@ -1184,13 +1247,13 @@ async function refundDroppedItemsForPhonePeCheckout(input) {
1184
1247
  * from the inline version this replaced.
1185
1248
  */
1186
1249
  async function createPhonePeGroupOrder(group, orderType, ctx) {
1187
- const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, merchantOrderId, phonepeOrderId, transactionId, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: phonepeProductById, } = ctx;
1250
+ const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, merchantOrderId, phonepeOrderId, transactionId, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: phonepeProductById, buyerState, } = ctx;
1188
1251
  const firstItem = group[0].item;
1189
1252
  const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
1190
1253
  // Reuses the same store/seller lookup as the COD/UPI path above instead
1191
1254
  // of re-implementing it inline — was two sequential findById calls per
1192
1255
  // seller group here, duplicated from resolveShippingCost.
1193
- const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
1256
+ const { shippingFee, storeOwnerId, storeState } = await resolveShippingCost(firstItem.storeId);
1194
1257
  const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
1195
1258
  const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
1196
1259
  // Mirrors createOrderForGroup — without this the online-payment path wrote
@@ -1221,16 +1284,39 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1221
1284
  const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
1222
1285
  const giftWrapMessage = addons.giftWrapMessage;
1223
1286
  const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, addons.shipmentProtectionAddon ?? false, commissionRates);
1287
+ /*
1288
+ * P-8 GST — now wired through this path. C0c defect 1.
1289
+ *
1290
+ * The comment that used to sit here deferred it, correctly identifying the
1291
+ * blocker: `expectedPaymentAmountRs` compares against what PhonePe already
1292
+ * captured, so adding product GST here WITHOUT adding it to the upstream
1293
+ * pre-payment amount would either fail the mismatch check or silently
1294
+ * under-charge. Both halves move in this change, plus the create-order route
1295
+ * that decides the captured amount.
1296
+ *
1297
+ * 🛑 What unblocked it: the GST TOTAL does not depend on the place of
1298
+ * supply. IGST total equals CGST+SGST total, so `/api/payment/create-order`
1299
+ * can compute the amount before the delivery address is even known — which
1300
+ * is the thing it genuinely cannot see. Measured across ~285k rate/base
1301
+ * combinations, the two differ by at most ₹0.01 (from `round(half × 2)` in
1302
+ * the intra branch), and the guard's tolerance is ₹1.
1303
+ *
1304
+ * Only the SPLIT needs the address, and this function has it.
1305
+ */
1306
+ let phonepeGst;
1307
+ if (siteSettings?.gst?.enabled && buyerState) {
1308
+ const intra = isIntraStateSupply(storeState, buyerState);
1309
+ if (intra === null) {
1310
+ serverLogger.warn("[gst] place of supply undetermined on the online path — defaulting to inter-state (IGST)", { storeId: firstItem.storeId, hasStoreState: !!storeState });
1311
+ }
1312
+ const summary = sumGroupGst(group.map(({ item }) => item), phonepeProductById, intra ?? false);
1313
+ if (summary.taxableAmount > 0 || summary.exemptAmount > 0)
1314
+ phonepeGst = summary;
1315
+ }
1316
+ /* Product GST is not a term — prices are tax-inclusive, so it is already
1317
+ * inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
1318
+ * stays because that is GST on our fee, charged on top. */
1224
1319
  const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst;
1225
- // P-8 GST — deliberately NOT wired into this PhonePe-verify path. The
1226
- // amount-mismatch check above (expectedPaymentAmountRs) compares against
1227
- // what the buyer already paid via the PhonePe order created earlier in
1228
- // the flow; adding product GST here without also adding it to that
1229
- // upstream pre-payment amount calculation would either fail the mismatch
1230
- // check or silently under/over-charge. Wiring GST through the full
1231
- // PhonePe create→confirm round-trip is separate follow-up work, tracked
1232
- // alongside P-13 (PhonePe is disabled by default today, so this order
1233
- // type doesn't currently carry a GST breakdown).
1234
1320
  // S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
1235
1321
  // Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
1236
1322
  // the price rule (`isBundle ? item.price : product.price`) that — like the
@@ -1311,6 +1397,26 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
1311
1397
  giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
1312
1398
  shipmentProtectionAddon: shipmentProtectionFee > 0 ? true : undefined,
1313
1399
  shipmentProtectionFee: shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
1400
+ /*
1401
+ * The tax breakdown, matching `createOrderForGroup` field for field.
1402
+ * This path recorded NONE of it before C0c defect 1 — so an online order
1403
+ * carried no `taxableAmount`, no `gstAmount` and no split, and the order
1404
+ * page's Tax row could not render even once GST was enabled. Both the
1405
+ * product GST and the platform-fee GST are summed into `gstAmount`, which
1406
+ * is what makes the order's own lines reconcile to its total.
1407
+ */
1408
+ taxableAmount: phonepeGst?.taxableAmount,
1409
+ exemptAmount: phonepeGst?.exemptAmount || undefined,
1410
+ // Our registration at the moment of supply — see the schema. Mirrors the
1411
+ // COD/UPI path so both produce an invoiceable order.
1412
+ supplierGstin: siteSettings?.gst?.enabled ? siteSettings.gst.gstin || undefined : undefined,
1413
+ supplierLegalName: siteSettings?.gst?.enabled ? siteSettings.gst.legalName || undefined : undefined,
1414
+ supplierAddress: siteSettings?.gst?.enabled ? siteSettings.gst.address || undefined : undefined,
1415
+ gstByRate: phonepeGst?.byRate.length ? phonepeGst.byRate : undefined,
1416
+ gstAmount: (phonepeGst?.gstAmount ?? 0) + platformFeeGst || undefined,
1417
+ cgst: phonepeGst?.cgst || undefined,
1418
+ sgst: phonepeGst?.sgst || undefined,
1419
+ igst: phonepeGst?.igst || undefined,
1314
1420
  couponCode: appliedDiscounts[0]?.code,
1315
1421
  couponDiscount: couponDiscount > 0 ? couponDiscount : undefined,
1316
1422
  appliedDiscounts: appliedDiscounts.length > 0 ? appliedDiscounts : undefined,
@@ -1563,6 +1669,35 @@ export async function verifyAndPlacePhonePeOrderAction(input) {
1563
1669
  computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
1564
1670
  computeShipmentProtectionFee(groupSubtotal, addons.shipmentProtectionAddon ?? false, commissionRates));
1565
1671
  }, 0);
1672
+ /*
1673
+ * Product GST, matching what `/api/payment/create-order` charged.
1674
+ *
1675
+ * 🛑 This term's ABSENCE was half of C0c defect 1. The guard agreed with
1676
+ * the create path only because both omitted it, so an undercharge could
1677
+ * never trip the check — the two were consistently wrong together, which
1678
+ * is the worst available state for a guard.
1679
+ *
1680
+ * `intraState: false` is deliberate and is not a guess about the buyer's
1681
+ * address: the TOTAL does not depend on the place of supply (IGST total ==
1682
+ * CGST+SGST total), measured to within ₹0.01 across ~285k rate/base
1683
+ * combinations, and this comparison already carries a ₹1 tolerance.
1684
+ * Only the split needs the address, and the split is not checked here.
1685
+ */
1686
+ /*
1687
+ * 🛑 No product-GST term, and the reason is the tax-inclusive model rather
1688
+ * than the omission this guard was fixed for an hour earlier.
1689
+ *
1690
+ * Defect 1's fix added `expectedProductGst` here, because the three money
1691
+ * sites had to AGREE with the preview and the preview added the tax. The
1692
+ * model then changed: every price carries its tax inside, so the correct
1693
+ * shared value became zero-added — at the capture route, at both order
1694
+ * paths, at the preview, and here. The agreement defect 1 established is
1695
+ * preserved; what changed is the figure all five agree on.
1696
+ *
1697
+ * `cartSubtotalRs` therefore already contains the GST, and adding it again
1698
+ * would make this guard demand ~₹580 more than the buyer owes on a
1699
+ * ₹5,547 cart — rejecting every correctly-charged order.
1700
+ */
1566
1701
  const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee + expectedAddonFees + expectedShippingFee;
1567
1702
  // Reuses the order already fetched above to confirm COMPLETED — PhonePe's
1568
1703
  // own amount is in its wire-format smallest unit. // audit-money-units-ok: describes the PhonePe boundary conversion this line performs, not our storage convention
@@ -1611,6 +1746,10 @@ export async function verifyAndPlacePhonePeOrderAction(input) {
1611
1746
  phonepeOrderId: phonepeOrder.gatewayOrderId,
1612
1747
  transactionId: phonepeOrder.transactionId,
1613
1748
  shippingAddress,
1749
+ // For the CGST/SGST vs IGST split. The TOTAL does not depend on it
1750
+ // (see createPhonePeGroupOrder), which is what let the pre-payment
1751
+ // amount be computed before this address was known.
1752
+ buyerState: resolvedAddressPp?.state,
1614
1753
  notes,
1615
1754
  outOfStockPolicy,
1616
1755
  unavailablePaid,