|
| 1 | +// currency.ts — money formatting helpers for the founder admin console. |
| 2 | +// |
| 3 | +// Background: Track A's agent API returns MRR fields (`mrr_monthly`, |
| 4 | +// `mrr_yearly`) as integers in INR **paise** — i.e. ₹1 = 100 paise. This |
| 5 | +// matches Razorpay's wire format. Track B's first cut (PR #45) rendered |
| 6 | +// everything as INR because that's what the data was, but the founder |
| 7 | +// convention for SaaS dashboards is USD. Track H makes USD the default |
| 8 | +// and exposes an INR toggle for India-context glance. |
| 9 | +// |
| 10 | +// Conversion strategy: |
| 11 | +// - We use a **static** INR→USD rate baked in at build time. This is |
| 12 | +// fine for a founder-internal MRR view because the operator only |
| 13 | +// needs **directional signal** ("which customer is biggest, is MRR |
| 14 | +// growing"), not penny-accurate FX. |
| 15 | +// - `VITE_INR_TO_USD` lets ops override the rate at build/deploy time |
| 16 | +// if the static default drifts too far from reality. |
| 17 | +// - For penny-accurate, daily-refreshed FX, fetch from |
| 18 | +// openexchangerates.org (or a similar provider) and cache server-side. |
| 19 | +// That's a follow-up only if conversion accuracy ever matters — for a |
| 20 | +// founder-internal dashboard it shouldn't. |
| 21 | +// |
| 22 | +// Helpers: |
| 23 | +// - `formatINR(paise)` — ₹-prefixed, en-IN locale grouping (lakh/crore). |
| 24 | +// - `formatUSD(paise)` — $-prefixed, en-US grouping, paise→USD via rate. |
| 25 | +// - `formatMoney(paise, currency)` — generic switch; this is what |
| 26 | +// callsites use so the toggle reads cleanly. |
| 27 | + |
| 28 | +/** |
| 29 | + * Static fallback exchange rate for INR → USD. |
| 30 | + * |
| 31 | + * As of 2026-05 the spot rate hovers around 1 INR ≈ $0.012 USD. This is |
| 32 | + * intentionally a constant — see file header for the "static is fine" |
| 33 | + * argument. Set `VITE_INR_TO_USD` in the build env to override. |
| 34 | + */ |
| 35 | +export const INR_TO_USD = 0.012 |
| 36 | + |
| 37 | +/** |
| 38 | + * Resolves an INR→USD rate from a raw env-style value with sensible |
| 39 | + * fallback semantics: |
| 40 | + * - missing / empty → static {@link INR_TO_USD} |
| 41 | + * - non-numeric → static {@link INR_TO_USD} |
| 42 | + * - zero / negative → static {@link INR_TO_USD} |
| 43 | + * - positive number → that value |
| 44 | + * |
| 45 | + * Exported so tests can exercise the parsing rules without fighting |
| 46 | + * Vite's compile-time `import.meta.env` substitution. |
| 47 | + */ |
| 48 | +export function resolveInrToUsd(raw: unknown): number { |
| 49 | + if (raw == null || raw === '') return INR_TO_USD |
| 50 | + const n = Number(raw) |
| 51 | + if (!Number.isFinite(n) || n <= 0) return INR_TO_USD |
| 52 | + return n |
| 53 | +} |
| 54 | + |
| 55 | +/** |
| 56 | + * Resolved rate at module load time. Reads `VITE_INR_TO_USD` from Vite's |
| 57 | + * inlined env; falls back to {@link INR_TO_USD} when unset or unparseable. |
| 58 | + * |
| 59 | + * Vite replaces `import.meta.env.VITE_*` at build time, so this is |
| 60 | + * effectively a compile-time constant per build — no runtime cost. The |
| 61 | + * downside is that tests can't override it after the module has been |
| 62 | + * transformed; use {@link resolveInrToUsd} directly to verify the parser. |
| 63 | + */ |
| 64 | +export const ACTIVE_INR_TO_USD = resolveInrToUsd( |
| 65 | + typeof import.meta !== 'undefined' |
| 66 | + ? (import.meta as ImportMeta).env?.VITE_INR_TO_USD |
| 67 | + : undefined, |
| 68 | +) |
| 69 | + |
| 70 | +export type CurrencyCode = 'USD' | 'INR' |
| 71 | + |
| 72 | +/** localStorage key for the founder console's currency preference. */ |
| 73 | +export const CURRENCY_STORAGE_KEY = 'instant.admin.currency' |
| 74 | + |
| 75 | +/** Default currency for the admin console — founder convention is USD. */ |
| 76 | +export const DEFAULT_CURRENCY: CurrencyCode = 'USD' |
| 77 | + |
| 78 | +/** |
| 79 | + * Format a paise amount as INR — e.g. 490000 paise → "₹4,900". |
| 80 | + * Returns an em dash for zero / non-finite so blank cells don't read as |
| 81 | + * "₹0" (zero is real signal: this customer is not paying us anything). |
| 82 | + */ |
| 83 | +export function formatINR(paise: number | null | undefined): string { |
| 84 | + if (paise == null || !Number.isFinite(paise) || paise === 0) return '—' |
| 85 | + const rupees = paise / 100 |
| 86 | + return new Intl.NumberFormat('en-IN', { |
| 87 | + style: 'currency', |
| 88 | + currency: 'INR', |
| 89 | + maximumFractionDigits: 0, |
| 90 | + }).format(rupees) |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * Format a paise amount as USD — e.g. 490000 paise → "$58.80". |
| 95 | + * paise → rupees (÷100) → USD (× {@link ACTIVE_INR_TO_USD}). Returns em |
| 96 | + * dash for zero / non-finite, same as {@link formatINR}. |
| 97 | + * |
| 98 | + * We render two decimals for USD because the converted values are |
| 99 | + * typically small (under $200/mo for hobby+pro), and rounding to whole |
| 100 | + * dollars throws away meaningful precision. |
| 101 | + */ |
| 102 | +export function formatUSD(paise: number | null | undefined): string { |
| 103 | + if (paise == null || !Number.isFinite(paise) || paise === 0) return '—' |
| 104 | + const rupees = paise / 100 |
| 105 | + const usd = rupees * ACTIVE_INR_TO_USD |
| 106 | + return new Intl.NumberFormat('en-US', { |
| 107 | + style: 'currency', |
| 108 | + currency: 'USD', |
| 109 | + maximumFractionDigits: 2, |
| 110 | + }).format(usd) |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * Generic dispatcher used by callsites that toggle between currencies. |
| 115 | + * Keeping the switch here means the page/drawer just hand off `currency` |
| 116 | + * and never branch on it themselves. |
| 117 | + */ |
| 118 | +export function formatMoney( |
| 119 | + paise: number | null | undefined, |
| 120 | + currency: CurrencyCode, |
| 121 | +): string { |
| 122 | + return currency === 'USD' ? formatUSD(paise) : formatINR(paise) |
| 123 | +} |
| 124 | + |
| 125 | +/** |
| 126 | + * Read the persisted currency choice. Falls back to {@link DEFAULT_CURRENCY} |
| 127 | + * when nothing is stored or the value is corrupt. Safe to call during |
| 128 | + * useState initialisation — guards SSR / no-`window` environments. |
| 129 | + */ |
| 130 | +export function readStoredCurrency(): CurrencyCode { |
| 131 | + if (typeof window === 'undefined' || !window.localStorage) { |
| 132 | + return DEFAULT_CURRENCY |
| 133 | + } |
| 134 | + try { |
| 135 | + const v = window.localStorage.getItem(CURRENCY_STORAGE_KEY) |
| 136 | + if (v === 'USD' || v === 'INR') return v |
| 137 | + } catch { |
| 138 | + // localStorage can throw (private mode, quota). Silent fallback is |
| 139 | + // fine — the toggle still works in-memory for this session. |
| 140 | + } |
| 141 | + return DEFAULT_CURRENCY |
| 142 | +} |
| 143 | + |
| 144 | +/** Persist the currency choice. Silent on failure (see {@link readStoredCurrency}). */ |
| 145 | +export function writeStoredCurrency(c: CurrencyCode): void { |
| 146 | + if (typeof window === 'undefined' || !window.localStorage) return |
| 147 | + try { |
| 148 | + window.localStorage.setItem(CURRENCY_STORAGE_KEY, c) |
| 149 | + } catch { |
| 150 | + // Ignore — see readStoredCurrency. |
| 151 | + } |
| 152 | +} |
0 commit comments