SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
4.8 KB · 124 lines typescript
Raw Blame History
1/**2 * Currency handling. Prices are stored as integer minor units? No — collectibles span3 * cents to eight figures and many sources report fractional amounts; we use NUMERIC(18,4)4 * in the database and JS numbers at the edges. Never mix currencies without an FX row.5 */6export const SUPPORTED_CURRENCIES = [7  'USD', 'CAD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD', 'HKD', 'SGD', 'CNY', 'KRW', 'SEK', 'NOK', 'DKK', 'NZD', 'MXN', 'BRL', 'INR', 'PLN', 'CZK', 'TWD', 'THB', 'AED',8] as const;9export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number];1011export const DISPLAY_CURRENCIES: CurrencyCode[] = ['USD', 'CAD', 'EUR', 'GBP', 'JPY'];1213const SYMBOL_MAP: Array<[RegExp, CurrencyCode]> = [14  [/\bUS\s?\$|\bUSD\b|U\.S\.\s?\$/i, 'USD'],15  [/\bCA\s?\$|\bC\$|\bCAD\b|CDN\s?\$/i, 'CAD'],16  [/\bAU\s?\$|\bA\$|\bAUD\b/i, 'AUD'],17  [/\bNZ\s?\$|\bNZD\b/i, 'NZD'],18  [/\bHK\s?\$|\bHKD\b/i, 'HKD'],19  [/\bS\$|\bSGD\b/i, 'SGD'],20  [/\bMX\s?\$|\bMXN\b/i, 'MXN'],21  [/€|\bEUR\b/i, 'EUR'],22  [/£|\bGBP\b/i, 'GBP'],23  [/¥|¥|\bJPY\b/i, 'JPY'],24  [/\bCHF\b|\bFr\.\s?\d/i, 'CHF'],25  [/\bCNY\b|\bRMB\b/i, 'CNY'],26  [/₩|\bKRW\b/i, 'KRW'],27  [/\bSEK\b|\bkr\b/i, 'SEK'],28  [/\bNOK\b/i, 'NOK'],29  [/\bDKK\b/i, 'DKK'],30  [/\bAED\b/i, 'AED'],31  [/\bINR\b|₹/i, 'INR'],32  [/\bBRL\b|R\$/i, 'BRL'],33  [/\bPLN\b|zł/i, 'PLN'],34  [/\bCZK\b|Kč/i, 'CZK'],35  [/\bTWD\b|NT\$/i, 'TWD'],36  [/\bTHB\b|฿/i, 'THB'],37];3839export interface ParsedPrice {40  amount: number;41  currency: CurrencyCode | null;42  /** 0–1 confidence that the parse is correct */43  confidence: number;44}4546/**47 * Parse a human-formatted price. Handles "$1,234.56", "1.234,56 €", "£12", "¥15,000", "US $2,500.00",48 * "CA$1 200,00", "1 234,56 EUR", "2.500" (ambiguous → treated as thousands separator when 3 trailing digits).49 * Returns null when no number is found. Never guesses a currency when none is present50 * (caller supplies `defaultCurrency` if the source is single-currency).51 */52export function parsePrice(raw: string | null | undefined, defaultCurrency?: CurrencyCode): ParsedPrice | null {53  if (!raw) return null;54  const text = raw.replace(/ /g, ' ').trim();55  if (!text) return null;5657  let currency: CurrencyCode | null = null;58  for (const [re, code] of SYMBOL_MAP) {59    if (re.test(text)) {60      currency = code;61      break;62    }63  }64  // Bare "$" defaults to USD only when caller allows via defaultCurrency or nothing else matched.65  if (!currency && /\$/.test(text)) currency = defaultCurrency ?? 'USD';66  if (!currency) currency = defaultCurrency ?? null;6768  const m = text.match(/-?\d[\d\s.,']*\d|\d/);69  if (!m) return null;70  let num = m[0].replace(/[\s']/g, '');71  const negative = num.startsWith('-');72  num = num.replace('-', '');7374  const lastComma = num.lastIndexOf(',');75  const lastDot = num.lastIndexOf('.');76  let confidence = 0.95;77  if (lastComma >= 0 && lastDot >= 0) {78    // Whichever separator is last is the decimal separator.79    if (lastComma > lastDot) num = num.replace(/\./g, '').replace(',', '.');80    else num = num.replace(/,/g, '');81  } else if (lastComma >= 0) {82    const tail = num.length - lastComma - 1;83    if (tail === 3 && (num.match(/,/g)?.length ?? 0) >= 1 && !/^\d{1,3},\d{3}$/.test(num)) {84      num = num.replace(/,/g, '');85    } else if (tail === 3) {86      // "2,500" → ambiguous; thousands in EN sources, decimal in some EU sources. Prefer thousands.87      num = num.replace(/,/g, '');88      confidence = 0.8;89    } else {90      num = num.replace(',', '.');91    }92  } else if (lastDot >= 0) {93    const tail = num.length - lastDot - 1;94    const dots = num.match(/\./g)?.length ?? 0;95    if (dots > 1 || (tail === 3 && currency === 'EUR')) {96      num = num.replace(/\./g, '');97      confidence = dots > 1 ? 0.9 : 0.7;98    }99  }100  const amount = Number.parseFloat(num);101  if (!Number.isFinite(amount)) return null;102  if (currency === 'JPY' || currency === 'KRW') confidence = Math.min(confidence, 0.9);103  return { amount: negative ? -amount : amount, currency, confidence };104}105106export function formatMoney(amount: number | null | undefined, currency: CurrencyCode = 'USD', opts: { compact?: boolean; maximumFractionDigits?: number } = {}): string {107  if (amount === null || amount === undefined || !Number.isFinite(amount)) return '—';108  const zeroDecimal = currency === 'JPY' || currency === 'KRW';109  const fmt = new Intl.NumberFormat('en-US', {110    style: 'currency',111    currency,112    notation: opts.compact ? 'compact' : 'standard',113    maximumFractionDigits: opts.maximumFractionDigits ?? (zeroDecimal ? 0 : amount >= 1000 ? 0 : 2),114    minimumFractionDigits: zeroDecimal || amount >= 1000 || opts.compact ? 0 : 2,115  });116  return fmt.format(amount);117}118119export function formatPct(value: number | null | undefined, digits = 2): string {120  if (value === null || value === undefined || !Number.isFinite(value)) return '—';121  const sign = value > 0 ? '+' : '';122  return `${sign}${(value * 100).toFixed(digits)}%`;123}124