Files
einvoice/ts/formats/utils/currency.utils.ts
Juergen Kunz 10e14af85b feat(validation): Implement EN16931 compliance validation types and VAT categories
- 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.
2025-08-11 12:25:32 +00:00

299 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
}