|
| 1 | +/** |
| 2 | + * Currency support for budget inputs + cost displays. Stored in localStorage |
| 3 | + * as a single setting that applies app-wide. Defaults to USD. |
| 4 | + * |
| 5 | + * For AI cost previews ($ pricing from providers is always USD), we convert |
| 6 | + * to the user's chosen currency for display only. Exchange rates are coarse |
| 7 | + * hardcoded values — accurate enough for "≈ ₹0.20 per extraction" preview |
| 8 | + * but explicitly not for accounting. |
| 9 | + */ |
| 10 | + |
| 11 | +export interface Currency { |
| 12 | + code: string; |
| 13 | + symbol: string; |
| 14 | + label: string; |
| 15 | + /** Approximate units per 1 USD. Used only for cost-preview display. */ |
| 16 | + per_usd: number; |
| 17 | +} |
| 18 | + |
| 19 | +export const CURRENCIES: Currency[] = [ |
| 20 | + { code: "USD", symbol: "$", label: "US Dollar", per_usd: 1 }, |
| 21 | + { code: "INR", symbol: "₹", label: "Indian Rupee", per_usd: 84 }, |
| 22 | + { code: "EUR", symbol: "€", label: "Euro", per_usd: 0.92 }, |
| 23 | + { code: "GBP", symbol: "£", label: "British Pound", per_usd: 0.79 }, |
| 24 | + { code: "CAD", symbol: "C$", label: "Canadian Dollar", per_usd: 1.36 }, |
| 25 | + { code: "AUD", symbol: "A$", label: "Australian Dollar", per_usd: 1.50 }, |
| 26 | + { code: "AED", symbol: "AED ", label: "UAE Dirham", per_usd: 3.67 }, |
| 27 | + { code: "SGD", symbol: "S$", label: "Singapore Dollar", per_usd: 1.34 }, |
| 28 | + { code: "JPY", symbol: "¥", label: "Japanese Yen", per_usd: 155 }, |
| 29 | + { code: "MXN", symbol: "MX$", label: "Mexican Peso", per_usd: 17.3 }, |
| 30 | + { code: "BRL", symbol: "R$", label: "Brazilian Real", per_usd: 5.7 }, |
| 31 | + { code: "ZAR", symbol: "R", label: "South African Rand", per_usd: 18.2 }, |
| 32 | +]; |
| 33 | + |
| 34 | +const KEY = "ados.currency"; |
| 35 | + |
| 36 | +function safeLocal(): Storage | null { |
| 37 | + if (typeof window === "undefined") return null; |
| 38 | + try { return window.localStorage; } catch { return null; } |
| 39 | +} |
| 40 | + |
| 41 | +export function getCurrencyCode(): string { |
| 42 | + return safeLocal()?.getItem(KEY) || "USD"; |
| 43 | +} |
| 44 | + |
| 45 | +export function setCurrencyCode(code: string): void { |
| 46 | + const s = safeLocal(); |
| 47 | + if (!s) return; |
| 48 | + if (CURRENCIES.find((c) => c.code === code)) { |
| 49 | + try { |
| 50 | + s.setItem(KEY, code); |
| 51 | + window.dispatchEvent(new Event("ados:currency-changed")); |
| 52 | + } catch {} |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +export function getCurrency(): Currency { |
| 57 | + const code = getCurrencyCode(); |
| 58 | + return CURRENCIES.find((c) => c.code === code) ?? CURRENCIES[0]; |
| 59 | +} |
| 60 | + |
| 61 | +/** Format an amount in the user's currency. For amounts originally in USD |
| 62 | + * (e.g. AI cost estimates), set `fromUsd` true to convert via rate. For |
| 63 | + * user-entered budgets already in the local currency, leave `fromUsd` false. */ |
| 64 | +export function formatMoney(amount: number, opts: { fromUsd?: boolean; decimals?: number } = {}): string { |
| 65 | + const cur = getCurrency(); |
| 66 | + const converted = opts.fromUsd ? amount * cur.per_usd : amount; |
| 67 | + const decimals = opts.decimals ?? (converted < 1 ? 4 : converted < 100 ? 2 : 0); |
| 68 | + // Use Intl.NumberFormat where available for proper locale grouping. |
| 69 | + let body: string; |
| 70 | + try { |
| 71 | + body = new Intl.NumberFormat(undefined, { |
| 72 | + minimumFractionDigits: decimals, |
| 73 | + maximumFractionDigits: decimals, |
| 74 | + }).format(converted); |
| 75 | + } catch { |
| 76 | + body = converted.toFixed(decimals); |
| 77 | + } |
| 78 | + return `${cur.symbol}${body}`; |
| 79 | +} |
| 80 | + |
| 81 | +/** Strip currency symbols/groupings from a user-typed string so we can parse a |
| 82 | + * numeric value for storage. Handles "₹5,00,000", "$1,000", "1 lakh" loosely. */ |
| 83 | +export function parseMoneyInput(raw: string): number { |
| 84 | + if (!raw) return 0; |
| 85 | + // Replace common Indian shorthand |
| 86 | + let v = raw.toLowerCase().trim(); |
| 87 | + const lakh = /([\d.]+)\s*lakh/; |
| 88 | + const crore = /([\d.]+)\s*crore/; |
| 89 | + if (crore.test(v)) { |
| 90 | + const m = v.match(crore); |
| 91 | + return m ? Number(m[1]) * 10_000_000 : 0; |
| 92 | + } |
| 93 | + if (lakh.test(v)) { |
| 94 | + const m = v.match(lakh); |
| 95 | + return m ? Number(m[1]) * 100_000 : 0; |
| 96 | + } |
| 97 | + // Strip non-numeric chars except dot |
| 98 | + const cleaned = v.replace(/[^\d.]/g, ""); |
| 99 | + return Number(cleaned) || 0; |
| 100 | +} |
0 commit comments