/** * Currency handling. Prices are stored as integer minor units? No — collectibles span * cents to eight figures and many sources report fractional amounts; we use NUMERIC(18,4) * in the database and JS numbers at the edges. Never mix currencies without an FX row. */ export const SUPPORTED_CURRENCIES = [ 'USD', 'CAD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD', 'HKD', 'SGD', 'CNY', 'KRW', 'SEK', 'NOK', 'DKK', 'NZD', 'MXN', 'BRL', 'INR', 'PLN', 'CZK', 'TWD', 'THB', 'AED', ] as const; export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]; export const DISPLAY_CURRENCIES: CurrencyCode[] = ['USD', 'CAD', 'EUR', 'GBP', 'JPY']; const SYMBOL_MAP: Array<[RegExp, CurrencyCode]> = [ [/\bUS\s?\$|\bUSD\b|U\.S\.\s?\$/i, 'USD'], [/\bCA\s?\$|\bC\$|\bCAD\b|CDN\s?\$/i, 'CAD'], [/\bAU\s?\$|\bA\$|\bAUD\b/i, 'AUD'], [/\bNZ\s?\$|\bNZD\b/i, 'NZD'], [/\bHK\s?\$|\bHKD\b/i, 'HKD'], [/\bS\$|\bSGD\b/i, 'SGD'], [/\bMX\s?\$|\bMXN\b/i, 'MXN'], [/€|\bEUR\b/i, 'EUR'], [/£|\bGBP\b/i, 'GBP'], [/¥|¥|\bJPY\b/i, 'JPY'], [/\bCHF\b|\bFr\.\s?\d/i, 'CHF'], [/\bCNY\b|\bRMB\b/i, 'CNY'], [/₩|\bKRW\b/i, 'KRW'], [/\bSEK\b|\bkr\b/i, 'SEK'], [/\bNOK\b/i, 'NOK'], [/\bDKK\b/i, 'DKK'], [/\bAED\b/i, 'AED'], [/\bINR\b|₹/i, 'INR'], [/\bBRL\b|R\$/i, 'BRL'], [/\bPLN\b|zł/i, 'PLN'], [/\bCZK\b|Kč/i, 'CZK'], [/\bTWD\b|NT\$/i, 'TWD'], [/\bTHB\b|฿/i, 'THB'], ]; export interface ParsedPrice { amount: number; currency: CurrencyCode | null; /** 0–1 confidence that the parse is correct */ confidence: number; } /** * Parse a human-formatted price. Handles "$1,234.56", "1.234,56 €", "£12", "¥15,000", "US $2,500.00", * "CA$1 200,00", "1 234,56 EUR", "2.500" (ambiguous → treated as thousands separator when 3 trailing digits). * Returns null when no number is found. Never guesses a currency when none is present * (caller supplies `defaultCurrency` if the source is single-currency). */ export function parsePrice(raw: string | null | undefined, defaultCurrency?: CurrencyCode): ParsedPrice | null { if (!raw) return null; const text = raw.replace(/ /g, ' ').trim(); if (!text) return null; let currency: CurrencyCode | null = null; for (const [re, code] of SYMBOL_MAP) { if (re.test(text)) { currency = code; break; } } // Bare "$" defaults to USD only when caller allows via defaultCurrency or nothing else matched. if (!currency && /\$/.test(text)) currency = defaultCurrency ?? 'USD'; if (!currency) currency = defaultCurrency ?? null; const m = text.match(/-?\d[\d\s.,']*\d|\d/); if (!m) return null; let num = m[0].replace(/[\s']/g, ''); const negative = num.startsWith('-'); num = num.replace('-', ''); const lastComma = num.lastIndexOf(','); const lastDot = num.lastIndexOf('.'); let confidence = 0.95; if (lastComma >= 0 && lastDot >= 0) { // Whichever separator is last is the decimal separator. if (lastComma > lastDot) num = num.replace(/\./g, '').replace(',', '.'); else num = num.replace(/,/g, ''); } else if (lastComma >= 0) { const tail = num.length - lastComma - 1; if (tail === 3 && (num.match(/,/g)?.length ?? 0) >= 1 && !/^\d{1,3},\d{3}$/.test(num)) { num = num.replace(/,/g, ''); } else if (tail === 3) { // "2,500" → ambiguous; thousands in EN sources, decimal in some EU sources. Prefer thousands. num = num.replace(/,/g, ''); confidence = 0.8; } else { num = num.replace(',', '.'); } } else if (lastDot >= 0) { const tail = num.length - lastDot - 1; const dots = num.match(/\./g)?.length ?? 0; if (dots > 1 || (tail === 3 && currency === 'EUR')) { num = num.replace(/\./g, ''); confidence = dots > 1 ? 0.9 : 0.7; } } const amount = Number.parseFloat(num); if (!Number.isFinite(amount)) return null; if (currency === 'JPY' || currency === 'KRW') confidence = Math.min(confidence, 0.9); return { amount: negative ? -amount : amount, currency, confidence }; } export function formatMoney(amount: number | null | undefined, currency: CurrencyCode = 'USD', opts: { compact?: boolean; maximumFractionDigits?: number } = {}): string { if (amount === null || amount === undefined || !Number.isFinite(amount)) return '—'; const zeroDecimal = currency === 'JPY' || currency === 'KRW'; const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency, notation: opts.compact ? 'compact' : 'standard', maximumFractionDigits: opts.maximumFractionDigits ?? (zeroDecimal ? 0 : amount >= 1000 ? 0 : 2), minimumFractionDigits: zeroDecimal || amount >= 1000 || opts.compact ? 0 : 2, }); return fmt.format(amount); } export function formatPct(value: number | null | undefined, digits = 2): string { if (value === null || value === undefined || !Number.isFinite(value)) return '—'; const sign = value > 0 ? '+' : ''; return `${sign}${(value * 100).toFixed(digits)}%`; }