@mohasinac/appkit 4.69.1 → 4.71.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 (182) hide show
  1. package/PROVIDERS.md +190 -0
  2. package/dist/.build-stamp.json +3 -3
  3. package/dist/_internal/client/features/lottery/LotteryAdminSlotView.js +2 -1
  4. package/dist/_internal/server/features/checkout/actions.js +43 -8
  5. package/dist/_internal/server/features/orders/actions.js +2 -1
  6. package/dist/_internal/server/features/orders/adapters.js +18 -0
  7. package/dist/_internal/server/features/orders/balance.js +2 -1
  8. package/dist/_internal/server/features/orders/cancellation-refund.d.ts +0 -89
  9. package/dist/_internal/server/features/orders/cancellation-refund.js +3 -2
  10. package/dist/_internal/server/features/payouts/actions.js +2 -1
  11. package/dist/_internal/server/features/points/award.js +3 -2
  12. package/dist/_internal/server/features/protection-claims/actions.js +2 -1
  13. package/dist/_internal/server/features/refunds/actions.js +3 -2
  14. package/dist/_internal/server/features/shipping/courier-block.js +5 -2
  15. package/dist/_internal/server/features/shipping/reconcile.js +3 -2
  16. package/dist/_internal/server/features/shipping/reimbursement.js +3 -2
  17. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  18. package/dist/_internal/server/features/stores/adapters.js +12 -2
  19. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +7 -3
  20. package/dist/_internal/server/jobs/core/emiInstallmentReminder.js +2 -1
  21. package/dist/_internal/server/jobs/core/obligationSweep.js +5 -4
  22. package/dist/_internal/server/jobs/core/onScamReportCreate.js +2 -1
  23. package/dist/_internal/server/jobs/core/paymentReconcile.js +5 -4
  24. package/dist/_internal/shared/checkout/rules/auction.rule.js +25 -7
  25. package/dist/_internal/shared/checkout/rules/prize-draw.rule.js +26 -8
  26. package/dist/_internal/shared/config/schema.d.ts +15 -0
  27. package/dist/_internal/shared/content/published-values.d.ts +2 -2
  28. package/dist/_internal/shared/content/published-values.js +31 -12
  29. package/dist/_internal/shared/features/auctions/errors.js +2 -1
  30. package/dist/_internal/shared/features/orders/refund-copy.js +3 -2
  31. package/dist/_internal/shared/features/promotions/errors.js +2 -1
  32. package/dist/constants/field-names.d.ts +23 -0
  33. package/dist/constants/field-names.js +23 -0
  34. package/dist/constants/storage-keys.d.ts +90 -0
  35. package/dist/constants/storage-keys.js +119 -0
  36. package/dist/contracts/index.d.ts +1 -1
  37. package/dist/contracts/payment.d.ts +42 -0
  38. package/dist/contracts/registry.d.ts +8 -0
  39. package/dist/contracts/tax.d.ts +118 -0
  40. package/dist/contracts/tax.js +3 -0
  41. package/dist/core/brand-resolver.d.ts +92 -0
  42. package/dist/core/brand-resolver.js +120 -0
  43. package/dist/core/brand-text.d.ts +15 -0
  44. package/dist/core/brand-text.js +73 -0
  45. package/dist/core/hooks/useNewsletterSubscribe.d.ts +30 -0
  46. package/dist/core/hooks/useNewsletterSubscribe.js +121 -0
  47. package/dist/features/about/components/DeveloperView.js +2 -1
  48. package/dist/features/about/index.d.ts +0 -2
  49. package/dist/features/about/index.js +0 -1
  50. package/dist/features/account/components/BuyerAccountGuideView.js +2 -1
  51. package/dist/features/account/components/BuyerAuctionsGuideView.js +4 -3
  52. package/dist/features/account/components/BuyerOrdersGuideView.js +6 -5
  53. package/dist/features/account/components/BuyerShoppingGuideView.js +3 -2
  54. package/dist/features/account/components/UserOffersPanel.js +2 -1
  55. package/dist/features/account/hooks/useCollapsedSections.js +5 -3
  56. package/dist/features/account/schemas/index.js +1 -1
  57. package/dist/features/admin/actions/notification-actions.js +9 -2
  58. package/dist/features/admin/components/AdminAnalyticsGuideView.js +2 -1
  59. package/dist/features/admin/components/AdminBundlesView.js +2 -1
  60. package/dist/features/admin/components/AdminCatalogGuideView.js +2 -1
  61. package/dist/features/admin/components/AdminContentGuideView.js +2 -1
  62. package/dist/features/admin/components/AdminFaqEditorView.js +2 -1
  63. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -1
  64. package/dist/features/admin/components/AdminGuideHubView.js +2 -1
  65. package/dist/features/admin/components/AdminMoneyCasesView.js +2 -1
  66. package/dist/features/admin/components/AdminOrdersGuideView.js +2 -1
  67. package/dist/features/admin/components/AdminProductsView.js +3 -2
  68. package/dist/features/admin/components/AdminSectionsView.js +3 -2
  69. package/dist/features/admin/components/AdminSiteSettingsView.js +8 -7
  70. package/dist/features/admin/components/AdminStoresGuideView.js +2 -1
  71. package/dist/features/admin/components/AdminSupportTicketDetailView.js +2 -1
  72. package/dist/features/admin/components/AdminTeamGuideView.js +2 -1
  73. package/dist/features/admin/components/AdminTrustGuideView.js +3 -2
  74. package/dist/features/admin/components/AdminUsersGuideView.js +2 -1
  75. package/dist/features/admin/components/sections/adminSectionsTypes.js +1 -1
  76. package/dist/features/admin/schemas/firestore.js +0 -1
  77. package/dist/features/auth/actions/user-slug.js +21 -2
  78. package/dist/features/auth/hooks/useAuth.js +11 -4
  79. package/dist/features/cart/utils/pending-ops.js +16 -3
  80. package/dist/features/catalogue/utils/product-from-item.d.ts +18 -2
  81. package/dist/features/catalogue/utils/product-from-item.js +23 -4
  82. package/dist/features/categories/components/BundleItemsPicker.js +2 -1
  83. package/dist/features/categories/components/CategoryDetailPageView.js +2 -1
  84. package/dist/features/categories/components/ContextualTrail.d.ts +9 -2
  85. package/dist/features/categories/components/ContextualTrail.js +11 -3
  86. package/dist/features/categories/components/LastCategoryBeacon.js +2 -2
  87. package/dist/features/checkout/actions/checkout-value-otp-actions.js +9 -1
  88. package/dist/features/contact/email.js +11 -12
  89. package/dist/features/email/notification-templates.js +4 -5
  90. package/dist/features/email/render-static-markup.d.ts +34 -0
  91. package/dist/features/email/render-static-markup.js +4 -0
  92. package/dist/features/events/utils/event-charts.js +2 -1
  93. package/dist/features/homepage/components/AnnouncementBar.js +5 -3
  94. package/dist/features/homepage/components/FAQSection.js +1 -1
  95. package/dist/features/homepage/components/MarketplaceHomepageView.js +34 -0
  96. package/dist/features/homepage/components/WelcomeSection.d.ts +3 -1
  97. package/dist/features/homepage/components/WelcomeSection.js +2 -2
  98. package/dist/features/homepage/lib/section-renderer.d.ts +8 -0
  99. package/dist/features/homepage/lib/section-renderer.js +1 -1
  100. package/dist/features/layout/AppLayoutShell.d.ts +3 -1
  101. package/dist/features/layout/AppLayoutShell.js +2 -2
  102. package/dist/features/layout/TitleBarLayout.d.ts +8 -1
  103. package/dist/features/layout/TitleBarLayout.js +2 -2
  104. package/dist/features/media/MediaVideo.js +2 -1
  105. package/dist/features/orders/actions/refund-actions.js +3 -2
  106. package/dist/features/orders/components/RefundRequestView.js +15 -2
  107. package/dist/features/orders/repository/orders.repository.js +2 -1
  108. package/dist/features/orders/types/index.d.ts +14 -0
  109. package/dist/features/orders/utils/payment-proof-message.js +5 -2
  110. package/dist/features/price-index/components/PriceIndexToolView.js +6 -5
  111. package/dist/features/pricing/components/AdminPricingView.js +2 -1
  112. package/dist/features/print/components/labels.js +2 -1
  113. package/dist/features/products/components/CityCollectiblesView.js +2 -1
  114. package/dist/features/products/components/MakeOfferButton.js +2 -1
  115. package/dist/features/products/components/PrizeDrawCollage.js +3 -2
  116. package/dist/features/products/components/PrizeRevealModal.js +2 -1
  117. package/dist/features/products/repository/products.repository.js +2 -1
  118. package/dist/features/promotions/components/CouponCard.js +3 -2
  119. package/dist/features/promotions/components/CouponHelpDetails.js +2 -1
  120. package/dist/features/promotions/constants/coupon-help.d.ts +1 -1
  121. package/dist/features/promotions/constants/coupon-help.js +8 -1
  122. package/dist/features/scams/components/ScamAwarenessModal.js +2 -1
  123. package/dist/features/scams/components/ScamProfileView.js +5 -3
  124. package/dist/features/scams/components/ScamRegistryView.js +2 -1
  125. package/dist/features/scams/constants/scam-types.d.ts +13 -1
  126. package/dist/features/scams/constants/scam-types.js +67 -23
  127. package/dist/features/scams/schemas/firestore.js +1 -1
  128. package/dist/features/search/components/Search.js +5 -1
  129. package/dist/features/seller/actions/offer-actions.js +47 -9
  130. package/dist/features/seller/components/DispatchForm.js +3 -2
  131. package/dist/features/seller/components/PrintCenterView.js +4 -2
  132. package/dist/features/seller/components/SellerBundlesView.js +2 -1
  133. package/dist/features/seller/components/SellerPayoutSettingsView.js +3 -2
  134. package/dist/features/seller/components/SellerProductShell.js +3 -2
  135. package/dist/features/seller/components/SellerProductsView.js +4 -3
  136. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +30 -4
  137. package/dist/features/store-extensions/repository/store-extensions.repositories.js +46 -7
  138. package/dist/features/store-extensions/schemas/firestore.d.ts +22 -3
  139. package/dist/features/store-extensions/schemas/firestore.js +20 -0
  140. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +7 -6
  141. package/dist/features/stores/components/StoreFinanceGuideView.js +6 -5
  142. package/dist/features/stores/components/StoreGuideHubView.js +2 -1
  143. package/dist/features/stores/components/StoreListingsGuideView.js +2 -1
  144. package/dist/features/stores/components/StoreOrdersGuideView.js +5 -4
  145. package/dist/features/stores/components/StoreSettingsGuideView.js +2 -1
  146. package/dist/features/support/schemas/index.d.ts +2 -2
  147. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +1 -1
  148. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +3 -2
  149. package/dist/features/whatsapp-bot/helpers/whatsapp.js +5 -2
  150. package/dist/index.d.ts +0 -3
  151. package/dist/index.js +0 -6
  152. package/dist/providers/payment-manual/index.d.ts +11 -0
  153. package/dist/providers/payment-manual/index.js +13 -0
  154. package/dist/providers/payment-phonepe/index.d.ts +23 -7
  155. package/dist/providers/payment-phonepe/index.js +15 -0
  156. package/dist/providers/payment-razorpay/index.d.ts +13 -0
  157. package/dist/providers/payment-razorpay/index.js +21 -1
  158. package/dist/providers/tax-india-gst/index.d.ts +45 -0
  159. package/dist/providers/tax-india-gst/index.js +78 -0
  160. package/dist/react/hooks/useScrollToTop.d.ts +5 -0
  161. package/dist/react/hooks/useScrollToTop.js +70 -0
  162. package/dist/schemas/registry.d.ts +2 -2
  163. package/dist/seo/json-ld.js +25 -4
  164. package/dist/styles.css +20 -0
  165. package/dist/ui/components/SiteLogo.d.ts +20 -2
  166. package/dist/ui/components/SiteLogo.js +28 -6
  167. package/dist/ui/components/SiteMark.d.ts +1 -1
  168. package/dist/ui/components/SiteMark.js +21 -6
  169. package/dist/ui/components/Tabs.d.ts +9 -1
  170. package/dist/ui/components/Tabs.js +20 -3
  171. package/dist/ui/components/Tabs.style.css +20 -0
  172. package/dist/ui/rich-text/RichText.d.ts +0 -12
  173. package/dist/ui/rich-text/RichText.js +52 -1
  174. package/dist/utils/date.formatter.d.ts +20 -0
  175. package/dist/utils/date.formatter.js +25 -0
  176. package/dist/utils/number.formatter.d.ts +47 -0
  177. package/dist/utils/number.formatter.js +79 -1
  178. package/dist/validation/phone.validator.d.ts +0 -1
  179. package/dist/validation/phone.validator.js +17 -3
  180. package/package.json +17 -3
  181. package/dist/features/about/components/AboutView.d.ts +0 -30
  182. package/dist/features/about/components/AboutView.js +0 -14
package/PROVIDERS.md ADDED
@@ -0,0 +1,190 @@
1
+ # Provider registry — what you can replace, and what you cannot
2
+
3
+ Written against the tree on **2026-10-29**. Every count here was measured, not
4
+ estimated; re-measure before quoting any of it elsewhere.
5
+
6
+ appkit reaches the outside world through one registry. A consumer calls
7
+ `registerProviders()` once at boot and appkit code asks for what it needs:
8
+
9
+ ```ts
10
+ import { registerProviders } from "@mohasinac/appkit/contracts/registry";
11
+
12
+ registerProviders({
13
+ db: myDbProvider,
14
+ auth: myAuthProvider,
15
+ session: mySessionProvider,
16
+ email: myEmailProvider,
17
+ storage: myStorageProvider,
18
+ style: tailwindAdapter,
19
+ });
20
+ ```
21
+
22
+ The five without `?` in `ProviderRegistry` are required by the type. The rest
23
+ are optional, and an absent optional provider means "appkit keeps its existing
24
+ behaviour" — never a throw.
25
+
26
+ ---
27
+
28
+ ## 🛑 Read this before planning a replacement: Firebase is a hard dependency today
29
+
30
+ **appkit is "Firebase ecommerce from scratch", not "ecommerce from scratch".**
31
+
32
+ | Measured 2026-10-29 | |
33
+ |---|---|
34
+ | appkit files importing `firebase-admin` / `firebase/*` **outside `providers/`** | **59** |
35
+ | appkit files calling `getAdminDb` / `getAdminAuth` / `getAdminStorage` directly | **64** |
36
+
37
+ `IDbProvider` exists and is registered, but 64 files bypass it and talk to the
38
+ Admin SDK. So swapping `db` for Postgres does **not** work today, whatever the
39
+ registry's shape suggests. The seams that are real are listed below; the rest
40
+ is a direction of travel.
41
+
42
+ This is stated plainly because the alternative is discovering it in a sales
43
+ conversation. Closing it is a tracked tier (AK3-dip-direct-repo: *"every appkit
44
+ component/hook importing a concrete repository instead of a provider"*), not a
45
+ afternoon's work.
46
+
47
+ ---
48
+
49
+ ## The slots
50
+
51
+ 🛑 **"Read?" is the column that matters.** A slot nothing reads is
52
+ indistinguishable from one that works — the consumer passes a provider, appkit
53
+ accepts it, and the value is never fetched. Six slots are in that state right
54
+ now. `scripts/audit-provider-registry-readers.mjs` blocks a seventh.
55
+
56
+ | Slot | Contract | letitrip registers | Read? | Replaceable today? |
57
+ |---|---|---|---|---|
58
+ | `db` | `IDbProvider` | `firebaseDbProvider` | ✅ 46 files | ❌ — 64 files bypass it for the Admin SDK |
59
+ | `auth` | `IAuthProvider` | `firebaseAuthProvider` | ✅ 4 | partly |
60
+ | `session` | `ISessionProvider` | `firebaseSessionProvider` | ✅ 3 | partly |
61
+ | `email` | `IEmailProvider` | `createResendProvider(...)` | ✅ 1 | **yes** — the cleanest seam in the package |
62
+ | `payment` | `IPaymentProvider` | manual \| Razorpay \| PhonePe | ✅ 11 | **yes** — three implementations prove it |
63
+ | `shipping` | `IShippingProvider` | manual \| Shiprocket | ✅ 6 | **yes** — two implementations |
64
+ | `rbac` | `IRbacProvider` | inline `{getPermissions, isAdmin}` | ✅ 1 | yes |
65
+ | `eventBus` | `IEventBus` | — | ✅ 1 | yes |
66
+ | `storage` | `IStorageProvider` | `firebaseStorageProvider` | 🛑 **0** | no — media code imports the provider module directly |
67
+ | `style` | `IStyleAdapter` | `tailwindAdapter` | 🛑 **0** | no — reached by direct import |
68
+ | `tax` | `ITaxProvider` | `indiaGstTaxProvider` | 🛑 **0** | **blocked — see below** |
69
+ | `search` | `ISearchProvider` | — | 🛑 **0** | no — listing queries call repositories |
70
+ | `cache` | `ICacheProvider` | — | 🛑 **0** | declared only |
71
+ | `queue` | `IQueueProvider` | — | 🛑 **0** | declared only; the job primitive is `enqueueJob` + `JOB_RUNNERS` |
72
+
73
+ `payment` and `shipping` are the two to copy when writing a new provider: each
74
+ has more than one real implementation, selected at boot from site settings, and
75
+ a fall-through to a complete manual flow. A half-configured gateway fails
76
+ loudly at boot rather than when a buyer tries to pay.
77
+
78
+ ### 🛑 `tax` — a declared seam that cannot be read yet
79
+
80
+ Two obstacles, and the second is a design problem rather than effort:
81
+
82
+ 1. **Runtime.** Its call sites are in `_internal/shared/checkout/order-math.ts`,
83
+ which is isomorphic — `features/cart/utils/line-total.ts` reaches it from the
84
+ *client* graph for per-store subtotals. `getProviders()` throws where nothing
85
+ registered, and a consumer registers on the server only. (Same trap
86
+ `features/contact/email.tsx` documents for Firebase Functions, where it made
87
+ the daily digest silently never deliver.)
88
+ 2. **Shape.** `TaxBreakdown` carries `components[]` keyed by provider-specific
89
+ codes. `order-math.ts` needs named `cgst`/`sgst`/`igst` scalars — 27
90
+ references — because India's Rule 46 requires tax *per rate* on the invoice.
91
+ Converting means generic code doing `components.find(c => c.code === "cgst")`,
92
+ re-importing the exact vocabulary the interface exists to abstract.
93
+
94
+ The honest reading: **`order-math.ts` is not generic code that uses GST, it is
95
+ India invoice code.** Fixing it is a decision about where GST invoice assembly
96
+ lives, on a money path.
97
+
98
+ ---
99
+
100
+ ## Two injection mechanisms, and which to use
101
+
102
+ The registry is not the only seam. Some values are needed on **every runtime**,
103
+ including the browser bundle and Firebase Functions, neither of which calls
104
+ `registerProviders()`.
105
+
106
+ | Need | Mechanism | Call it in |
107
+ |---|---|---|
108
+ | A service appkit calls (db, email, payment) | `registerProviders({...})` | `providers.config.ts` (server) |
109
+ | A value isomorphic code reads (brand, currency, storage namespace) | a `configure*` function | **both** `providers.config.ts` *and* `client-providers-init.ts` |
110
+
111
+ The `configure*` family — all merge rather than replace, all have neutral
112
+ defaults:
113
+
114
+ | Function | Module | Sets |
115
+ |---|---|---|
116
+ | `configureBrand` | `core/brand-resolver` | name, shortName, domain, support/noreply email |
117
+ | `configureMarketDefaults` | `core/baseline-resolver` | currency, locale, country, phone prefix, timezone, symbol |
118
+ | `setStorageNamespace` | `constants/storage-keys` | the prefix on every browser storage key |
119
+ | `configurePlatformSupplier` | `_internal/.../supplier-of-record` | statutory identity for invoices (server only) |
120
+
121
+ 🛑 **Module state does not cross a module graph.** A `configure*` call in
122
+ `providers.config.ts` runs from the instrumentation hook and is invisible to the
123
+ browser bundle. An isomorphic value needs the call in both places, reading the
124
+ same owner (`appkit.config.js`) so the two cannot disagree about the value —
125
+ only about whether they ran.
126
+
127
+ 🛑 **Brand defaults are NEUTRAL (`"Site"`), market defaults are not.**
128
+ `baseline-resolver`'s baseline is India's (INR / en-IN / +91). That asymmetry is
129
+ deliberate: a wrong currency is visible in the first rendered price, while a
130
+ wrong brand name renders plausibly and is someone else's trademark.
131
+
132
+ ---
133
+
134
+ ## The override hierarchy — least invasive first
135
+
136
+ Replacing a provider is the *fifth* of six options. Try them in order:
137
+
138
+ 1. **Config / tokens** — `siteSettings.theme`, a partial `LabelsProvider` map,
139
+ `appkit.config.js`.
140
+ 2. **Options object** — every public function takes an optional final `opts?`.
141
+ `renderXOgImage(doc, { theme, headline, layout: "compact" })`.
142
+ 3. **Render-prop slots** — `<XDetailView renderActions={(ctx) => <MyActions/>}>`.
143
+ 4. **Adapter wrap** — `const myToClient = (doc) => decorate(toClientX(doc))`.
144
+ 5. **Replace the call** — register your own provider, or call the repository
145
+ directly and skip the helper.
146
+ 6. **Fork via patch** — only when the seam does not exist. File an issue; the
147
+ seam should ship in appkit rather than live in your fork.
148
+
149
+ ---
150
+
151
+ ## Writing a provider
152
+
153
+ Contracts are **abstract classes**, not interfaces, so every implementation
154
+ shares one base:
155
+
156
+ ```ts
157
+ import { IPaymentProvider } from "@mohasinac/appkit/contracts/payment";
158
+
159
+ export class MyGateway extends IPaymentProvider {
160
+ readonly id = "my-gateway";
161
+ // implement the abstract members; `tsc` enumerates them for you
162
+ }
163
+ ```
164
+
165
+ Three rules learned the hard way:
166
+
167
+ - **Fail at boot, not at point of use.** A provider constructed with empty
168
+ credentials should throw while the app is starting. The alternative is a buyer
169
+ discovering it at checkout.
170
+ - **Treat a placeholder as absent.** Seeded credentials like
171
+ `re_PLACEHOLDER` are truthy. `resolveKeys()` maps them to absent so a flag
172
+ switched on with no real key does not look configured.
173
+ - **Never name your gateway in generic code.** `audit-provider-name-leak`
174
+ blocks it: a provider name belongs inside its own `providers/<name>/`
175
+ directory and nowhere else.
176
+
177
+ ---
178
+
179
+ ## Verifying a standalone install
180
+
181
+ 🛑 **There is no proof app yet, so "standalone" remains a claim.**
182
+
183
+ The acceptance test is a scratch Next.js app that installs appkit from the
184
+ registry, registers a **non-Firebase** provider for at least one contract, and
185
+ renders a catalogue. Given the 64 files above, the honest expectation is that it
186
+ fails on `db` and succeeds on `email` — which is exactly why the test is worth
187
+ running rather than reasoned about.
188
+
189
+ It cannot be written against this tree: it must install a *published* version,
190
+ and the work described in this document is not published yet.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.69.1",
4
- "head": "1a1259a8058e4a957e71fef93fe49c34708f77bb",
3
+ "version": "4.71.0",
4
+ "head": "260f012ee352221df711fee74a7d61d8825ae3c5",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-27T13:28:31.858Z"
7
+ "builtAt": "2026-10-29T02:57:55.180Z"
8
8
  }
@@ -5,6 +5,7 @@ import { Text, Heading } from "../../../../ui/components/Typography.js";
5
5
  import { Badge } from "../../../../ui/components/Badge.js";
6
6
  import { Section } from "../../../../ui/components/Semantic.js";
7
7
  import { DataTable } from "../../../../ui/DataTable.js";
8
+ import { formatMoney } from "../../../../utils/number.formatter.js";
8
9
  /**
9
10
  * Admin-only view showing slots with price + weight + % chance.
10
11
  * Separate from LotterySlotGrid (public) which never shows price/weight.
@@ -25,7 +26,7 @@ export function LotteryAdminSlotView({ slots }) {
25
26
  {
26
27
  key: "price",
27
28
  header: "Price (₹)",
28
- render: (s) => (_jsxs(Text, { size: "sm", family: "mono", children: ["\u20B9", s.price.toLocaleString("en-IN")] })),
29
+ render: (s) => (_jsx(Text, { size: "sm", family: "mono", children: formatMoney(s.price) })),
29
30
  },
30
31
  {
31
32
  key: "weight",
@@ -45,6 +45,7 @@ import { computeProtectionFees } from "../../../shared/features/shipping/protect
45
45
  /* S5a — one payer, N destinations. Pure: the rates come in snapshotted, and
46
46
  * an UNPRICED arrangement is refused rather than quoted at ₹0 (D44's lesson). */
47
47
  import { countDestinations, quoteSharedPurchase, } from "../../../shared/features/shipping/shared-purchase.js";
48
+ import { formatMoney } from "../../../../utils/number.formatter.js";
48
49
  import { resolveCouponCategorySlugs } from "../../../../features/promotions/actions/coupon-actions.js";
49
50
  import { assertCheckoutLane, assertLockedLinesStillValid, finalizeLockedLines, } from "./locked-lines.js";
50
51
  import { activeLane, defaultCheckoutItems, resolveCheckoutItems, laneOf, } from "../../../shared/checkout/lanes.js";
@@ -65,7 +66,6 @@ import { getProviders } from "../../../../contracts/index.js";
65
66
  * index would pull a static `import Razorpay from "razorpay"` into this
66
67
  * action's import graph for one HMAC comparison — see that file's header.
67
68
  */
68
- import { verifyPaymentSignature as verifyRazorpayPaymentSignature } from "../../../../providers/payment-razorpay/signature.js";
69
69
  import { resolveKeys } from "../../../../core/integration-keys.js";
70
70
  import { paiseToRupees } from "../../../../core/money.js";
71
71
  import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config.js";
@@ -868,7 +868,7 @@ async function createOrderForGroup(group, orderType, ctx) {
868
868
  */
869
869
  const emiGate = groupEmiGate(emiGroupInput, emiSettings);
870
870
  if (emiOptIn && emiTenureMonths && !emiGate.applies) {
871
- throw new ValidationError(`EMI is not available for this order (${emiGate.reason}). Minimum order value per seller is ₹${emiSettings.minOrderValue}.`);
871
+ throw new ValidationError(`EMI is not available for this order (${emiGate.reason}). Minimum order value per seller is ${formatMoney(emiSettings.minOrderValue)}.`);
872
872
  }
873
873
  const emiSchedule = groupEmiSchedule(emiGroupInput, emiSettings) ?? undefined;
874
874
  // Handling fee charged to the buyer for choosing COD, on top of the order
@@ -2897,8 +2897,43 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
2897
2897
  */
2898
2898
  if (input.razorpaySignature) {
2899
2899
  const { razorpayKeySecret } = await resolveKeys();
2900
+ /*
2901
+ * 🛑 A4b — through the CONTRACT, not a direct Razorpay import.
2902
+ *
2903
+ * This file used to `import { verifyPaymentSignature } from
2904
+ * "providers/payment-razorpay/signature"`, which named one gateway in the
2905
+ * one place that has to work for every gateway. `verifyCheckoutSignature`
2906
+ * is on `IPaymentProvider` now; Razorpay's implementation calls the SAME
2907
+ * function with the SAME HMAC, so no arithmetic moved.
2908
+ *
2909
+ * Everything that made this fail closed is unchanged: the branch still only
2910
+ * runs when a signature is present, the secret is still resolved here, and
2911
+ * `!!razorpayKeySecret` still short-circuits. A provider with no such
2912
+ * handshake (manual, PhonePe) returns `false` — never `true`.
2913
+ *
2914
+ * Proven rather than argued: `.build-memory/checkout-sig-parity.mjs` ran
2915
+ * both paths over 12 cases — valid, tampered order id, tampered payment id,
2916
+ * wrong secret, empty secret, non-hex, short, and every missing-field
2917
+ * permutation. The provider never accepts what the direct call rejected.
2918
+ *
2919
+ * One case differs, in the safe direction, and it fixes a latent crash:
2920
+ * on a callback MISSING `razorpay_signature` the old call THREW
2921
+ * (`hexEqual` reads `.length` of `undefined`), so a malformed callback
2922
+ * escaped as an unhandled TypeError instead of taking the rejection path
2923
+ * below. The provider checks its three fields first and returns false, so
2924
+ * that request now gets logged as `signature_mismatch` like any other
2925
+ * forgery.
2926
+ */
2927
+ /*
2928
+ * 🛑 No `payment!`. `payment` is OPTIONAL on the registry, and a missing
2929
+ * provider must reject rather than throw — the same fail-closed direction
2930
+ * as the absent secret beside it. An assertion here would turn an
2931
+ * unconfigured install into a 500 on the payment path.
2932
+ */
2933
+ const paymentProvider = getProviders().payment;
2900
2934
  const signatureValid = !!razorpayKeySecret &&
2901
- verifyRazorpayPaymentSignature(input.razorpaySignature, razorpayKeySecret);
2935
+ !!paymentProvider &&
2936
+ paymentProvider.verifyCheckoutSignature(input.razorpaySignature, razorpayKeySecret);
2902
2937
  if (!signatureValid) {
2903
2938
  serverLogger.warn(`Razorpay signature rejected: order=${input.razorpaySignature.razorpay_order_id} payment=${input.razorpaySignature.razorpay_payment_id}`);
2904
2939
  failedCheckoutRepository
@@ -3022,7 +3057,7 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3022
3057
  // (no session) reconstructs them from the gateway's own passthrough channel
3023
3058
  // — PhonePe's `metaInfo`, Razorpay's `notes` — both packed at create-order
3024
3059
  // time and both surfaced as `PaymentOrder.metadata`. See
3025
- // `PhonePeCheckoutIntent`, which is the shape either one carries.
3060
+ // `CheckoutIntentMetadata`, which is the shape either one carries.
3026
3061
  const intent = gatewayOrder.metadata;
3027
3062
  const uid = input.userId ?? intent?.uid;
3028
3063
  if (!uid) {
@@ -3428,9 +3463,9 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3428
3463
  const amountDelta = paidAmountRs - expectedPaymentAmountRs;
3429
3464
  const AMOUNT_TOLERANCE_RS = 1;
3430
3465
  if (amountDelta < -AMOUNT_TOLERANCE_RS) {
3431
- serverLogger.warn(`Payment amount mismatch for user ${uid}: paid ₹${paidAmountRs}, expected ≥ ₹${expectedPaymentAmountRs}`);
3466
+ serverLogger.warn(`Payment amount mismatch for user ${uid}: paid ${formatMoney(paidAmountRs)}, expected ≥ ${formatMoney(expectedPaymentAmountRs)}`);
3432
3467
  failedCheckoutRepository
3433
- .logPayment(uid, "amount_mismatch", `Paid ₹${paidAmountRs}, expected ≥ ₹${expectedPaymentAmountRs}`, {
3468
+ .logPayment(uid, "amount_mismatch", `Paid ${formatMoney(paidAmountRs)}, expected ≥ ${formatMoney(expectedPaymentAmountRs)}`, {
3434
3469
  gatewayOrderId: gatewayOrder.gatewayOrderId,
3435
3470
  gatewayPaymentId: merchantOrderId,
3436
3471
  amountRs: paidAmountRs,
@@ -3440,9 +3475,9 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3440
3475
  throw new ValidationError(ERROR_MESSAGES.CHECKOUT.PAYMENT_FAILED);
3441
3476
  }
3442
3477
  if (amountDelta > AMOUNT_TOLERANCE_RS) {
3443
- serverLogger.error(`OVER-CAPTURE for user ${uid}: paid ₹${paidAmountRs}, expected ₹${expectedPaymentAmountRs} (surplus ₹${amountDelta.toFixed(2)}) — order will still be placed`, { merchantOrderId, expectedCouponDiscount });
3478
+ serverLogger.error(`OVER-CAPTURE for user ${uid}: paid ${formatMoney(paidAmountRs)}, expected ${formatMoney(expectedPaymentAmountRs)} (surplus ${formatMoney(amountDelta)}) — order will still be placed`, { merchantOrderId, expectedCouponDiscount });
3444
3479
  failedCheckoutRepository
3445
- .logPayment(uid, "amount_mismatch", `OVER-CAPTURE: paid ₹${paidAmountRs}, expected ₹${expectedPaymentAmountRs}, surplus ₹${amountDelta.toFixed(2)} — refund owed`, {
3480
+ .logPayment(uid, "amount_mismatch", `OVER-CAPTURE: paid ${formatMoney(paidAmountRs)}, expected ${formatMoney(expectedPaymentAmountRs)}, surplus ${formatMoney(amountDelta)} — refund owed`, {
3446
3481
  gatewayOrderId: gatewayOrder.gatewayOrderId,
3447
3482
  gatewayPaymentId: merchantOrderId,
3448
3483
  amountRs: paidAmountRs,
@@ -37,6 +37,7 @@ import { PAYMENT_WINDOW_MS, PAYMENT_FRAUD_REJECTED_REASON, isManualPaymentMethod
37
37
  import { deadlinesFor } from "../../../shared/obligations/deadlines.js";
38
38
  import { normalizeError } from "../../../../errors/normalize.js";
39
39
  import { serverLogger } from "../../../../monitoring/index.js";
40
+ import { formatMoney } from "../../../../utils/number.formatter.js";
40
41
  export async function createOrderAction(input) {
41
42
  return wrapAction(async () => {
42
43
  const user = await requireRoleUser(["user", "buyer", "seller", "admin"]);
@@ -181,7 +182,7 @@ function notifyAdminsOfPaymentProof(order) {
181
182
  await adminNotificationsRepository.create({
182
183
  category: "payouts",
183
184
  title: "Payment proof awaiting review",
184
- body: `${order.userName} uploaded proof for "${order.productTitle}" (₹${order.totalPrice.toLocaleString("en-IN")}). Review before the 2-hour auto-approve window closes.`,
185
+ body: `${order.userName} uploaded proof for "${order.productTitle}" (${formatMoney(order.totalPrice)}). Review before the 2-hour auto-approve window closes.`,
185
186
  severity: "warning",
186
187
  isRead: false,
187
188
  entityType: "order",
@@ -269,6 +269,24 @@ export function orderDocumentToOrder(doc) {
269
269
  supplierGstin: doc.supplierGstin,
270
270
  supplierLegalName: doc.supplierLegalName,
271
271
  supplierAddress: doc.supplierAddress,
272
+ /*
273
+ * 🛑 Root Cause #57, in the one place it is most visible: this adapter
274
+ * mapped the CGST/SGST/IGST split above and dropped the field that
275
+ * EXPLAINS it.
276
+ *
277
+ * Place of supply is what decides whether a supply is intra-State
278
+ * (CGST+SGST) or inter-State (IGST). Without it the HTML invoice showed a
279
+ * split it could not justify — and the PDF, which reads the raw
280
+ * `OrderDocument` rather than going through here, printed the line
281
+ * correctly. So the two invoices for ONE order disagreed on a Rule 46(n)
282
+ * field, and the buyer-facing one was the deficient half.
283
+ *
284
+ * Nothing was wrong with the data: `createOrderForGroup` has always
285
+ * written it. It simply never crossed the client boundary, which is why
286
+ * the gap reads as "this order has no place of supply" rather than as a
287
+ * dropped field.
288
+ */
289
+ placeOfSupply: doc.placeOfSupply,
272
290
  codHandlingFee: doc.codHandlingFee,
273
291
  trackingNumber: doc.trackingNumber,
274
292
  shippingCarrier: doc.shippingCarrier,
@@ -35,6 +35,7 @@ import { AuthorizationError } from "../../../../errors/authorization-error.js";
35
35
  import { storeRepository } from "../../../../features/stores/repository/store.repository.js";
36
36
  import { sendNotification } from "../../../../features/admin/actions/notification-actions.js";
37
37
  import { serverLogger } from "../../../../monitoring/server-logger.js";
38
+ import { formatMoney } from "../../../../utils/number.formatter.js";
38
39
  /*
39
40
  * 🛑 ONE message, for the same reason the ads collector names its own: an
40
41
  * order that is not the caller's answers 404 rather than 403, so "no such
@@ -128,7 +129,7 @@ export async function openBalanceCollection(orderId, actor, now = new Date()) {
128
129
  type: "order_confirmed",
129
130
  priority: "high",
130
131
  title: "Your pre-order balance is due",
131
- message: `The balance of ₹${due.toFixed(2)} on order ${orderId} is now payable. Your item is ready to ship once it clears.`,
132
+ message: `The balance of ${formatMoney(due)} on order ${orderId} is now payable. Your item is ready to ship once it clears.`,
132
133
  relatedId: orderId,
133
134
  relatedType: "order",
134
135
  });
@@ -1,92 +1,3 @@
1
- /**
2
- * Cancel an order AND give the money back — one owner, three callers.
3
- *
4
- * ## D14, and why passing the amount was only half of it
5
- *
6
- * `cancelOrder(orderId, reason, refundAmount?)` has carried an optional
7
- * `refundAmount` since it was written, and **all four callers omitted it**. So
8
- * cancelling a *paid* order restored stock and left the money with us, while
9
- * `previewCancellationRefund` showed the buyer a figure nothing honoured.
10
- *
11
- * 🛑 **But passing the amount alone would have made it WORSE.** `cancelOrder`'s
12
- * body writes `{ refundAmount, refundStatus: "pending" }` and nothing else —
13
- * there is no provider call anywhere in it. So the obvious fix creates a *new*
14
- * dead state: a recorded refund intent that nothing ever settles, which is
15
- * strictly worse than today's honest silence because it reads, in the database,
16
- * as though a refund is on its way. The amount and the movement have to land
17
- * together, which is why this module exists rather than a one-line edit at each
18
- * call site.
19
- *
20
- * ## The decision table
21
- *
22
- * Every row cancels the order and **restores its stock** — see `releaseStock`,
23
- * which closes a second half of D14 the plan had recorded backwards. The table
24
- * is only about the money.
25
- *
26
- * | condition | what happens to the money |
27
- * |---|---|
28
- * | `paymentStatus !== "paid"` | **nothing** — nothing was ever captured |
29
- * | nothing left refundable | nothing — already refunded in full |
30
- * | goods have left the seller | intent recorded, **case opened** — a human decides |
31
- * | paid by cash / UPI-manual / EMI | intent recorded, **case opened** — there is no automatic rail |
32
- * | paid through the gateway | **refunded automatically**, in full, once |
33
- *
34
- * The last row is the only automatic money movement, and it satisfies the
35
- * plan's guardrail for one: the ceiling is an amount we captured, the recipient
36
- * is the payer, and no counterparty is debited.
37
- *
38
- * ## Three things that are load-bearing
39
- *
40
- * **It goes through `processRefundAction`, never `payment.refund()` directly.**
41
- * That action owns the idempotency claim, the cumulative cap against
42
- * `refundableBase`, and the two-phase reserve/settle write around the provider
43
- * call. A direct provider call here would reintroduce every defect step 8
44
- * closed — D1, D2, D6 and D7 at once.
45
- *
46
- * **The key is `cancel_{orderId}`** — a NATURAL key, so a double-clicked Cancel
47
- * button, a retried server action and a sweep all collapse to one refund. A
48
- * `randomUUID()` here would be D2 wearing a new name.
49
- *
50
- * ## Points ARE reversed here now (Phase P)
51
- *
52
- * `pointsLedger`'s own header states the rule as a legal boundary: *"points
53
- * spent on a cancelled order are returned as points"* — never as money, because
54
- * paying them out in rupees is what would make this stored value, which in
55
- * India is PPI territory with RBI authorisation attached.
56
- *
57
- * `reverseRedemptionForOrder` is that rule's first writer; the kind had been
58
- * declared with none since it was written (D48). It is idempotent on the order
59
- * — one reversal per order, checked by query before the write, because an
60
- * append-only ledger cannot net off a double reversal.
61
- *
62
- * 🛑 **It stopped being a no-op on 2026-10-22.** This paragraph read "It is a
63
- * NO-OP today and still correct to call … when redemption becomes a real
64
- * tender, cancellation already gives the points back" — and the tender is now
65
- * built (`resolvePointsTender` + `settlePointsTender` in `checkout/actions.ts`),
66
- * so orders carry `pointsApplied` and this query finds real redemptions. The
67
- * bet paid off: no cancellation path had to be remembered, because the reversal
68
- * was already wired and keyed.
69
- *
70
- * 🛑 **The MONEY half had to change in the same commit, and it is the sharper
71
- * of the two.** `refundableBase` now subtracts `pointsApplied`, because a
72
- * refund can only return what was collected: on a ₹1,000 order settled with
73
- * 250 points the card paid ₹750, so refunding the full base would return
74
- * ₹1,000 in cash AND 250 points — ₹250 of real money conjured from a discount.
75
- * Two halves of one refund, in two units, and only their sum is what the buyer
76
- * gave up.
77
- *
78
- * **Cancel, then stock, then money.** The cancellation is the urgent half and
79
- * must not be held hostage to a gateway round trip; the restock is a local
80
- * write another buyer is actively waiting on; the refund is the one step that
81
- * can hang for a full network timeout. The cost of that ordering is a window
82
- * where the order is `cancelled` with the money still held — which is exactly
83
- * why `refundAmount` is written *at cancellation time*, before the provider is
84
- * called: a crash in the window leaves a detectable artefact rather than
85
- * nothing, and R5 in `paymentReconcile` is the thing that finds it. The reverse
86
- * ordering has no such consolation, because a refund posted against a live
87
- * order that then fails to cancel is money gone from an order the buyer still
88
- * expects to receive.
89
- */
90
1
  import type { OrderDocument } from "../../../../features/orders/schemas/firestore";
91
2
  import type { CancellationRefundOutcome } from "../../../../features/orders/types/index";
92
3
  import type { HistoryActor } from "../../../shared/history/types";
@@ -1,3 +1,4 @@
1
+ import { formatMoney } from "../../../../utils/number.formatter.js";
1
2
  /**
2
3
  * Cancel an order AND give the money back — one owner, three callers.
3
4
  *
@@ -212,7 +213,7 @@ async function openOwedCase(order, amount, why, detailLines) {
212
213
  */
213
214
  subjectId: order.id,
214
215
  severity: "error",
215
- title: `Refund of ₹${amount.toFixed(2)} owed on cancelled order ${order.id}`,
216
+ title: `Refund of ${formatMoney(amount)} owed on cancelled order ${order.id}`,
216
217
  detail: [
217
218
  ...detailLines,
218
219
  "",
@@ -221,7 +222,7 @@ async function openOwedCase(order, amount, why, detailLines) {
221
222
  `Store: ${order.storeId ?? "—"}`,
222
223
  `Payment method: ${order.paymentMethod ?? "unknown"}`,
223
224
  `Gateway reference: ${order.paymentId ?? "—"}`,
224
- `Amount owed: ₹${amount.toFixed(2)}`,
225
+ `Amount owed: ${formatMoney(amount)}`,
225
226
  "",
226
227
  "The order is cancelled and its stock is restored. `refundAmount` is recorded on the order with `refundStatus: \"pending\"`, so this case and the order agree about what is owed.",
227
228
  ].join("\n"),
@@ -64,6 +64,7 @@ import { payoutRepository } from "../../../../features/payments/repository/payou
64
64
  import { serverErrorsRepository } from "../../../../features/server-errors/repository/server-errors.repository.js";
65
65
  import { ValidationError } from "../../../../errors/index.js";
66
66
  import { roundRupees } from "../../../../utils/number.formatter.js";
67
+ import { formatMoney } from "../../../../utils/number.formatter.js";
67
68
  export async function applyRefundDeductionAction(input) {
68
69
  return wrapAction(async () => {
69
70
  if (input.refundedAmount <= 0) {
@@ -95,7 +96,7 @@ export async function applyRefundDeductionAction(input) {
95
96
  source: "vercel",
96
97
  route: "applyRefundDeductionAction",
97
98
  code,
98
- message: `Clawback of ₹${deductedAmount} for order ${input.orderId} (store ${input.storeId}) could not be applied: ${unrecoverable}. ` +
99
+ message: `Clawback of ${formatMoney(deductedAmount)} for order ${input.orderId} (store ${input.storeId}) could not be applied: ${unrecoverable}. ` +
99
100
  `The seller retains this amount for returned goods; there is no automatic recovery until a seller ledger exists.`,
100
101
  requestId: input.refundId,
101
102
  });
@@ -56,6 +56,7 @@ import { POINTS_EARN_PERCENT, pointsExpiryFrom, } from "../../../../features/poi
56
56
  import { orderTaxableValue } from "../../../shared/checkout/order-math.js";
57
57
  import { normalizeError } from "../../../../errors/normalize.js";
58
58
  import { serverLogger } from "../../../../monitoring/index.js";
59
+ import { formatMoney } from "../../../../utils/number.formatter.js";
59
60
  function roundPoints(value) {
60
61
  return Math.round(value * 100) / 100;
61
62
  }
@@ -160,13 +161,13 @@ export async function awardPurchasePoints(order) {
160
161
  * later — and written once rather than re-derived, so it stays true of this
161
162
  * moment even if the order is edited afterwards.
162
163
  */
163
- description: `${POINTS_EARN_PERCENT}% back on ${order.id}: ₹${moneyPaidForGoods.toFixed(2)} of items` +
164
+ description: `${POINTS_EARN_PERCENT}% back on ${order.id}: ${formatMoney(moneyPaidForGoods)} of items` +
164
165
  // Named explicitly, because an unexplained gap between the order's item
165
166
  // value and the earn base is exactly the kind of figure a buyer writes
166
167
  // in to ask about — and the answer ("you paid part of it with points")
167
168
  // is a fact about their own order that they can check.
168
169
  ((order.pointsApplied ?? 0) > 0
169
- ? ` (₹${goods.toFixed(2)} of items less ${order.pointsApplied} points).`
170
+ ? ` (${formatMoney(goods)} of items less ${order.pointsApplied} points).`
170
171
  : "."),
171
172
  expiresAt: pointsExpiryFrom(now),
172
173
  });
@@ -35,6 +35,7 @@ import { adjudicateClaim, SALE_PROTECTION_MAX_VALUE, } from "../../../shared/fea
35
35
  import { deadlinesFor } from "../../../shared/obligations/deadlines.js";
36
36
  import { paymentCasesRepository } from "../../../../features/payment-cases/repository/payment-cases.repository.js";
37
37
  import { orderGoodsValue } from "../../../shared/checkout/order-math.js";
38
+ import { formatMoney } from "../../../../utils/number.formatter.js";
38
39
  /**
39
40
  * File a claim as the buyer.
40
41
  *
@@ -100,7 +101,7 @@ export async function fileProtectionClaimAction(input) {
100
101
  */
101
102
  const claimedAmount = orderGoodsValue(order);
102
103
  if (kind === "sale" && claimedAmount > SALE_PROTECTION_MAX_VALUE) {
103
- throw new ValidationError(`Sale protection covers orders up to ₹${SALE_PROTECTION_MAX_VALUE.toLocaleString("en-IN")}. This order is above that, so the claim needs a human — please contact support.`);
104
+ throw new ValidationError(`Sale protection covers orders up to ${formatMoney(SALE_PROTECTION_MAX_VALUE)}. This order is above that, so the claim needs a human — please contact support.`);
104
105
  }
105
106
  const claim = await protectionClaimsRepository().file({
106
107
  kind,
@@ -49,6 +49,7 @@ import { applyRefundDeductionAction } from "../payouts/actions.js";
49
49
  import { issueCreditNoteForRefund } from "../invoicing/credit-note.js";
50
50
  import { getAdminDb } from "../../../../providers/db-firebase/index.js";
51
51
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore.js";
52
+ import { formatMoney } from "../../../../utils/number.formatter.js";
52
53
  /**
53
54
  * SB-UNI-N — block the refund if any of the order's digital codes has already
54
55
  * been claimed (revealed). Throws ValidationError on the first claimed code.
@@ -146,9 +147,9 @@ export async function processRefundAction(input) {
146
147
  */
147
148
  const capacity = refundCapacity(order, siteSettings?.commissions?.gstPercent);
148
149
  if (input.amount > capacity.remaining + MONEY_EPSILON) {
149
- throw new ValidationError(`Refund of ₹${input.amount.toFixed(2)} exceeds the ₹${capacity.remaining.toFixed(2)} still refundable on this order` +
150
+ throw new ValidationError(`Refund of ${formatMoney(input.amount)} exceeds the ${formatMoney(capacity.remaining)} still refundable on this order` +
150
151
  (capacity.withheld > 0
151
- ? ` (₹${capacity.withheld.toFixed(2)} platform fee is non-refundable).`
152
+ ? ` (${formatMoney(capacity.withheld)} platform fee is non-refundable).`
152
153
  : "."));
153
154
  }
154
155
  // Guard: non-refundable orders (prize draws, bundles). A property of the
@@ -23,7 +23,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
23
23
  * operator blind to everything else.
24
24
  */
25
25
  import { Fragment } from "react";
26
- import { renderToStaticMarkup } from "react-dom/server";
26
+ // 🛑 Lazy require, not a top-level `react-dom/server` import — this module is
27
+ // reachable from a Server Component via appkit/server, and Next's static tracer
28
+ // fails the build on it. See render-static-markup.ts.
29
+ import { renderStaticMarkup } from "../../../../features/email/render-static-markup.js";
27
30
  import { EmailContainer, EmailDoc, EmailRow, } from "../../../../features/email/primitives.js";
28
31
  import { paymentCasesRepository } from "../../../../features/payment-cases/repository/payment-cases.repository.js";
29
32
  // `adminNotificationsRepository` lives behind the repositories barrel — the
@@ -260,5 +263,5 @@ async function emailStaff(input) {
260
263
  * our own constants and the provider/reason enums.
261
264
  */
262
265
  function renderStaffAlertEmail(title, body) {
263
- return `<!DOCTYPE html>${renderToStaticMarkup(_jsx(EmailDoc, { title: title, children: _jsx(EmailContainer, { children: _jsx(EmailRow, { children: body.split("\n\n").map((para, i) => (_jsxs(Fragment, { children: [i > 0 ? _jsx("br", {}) : null, i > 0 ? _jsx("br", {}) : null, para.split("\n").map((line, j) => (_jsxs(Fragment, { children: [j > 0 ? _jsx("br", {}) : null, line] }, j)))] }, i))) }) }) }))}`;
266
+ return `<!DOCTYPE html>${renderStaticMarkup(_jsx(EmailDoc, { title: title, children: _jsx(EmailContainer, { children: _jsx(EmailRow, { children: body.split("\n\n").map((para, i) => (_jsxs(Fragment, { children: [i > 0 ? _jsx("br", {}) : null, i > 0 ? _jsx("br", {}) : null, para.split("\n").map((line, j) => (_jsxs(Fragment, { children: [j > 0 ? _jsx("br", {}) : null, line] }, j)))] }, i))) }) }) }))}`;
264
267
  }
@@ -8,6 +8,7 @@ import { orderRepository } from "../../../../features/orders/repository/orders.r
8
8
  import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
9
9
  import { reconcileShipping, } from "../../../shared/shipping/quote.js";
10
10
  import { serverLogger } from "../../../../monitoring/index.js";
11
+ import { formatMoney } from "../../../../utils/number.formatter.js";
11
12
  /**
12
13
  * 🛑 IDEMPOTENT, and the guard is `order.shippingReconciliation` being unset.
13
14
  *
@@ -106,8 +107,8 @@ export async function reconcileOrderShipping(orderId, actual) {
106
107
  * never re-derived, so it stays true of the moment it describes even if
107
108
  * the order is edited afterwards.
108
109
  */
109
- description: `Shipping saving on ${orderId}: charged ₹${order.selectedShippingQuote.chargedRate} ` +
110
- `for ${order.selectedShippingQuote.courierName}, shipped ₹${actual.actualRate} ` +
110
+ description: `Shipping saving on ${orderId}: charged ${formatMoney(order.selectedShippingQuote.chargedRate)} ` +
111
+ `for ${order.selectedShippingQuote.courierName}, shipped ${formatMoney(actual.actualRate)} ` +
111
112
  `via ${actual.courierName}.`,
112
113
  });
113
114
  }