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:
2025-08-11 12:25:32 +00:00
parent 01c6e8daad
commit 10e14af85b
53 changed files with 11315 additions and 17 deletions

View 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
}