299 lines
8.1 KiB
TypeScript
299 lines
8.1 KiB
TypeScript
|
/**
|
|||
|
* 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<string, number> = {
|
|||
|
// 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
|
|||
|
}
|