/*********************************************************************************************************************************************************************** * File Name: currency.util.js * Type of Program: Utility * Description: Currency formatting and resolution helpers for backend use (emails, receipts, notifications). * * All format functions accept an optional options object: { locale } * locale — BCP 47 tag, defaults to 'en-US' * * USD is the platform's base/canonical currency. Plans may carry localized price * overrides (plan_prices table). resolvePrice() applies the COALESCE logic: * localized override wins → falls back to plan's base price + currency. ***********************************************************************************************************************************************************************/ 'use strict'; // ─── Supported currencies ───────────────────────────────────────────────────── const SUPPORTED_CURRENCIES = [ { code: 'USD', name: 'US Dollar', symbol: '$' }, { code: 'EUR', name: 'Euro', symbol: '€' }, { code: 'GBP', name: 'British Pound', symbol: '£' }, { code: 'CNY', name: 'Chinese Yuan', symbol: '¥' }, { code: 'JPY', name: 'Japanese Yen', symbol: '¥' }, { code: 'PHP', name: 'Philippine Peso', symbol: '₱' }, { code: 'KRW', name: 'South Korean Won', symbol: '₩' }, { code: 'AUD', name: 'Australian Dollar', symbol: 'A$' }, { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }, { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' }, { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$'}, { code: 'INR', name: 'Indian Rupee', symbol: '₹' }, { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' }, { code: 'THB', name: 'Thai Baht', symbol: '฿' }, { code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' }, { code: 'TWD', name: 'Taiwan Dollar', symbol: 'NT$'}, { code: 'VND', name: 'Vietnamese Dong', symbol: '₫' }, ]; const SUPPORTED_CURRENCY_CODES = new Set(SUPPORTED_CURRENCIES.map((c) => c.code)); function isSupported(code) { return SUPPORTED_CURRENCY_CODES.has(code?.toUpperCase()); } // ─── Formatting ─────────────────────────────────────────────────────────────── /** "¥1,299.00" / "$9.99" */ function fmtCurrency(amount, currency = 'USD', { locale = 'en-US' } = {}) { if (amount === null || amount === undefined) return '—'; return new Intl.NumberFormat(locale, { style: 'currency', currency: currency ?? 'USD', minimumFractionDigits: 2, }).format(Number(amount)); } // ─── Price resolution ───────────────────────────────────────────────────────── /** * Returns the effective { price, currency } for a plan given a user's preferred currency. * plan.prices must be eager-loaded (as: 'prices') for the override to be considered. * Falls back to plan.price + plan.currency when no override exists. */ function resolvePrice(plan, preferredCurrency) { if (!preferredCurrency || preferredCurrency === plan.currency) return { price: Number(plan.price), currency: plan.currency }; const override = (plan.prices ?? []).find((p) => p.currency === preferredCurrency); if (override) return { price: Number(override.price), currency: override.currency }; return { price: Number(plan.price), currency: plan.currency }; } // ─── Exchange rate fetching ─────────────────────────────────────────────────── // Uses frankfurter.app (ECB-backed, no API key, free). // In-process cache with 1-hour TTL avoids hammering the API on every save. const _rateCache = new Map(); async function fetchExchangeRate(from, to) { if (from === to) return 1; const key = `${from}:${to}`; const now = Date.now(); const cached = _rateCache.get(key); if (cached && cached.expiresAt > now) return cached.rate; try { const res = await fetch( `https://api.frankfurter.app/latest?from=${from}&to=${to}`, { signal: AbortSignal.timeout(4000) }, ); if (!res.ok) return null; const json = await res.json(); const rate = json?.rates?.[to]; if (!rate) return null; _rateCache.set(key, { rate, expiresAt: now + 60 * 60 * 1000 }); // 1 h TTL return rate; } catch { return null; } } // ─── Localized price validation ─────────────────────────────────────────────── // Three zones relative to the market-rate conversion of the base price: // // pass → 85 % – 150 % of expected (green, saves normally) // warn → 70 % – 85 % or 150 % – 300 % (saves with caution message) // block → < 70 % or > 300 % (rejected — too far from market rate) // // If the exchange-rate API is unavailable the check is skipped (returns 'pass'). const PRICE_ZONES = { LOWER_HARD: 0.70, LOWER_WARN: 0.85, UPPER_WARN: 1.50, UPPER_HARD: 3.00, }; async function validateLocalizedPrice(basePrice, baseCurrency, localizedPrice, targetCurrency) { const rate = await fetchExchangeRate(baseCurrency, targetCurrency); if (!rate) return { zone: 'pass', skipped: true }; const expected = Number(basePrice) * rate; const entered = Number(localizedPrice); const { LOWER_HARD, LOWER_WARN, UPPER_WARN, UPPER_HARD } = PRICE_ZONES; const hardMin = expected * LOWER_HARD; const hardMax = expected * UPPER_HARD; const warnMin = expected * LOWER_WARN; const warnMax = expected * UPPER_WARN; const fmt = (n) => n.toFixed(2); const rateStr = `1 ${baseCurrency} = ${rate} ${targetCurrency}`; if (entered < hardMin || entered > hardMax) { return { zone: 'block', expected: fmt(expected), hardMin: fmt(hardMin), hardMax: fmt(hardMax), warnMin: fmt(warnMin), warnMax: fmt(warnMax), message: `${fmt(entered)} ${targetCurrency} is too far from the current market rate (${rateStr}). ` + `Acceptable range: ${fmt(hardMin)} – ${fmt(hardMax)} ${targetCurrency}.`, }; } if (entered < warnMin || entered > warnMax) { return { zone: 'warn', expected: fmt(expected), hardMin: fmt(hardMin), hardMax: fmt(hardMax), warnMin: fmt(warnMin), warnMax: fmt(warnMax), message: `${fmt(entered)} ${targetCurrency} is outside the suggested range (${rateStr}). ` + `Suggested: ${fmt(warnMin)} – ${fmt(warnMax)} ${targetCurrency}. Saved with caution.`, }; } return { zone: 'pass', expected: fmt(expected), hardMin: fmt(hardMin), hardMax: fmt(hardMax), warnMin: fmt(warnMin), warnMax: fmt(warnMax), }; } module.exports = { SUPPORTED_CURRENCIES, isSupported, fmtCurrency, resolvePrice, fetchExchangeRate, validateLocalizedPrice, PRICE_ZONES, };