- Added validation types for EN16931 compliance in `validation.types.ts`, including interfaces for `ValidationResult`, `ValidationOptions`, and `ValidationReport`. - Introduced `VATCategoriesValidator` in `vat-categories.validator.ts` to validate VAT categories according to EN16931 rules, including detailed checks for standard, zero-rated, exempt, reverse charge, intra-community, export, and out-of-scope services. - Enhanced `IEInvoiceMetadata` interface in `en16931-metadata.ts` to include additional fields required for full standards compliance, such as delivery information, payment information, allowances, and charges. - Implemented helper methods for VAT calculations and validation logic to ensure accurate compliance with EN16931 standards.
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
|
||
} |