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.
This commit is contained in:
299
ts/formats/utils/currency.utils.ts
Normal file
299
ts/formats/utils/currency.utils.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
Reference in New Issue
Block a user