/** * ISO 4217 Currency utilities for EN16931 compliance * Provides currency-aware rounding and decimal handling */ /** * ISO 4217 Currency minor units (decimal places) * Based on ISO 4217:2015 standard * * Most currencies use 2 decimal places, but there are exceptions: * - 0 decimals: JPY, KRW, CLP, etc. * - 3 decimals: BHD, IQD, JOD, KWD, OMR, TND * - 4 decimals: CLF (Chilean Unit of Account) */ export const ISO4217MinorUnits: Record = { // Major currencies 'EUR': 2, // Euro 'USD': 2, // US Dollar 'GBP': 2, // British Pound 'CHF': 2, // Swiss Franc 'CAD': 2, // Canadian Dollar 'AUD': 2, // Australian Dollar 'NZD': 2, // New Zealand Dollar 'CNY': 2, // Chinese Yuan 'INR': 2, // Indian Rupee 'MXN': 2, // Mexican Peso 'BRL': 2, // Brazilian Real 'RUB': 2, // Russian Ruble 'ZAR': 2, // South African Rand 'SGD': 2, // Singapore Dollar 'HKD': 2, // Hong Kong Dollar 'NOK': 2, // Norwegian Krone 'SEK': 2, // Swedish Krona 'DKK': 2, // Danish Krone 'PLN': 2, // Polish Zloty 'CZK': 2, // Czech Koruna 'HUF': 2, // Hungarian Forint (technically 2, though often shown as 0) 'RON': 2, // Romanian Leu 'BGN': 2, // Bulgarian Lev 'HRK': 2, // Croatian Kuna 'TRY': 2, // Turkish Lira 'ISK': 0, // Icelandic Króna (0 decimals) // Zero decimal currencies 'JPY': 0, // Japanese Yen 'KRW': 0, // South Korean Won 'CLP': 0, // Chilean Peso 'PYG': 0, // Paraguayan Guaraní 'RWF': 0, // Rwandan Franc 'VND': 0, // Vietnamese Dong 'XAF': 0, // CFA Franc BEAC 'XOF': 0, // CFA Franc BCEAO 'XPF': 0, // CFP Franc 'BIF': 0, // Burundian Franc 'DJF': 0, // Djiboutian Franc 'GNF': 0, // Guinean Franc 'KMF': 0, // Comorian Franc 'MGA': 0, // Malagasy Ariary 'UGX': 0, // Ugandan Shilling 'VUV': 0, // Vanuatu Vatu // Three decimal currencies 'BHD': 3, // Bahraini Dinar 'IQD': 3, // Iraqi Dinar 'JOD': 3, // Jordanian Dinar 'KWD': 3, // Kuwaiti Dinar 'LYD': 3, // Libyan Dinar 'OMR': 3, // Omani Rial 'TND': 3, // Tunisian Dinar // Four decimal currencies 'CLF': 4, // Chilean Unit of Account (UF) 'UYW': 4, // Unidad Previsional (Uruguay) }; /** * Rounding modes for currency calculations */ export enum RoundingMode { HALF_UP = 'HALF_UP', // Round half values up (0.5 → 1, -0.5 → -1) HALF_DOWN = 'HALF_DOWN', // Round half values down (0.5 → 0, -0.5 → 0) HALF_EVEN = 'HALF_EVEN', // Banker's rounding (0.5 → 0, 1.5 → 2) UP = 'UP', // Always round up DOWN = 'DOWN', // Always round down (truncate) CEILING = 'CEILING', // Round toward positive infinity FLOOR = 'FLOOR' // Round toward negative infinity } /** * Currency configuration for calculations */ export interface CurrencyConfig { code: string; minorUnits: number; roundingMode: RoundingMode; tolerance?: number; // Override default tolerance if needed } /** * Get minor units (decimal places) for a currency */ export function getCurrencyMinorUnits(currencyCode: string): number { const code = currencyCode.toUpperCase(); return ISO4217MinorUnits[code] ?? 2; // Default to 2 if unknown } /** * Round a value according to currency rules */ export function roundToCurrency( value: number, currencyCode: string, mode: RoundingMode = RoundingMode.HALF_UP ): number { const minorUnits = getCurrencyMinorUnits(currencyCode); if (minorUnits === 0) { // For zero decimal currencies, round to integer return Math.round(value); } const multiplier = Math.pow(10, minorUnits); const scaled = value * multiplier; let rounded: number; switch (mode) { case RoundingMode.HALF_UP: // Round half values away from zero if (scaled >= 0) { rounded = Math.floor(scaled + 0.5); } else { rounded = Math.ceil(scaled - 0.5); } break; case RoundingMode.HALF_DOWN: // Round half values toward zero const fraction = Math.abs(scaled % 1); if (fraction === 0.5) { // Exactly 0.5 - round toward zero rounded = scaled >= 0 ? Math.floor(scaled) : Math.ceil(scaled); } else { // Not exactly 0.5 - use normal rounding rounded = Math.round(scaled); } break; case RoundingMode.HALF_EVEN: // Banker's rounding const isHalf = Math.abs(scaled % 1) === 0.5; if (isHalf) { const floor = Math.floor(scaled); rounded = floor % 2 === 0 ? floor : Math.ceil(scaled); } else { rounded = Math.round(scaled); } break; case RoundingMode.UP: rounded = scaled >= 0 ? Math.ceil(scaled) : Math.floor(scaled); break; case RoundingMode.DOWN: rounded = Math.trunc(scaled); break; case RoundingMode.CEILING: rounded = Math.ceil(scaled); break; case RoundingMode.FLOOR: rounded = Math.floor(scaled); break; default: rounded = Math.round(scaled); } return rounded / multiplier; } /** * Get tolerance for currency comparison * Based on the smallest representable unit for the currency */ export function getCurrencyTolerance(currencyCode: string): number { const minorUnits = getCurrencyMinorUnits(currencyCode); // Tolerance is half of the smallest unit return 0.5 * Math.pow(10, -minorUnits); } /** * Compare two monetary values with currency-aware tolerance */ export function areMonetaryValuesEqual( value1: number, value2: number, currencyCode: string ): boolean { const tolerance = getCurrencyTolerance(currencyCode); return Math.abs(value1 - value2) <= tolerance; } /** * Format a value according to currency decimal places */ export function formatCurrencyValue( value: number, currencyCode: string ): string { const minorUnits = getCurrencyMinorUnits(currencyCode); return value.toFixed(minorUnits); } /** * Validate if a value has correct decimal places for a currency */ export function hasValidDecimalPlaces( value: number, currencyCode: string ): boolean { const minorUnits = getCurrencyMinorUnits(currencyCode); const multiplier = Math.pow(10, minorUnits); const scaled = Math.round(value * multiplier); const reconstructed = scaled / multiplier; return Math.abs(value - reconstructed) < Number.EPSILON; } /** * Currency calculation context for EN16931 compliance */ export class CurrencyCalculator { private currency: string; private minorUnits: number; private roundingMode: RoundingMode; constructor(config: CurrencyConfig | string) { if (typeof config === 'string') { this.currency = config; this.minorUnits = getCurrencyMinorUnits(config); this.roundingMode = RoundingMode.HALF_UP; } else { this.currency = config.code; this.minorUnits = config.minorUnits; this.roundingMode = config.roundingMode; } } /** * Round a value according to configured rules */ round(value: number): number { return roundToCurrency(value, this.currency, this.roundingMode); } /** * Calculate line net amount with rounding * EN16931: Line net = (quantity × unit price) - line discounts */ calculateLineNet( quantity: number, unitPrice: number, discount: number = 0 ): number { const gross = quantity * unitPrice; const net = gross - discount; return this.round(net); } /** * Calculate VAT amount with rounding * EN16931: VAT amount = taxable amount × (rate / 100) */ calculateVAT(taxableAmount: number, rate: number): number { const vat = taxableAmount * (rate / 100); return this.round(vat); } /** * Compare values with currency-aware tolerance */ areEqual(value1: number, value2: number): boolean { return areMonetaryValuesEqual(value1, value2, this.currency); } /** * Get the tolerance for comparisons */ getTolerance(): number { return getCurrencyTolerance(this.currency); } /** * Format value for display */ format(value: number): string { return formatCurrencyValue(value, this.currency); } } /** * Get version info for ISO 4217 data */ export function getISO4217Version(): string { return '2015'; // Update when currency list is updated }