Skip to content

Commit f4e1ea7

Browse files
AdminCustomersPage: USD default + INR toggle for founder MRR display
Track B (PR #45) shipped MRR formatted as INR because that's the wire format Track A returns (paise). Founder convention for SaaS dashboards is USD — switch the default and add an INR toggle for India context. - New src/lib/currency.ts with formatINR / formatUSD / formatMoney + resolveInrToUsd. Static rate constant (INR_TO_USD = 0.012, May 2026), override via VITE_INR_TO_USD at build time. Inline comments note this is directional signal only; daily-refreshed FX is a follow-up. - AdminCustomersPage gains a [USD] [INR] toggle in the header, persists the choice in localStorage (instant.admin.currency), defaults to USD, threads the chosen currency into CustomerDetailDrawer. - Tooltip on the toggle documents the static rate ("1₹ = $0.012; for directional comparison only"). - 11 new tests (34 total in the file, 376 across the project, 0 fail): default render USD/$, toggle INR/₹, localStorage persists across unmount, drawer inherits choice, lib helpers + resolveInrToUsd parser rules. The override flow is asserted at the parser unit because Vite statically inlines import.meta.env.VITE_* at transform time and the module-load constant can't be re-driven from a test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d6609a commit f4e1ea7

4 files changed

Lines changed: 397 additions & 30 deletions

File tree

src/components/CustomerDetailDrawer.tsx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
AdminCustomerDetailResponse,
2525
AdminCustomerSummary,
2626
} from '../api/types'
27+
import { type CurrencyCode, DEFAULT_CURRENCY, formatMoney } from '../lib/currency'
2728
import { EnvPill, RelTime, TierPill } from './Common'
2829
import { IssuePromoModal } from './IssuePromoModal'
2930
import { TierChangeModal } from './TierChangeModal'
@@ -32,20 +33,12 @@ type Tab = 'overview' | 'resources' | 'activity' | 'promos'
3233

3334
interface Props {
3435
summary: AdminCustomerSummary
36+
/** Display currency for MRR fields — controlled by the page-level
37+
* toggle. Defaults to USD when omitted (founder convention). */
38+
currency?: CurrencyCode
3539
onClose: () => void
3640
}
3741

38-
function formatINR(amount?: number | null): string {
39-
if (amount == null || !Number.isFinite(amount) || amount === 0) return '—'
40-
// Track A returns amounts in paise. Render in INR with locale grouping.
41-
const rupees = amount / 100
42-
return new Intl.NumberFormat('en-IN', {
43-
style: 'currency',
44-
currency: 'INR',
45-
maximumFractionDigits: 0,
46-
}).format(rupees)
47-
}
48-
4942
export function formatBytes(b: number | null | undefined): string {
5043
if (b == null || !Number.isFinite(b) || b <= 0) return '0 B'
5144
const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -59,7 +52,11 @@ export function formatBytes(b: number | null | undefined): string {
5952
return `${v.toFixed(digits)} ${units[i]}`
6053
}
6154

62-
export function CustomerDetailDrawer({ summary, onClose }: Props) {
55+
export function CustomerDetailDrawer({
56+
summary,
57+
currency = DEFAULT_CURRENCY,
58+
onClose,
59+
}: Props) {
6360
const [tab, setTab] = useState<Tab>('overview')
6461
const [detail, setDetail] = useState<AdminCustomerDetailResponse | null>(null)
6562
const [loading, setLoading] = useState(true)
@@ -252,7 +249,7 @@ export function CustomerDetailDrawer({ summary, onClose }: Props) {
252249
)}
253250

254251
{!loading && !error && detail && tab === 'overview' && (
255-
<OverviewTab summary={summary} detail={detail} />
252+
<OverviewTab summary={summary} detail={detail} currency={currency} />
256253
)}
257254
{!loading && !error && detail && tab === 'resources' && (
258255
<ResourcesTab detail={detail} />
@@ -298,9 +295,11 @@ export function CustomerDetailDrawer({ summary, onClose }: Props) {
298295
function OverviewTab({
299296
summary,
300297
detail,
298+
currency,
301299
}: {
302300
summary: AdminCustomerSummary
303301
detail: AdminCustomerDetailResponse
302+
currency: CurrencyCode
304303
}) {
305304
const rows: Array<[string, React.ReactNode]> = [
306305
['Email', summary.primary_email],
@@ -317,9 +316,11 @@ function OverviewTab({
317316
),
318317
],
319318
[
320-
'MRR (monthly)',
321-
formatINR(summary.mrr_monthly) +
322-
(summary.mrr_yearly > 0 ? ` · yearly ${formatINR(summary.mrr_yearly)}` : ''),
319+
`MRR (monthly, ${currency})`,
320+
formatMoney(summary.mrr_monthly, currency) +
321+
(summary.mrr_yearly > 0
322+
? ` · yearly ${formatMoney(summary.mrr_yearly, currency)}`
323+
: ''),
323324
],
324325
['Last active', summary.last_active ? <RelTime at={summary.last_active} /> : '—'],
325326
[
@@ -361,7 +362,14 @@ function OverviewTab({
361362
<dt className="dim" style={{ fontWeight: 500 }}>
362363
{k}
363364
</dt>
364-
<dd style={{ margin: 0 }}>{v}</dd>
365+
<dd
366+
style={{ margin: 0 }}
367+
data-testid={
368+
typeof k === 'string' && k.startsWith('MRR') ? 'drawer-mrr' : undefined
369+
}
370+
>
371+
{v}
372+
</dd>
365373
</div>
366374
))}
367375
</dl>

src/lib/currency.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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

Comments
 (0)