mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
172 lines
7.1 KiB
JavaScript
172 lines
7.1 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
||
* 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,
|
||
};
|