feat: Implement PEPPOL and XRechnung validators for compliance with e-invoice specifications

- Added PeppolValidator class to validate PEPPOL BIS 3.0 invoices, including checks for endpoint IDs, document type IDs, process IDs, party identification, and business rules.
- Implemented validation for GLN check digits, document types, and transport protocols specific to PEPPOL.
- Added XRechnungValidator class to validate XRechnung 3.0 invoices, focusing on German-specific requirements such as Leitweg-ID, payment details, seller contact, and tax registration.
- Included validation for IBAN and BIC formats, ensuring compliance with SEPA regulations.
- Established methods for checking B2G invoice indicators and validating mandatory fields for both validators.
This commit is contained in:
2025-08-11 18:07:01 +00:00
parent 10e14af85b
commit cbb297b0b1
24 changed files with 7714 additions and 98 deletions

View File

@@ -0,0 +1,323 @@
/**
* Currency Calculator using Decimal Arithmetic
* EN16931-compliant monetary calculations with exact precision
*/
import { Decimal, decimal, RoundingMode } from './decimal.js';
import type { TCurrency } from '@tsclass/tsclass/dist_ts/finance/index.js';
import { getCurrencyMinorUnits } from './currency.utils.js';
/**
* Currency-aware calculator using decimal arithmetic for EN16931 compliance
*/
export class DecimalCurrencyCalculator {
private readonly currency: TCurrency;
private readonly minorUnits: number;
private readonly roundingMode: RoundingMode;
constructor(
currency: TCurrency,
roundingMode: RoundingMode = 'HALF_UP'
) {
this.currency = currency;
this.minorUnits = getCurrencyMinorUnits(currency);
this.roundingMode = roundingMode;
}
/**
* Round a decimal value according to currency rules
*/
round(value: Decimal | number | string): Decimal {
const decimalValue = value instanceof Decimal ? value : new Decimal(value);
return decimalValue.round(this.minorUnits, this.roundingMode);
}
/**
* Calculate line net amount: (quantity × unitPrice) - discount
*/
calculateLineNet(
quantity: Decimal | number | string,
unitPrice: Decimal | number | string,
discount: Decimal | number | string = '0'
): Decimal {
const qty = quantity instanceof Decimal ? quantity : new Decimal(quantity);
const price = unitPrice instanceof Decimal ? unitPrice : new Decimal(unitPrice);
const disc = discount instanceof Decimal ? discount : new Decimal(discount);
const gross = qty.multiply(price);
const net = gross.subtract(disc);
return this.round(net);
}
/**
* Calculate VAT amount from base and rate
*/
calculateVAT(
baseAmount: Decimal | number | string,
vatRate: Decimal | number | string
): Decimal {
const base = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount);
const rate = vatRate instanceof Decimal ? vatRate : new Decimal(vatRate);
const vat = base.percentage(rate);
return this.round(vat);
}
/**
* Calculate total with VAT
*/
calculateGrossAmount(
netAmount: Decimal | number | string,
vatAmount: Decimal | number | string
): Decimal {
const net = netAmount instanceof Decimal ? netAmount : new Decimal(netAmount);
const vat = vatAmount instanceof Decimal ? vatAmount : new Decimal(vatAmount);
return this.round(net.add(vat));
}
/**
* Calculate sum of line items
*/
sumLineItems(items: Array<{
quantity: Decimal | number | string;
unitPrice: Decimal | number | string;
discount?: Decimal | number | string;
}>): Decimal {
let total = Decimal.ZERO;
for (const item of items) {
const lineNet = this.calculateLineNet(
item.quantity,
item.unitPrice,
item.discount
);
total = total.add(lineNet);
}
return this.round(total);
}
/**
* Calculate VAT breakdown by rate
*/
calculateVATBreakdown(items: Array<{
netAmount: Decimal | number | string;
vatRate: Decimal | number | string;
}>): Array<{
rate: Decimal;
baseAmount: Decimal;
vatAmount: Decimal;
}> {
// Group by VAT rate
const groups = new Map<string, {
rate: Decimal;
baseAmount: Decimal;
}>();
for (const item of items) {
const net = item.netAmount instanceof Decimal ? item.netAmount : new Decimal(item.netAmount);
const rate = item.vatRate instanceof Decimal ? item.vatRate : new Decimal(item.vatRate);
const rateKey = rate.toString();
if (groups.has(rateKey)) {
const group = groups.get(rateKey)!;
group.baseAmount = group.baseAmount.add(net);
} else {
groups.set(rateKey, {
rate,
baseAmount: net
});
}
}
// Calculate VAT for each group
const breakdown: Array<{
rate: Decimal;
baseAmount: Decimal;
vatAmount: Decimal;
}> = [];
for (const group of groups.values()) {
breakdown.push({
rate: group.rate,
baseAmount: this.round(group.baseAmount),
vatAmount: this.calculateVAT(group.baseAmount, group.rate)
});
}
return breakdown;
}
/**
* Check if two amounts are equal within currency precision
*/
areEqual(
amount1: Decimal | number | string,
amount2: Decimal | number | string
): boolean {
const a1 = amount1 instanceof Decimal ? amount1 : new Decimal(amount1);
const a2 = amount2 instanceof Decimal ? amount2 : new Decimal(amount2);
// Round both to currency precision before comparing
const rounded1 = this.round(a1);
const rounded2 = this.round(a2);
return rounded1.equals(rounded2);
}
/**
* Calculate payment terms discount
*/
calculatePaymentDiscount(
amount: Decimal | number | string,
discountRate: Decimal | number | string
): Decimal {
const amt = amount instanceof Decimal ? amount : new Decimal(amount);
const rate = discountRate instanceof Decimal ? discountRate : new Decimal(discountRate);
const discount = amt.percentage(rate);
return this.round(discount);
}
/**
* Distribute a total amount across items proportionally
*/
distributeAmount(
totalToDistribute: Decimal | number | string,
items: Array<{ value: Decimal | number | string }>
): Decimal[] {
const total = totalToDistribute instanceof Decimal ? totalToDistribute : new Decimal(totalToDistribute);
// Calculate sum of all item values
const itemSum = items.reduce((sum, item) => {
const value = item.value instanceof Decimal ? item.value : new Decimal(item.value);
return sum.add(value);
}, Decimal.ZERO);
if (itemSum.isZero()) {
// Can't distribute if sum is zero
return items.map(() => Decimal.ZERO);
}
const distributed: Decimal[] = [];
let distributedSum = Decimal.ZERO;
// Distribute proportionally
for (let i = 0; i < items.length; i++) {
const itemValue = items[i].value instanceof Decimal ? items[i].value : new Decimal(items[i].value);
if (i === items.length - 1) {
// Last item gets the remainder to avoid rounding errors
distributed.push(total.subtract(distributedSum));
} else {
const itemDecimal = itemValue instanceof Decimal ? itemValue : new Decimal(itemValue);
const proportion = itemDecimal.divide(itemSum);
const distributedAmount = this.round(total.multiply(proportion));
distributed.push(distributedAmount);
distributedSum = distributedSum.add(distributedAmount);
}
}
return distributed;
}
/**
* Calculate compound amount (e.g., for multiple charges/allowances)
*/
calculateCompoundAmount(
baseAmount: Decimal | number | string,
adjustments: Array<{
type: 'charge' | 'allowance';
value: Decimal | number | string;
isPercentage?: boolean;
}>
): Decimal {
let result = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount);
for (const adjustment of adjustments) {
const value = adjustment.value instanceof Decimal ? adjustment.value : new Decimal(adjustment.value);
let adjustmentAmount: Decimal;
if (adjustment.isPercentage) {
adjustmentAmount = result.percentage(value);
} else {
adjustmentAmount = value;
}
if (adjustment.type === 'charge') {
result = result.add(adjustmentAmount);
} else {
result = result.subtract(adjustmentAmount);
}
}
return this.round(result);
}
/**
* Validate monetary calculation according to EN16931 rules
*/
validateCalculation(
expected: Decimal | number | string,
calculated: Decimal | number | string,
ruleName: string
): {
valid: boolean;
expected: string;
calculated: string;
difference?: string;
rule: string;
} {
const exp = expected instanceof Decimal ? expected : new Decimal(expected);
const calc = calculated instanceof Decimal ? calculated : new Decimal(calculated);
const roundedExp = this.round(exp);
const roundedCalc = this.round(calc);
const valid = roundedExp.equals(roundedCalc);
return {
valid,
expected: roundedExp.toFixed(this.minorUnits),
calculated: roundedCalc.toFixed(this.minorUnits),
difference: valid ? undefined : roundedExp.subtract(roundedCalc).abs().toFixed(this.minorUnits),
rule: ruleName
};
}
/**
* Format amount for display
*/
formatAmount(amount: Decimal | number | string): string {
const amt = amount instanceof Decimal ? amount : new Decimal(amount);
const rounded = this.round(amt);
return `${rounded.toFixed(this.minorUnits)} ${this.currency}`;
}
/**
* Get currency information
*/
getCurrencyInfo(): {
code: TCurrency;
minorUnits: number;
roundingMode: RoundingMode;
} {
return {
code: this.currency,
minorUnits: this.minorUnits,
roundingMode: this.roundingMode
};
}
}
/**
* Factory function to create a decimal currency calculator
*/
export function createDecimalCalculator(
currency: TCurrency,
roundingMode?: RoundingMode
): DecimalCurrencyCalculator {
return new DecimalCurrencyCalculator(currency, roundingMode);
}

509
ts/formats/utils/decimal.ts Normal file
View File

@@ -0,0 +1,509 @@
/**
* Decimal Arithmetic Library for EN16931 Compliance
* Provides arbitrary precision decimal arithmetic to avoid floating-point errors
*
* Based on EN16931 requirements for financial calculations:
* - All monetary amounts must be calculated with sufficient precision
* - Rounding must be consistent and predictable
* - No loss of precision in intermediate calculations
*/
/**
* Decimal class for arbitrary precision arithmetic
* Internally stores the value as an integer with a scale factor
*/
export class Decimal {
private readonly value: bigint;
private readonly scale: number;
// Constants - initialized lazily to avoid initialization issues
private static _ZERO: Decimal | undefined;
private static _ONE: Decimal | undefined;
private static _TEN: Decimal | undefined;
private static _HUNDRED: Decimal | undefined;
static get ZERO(): Decimal {
if (!this._ZERO) this._ZERO = new Decimal(0);
return this._ZERO;
}
static get ONE(): Decimal {
if (!this._ONE) this._ONE = new Decimal(1);
return this._ONE;
}
static get TEN(): Decimal {
if (!this._TEN) this._TEN = new Decimal(10);
return this._TEN;
}
static get HUNDRED(): Decimal {
if (!this._HUNDRED) this._HUNDRED = new Decimal(100);
return this._HUNDRED;
}
// Default scale for monetary calculations (4 decimal places for intermediate calculations)
private static readonly DEFAULT_SCALE = 4;
/**
* Create a new Decimal from various input types
*/
constructor(value: string | number | bigint | Decimal, scale?: number) {
if (value instanceof Decimal) {
this.value = value.value;
this.scale = value.scale;
return;
}
// Special handling for direct bigint with scale (internal use)
if (typeof value === 'bigint' && scale !== undefined) {
this.value = value;
this.scale = scale;
return;
}
// Determine scale if not provided
if (scale === undefined) {
if (typeof value === 'string') {
const parts = value.split('.');
scale = parts.length > 1 ? parts[1].length : 0;
} else {
scale = Decimal.DEFAULT_SCALE;
}
}
this.scale = scale;
// Convert to scaled integer
if (typeof value === 'string') {
// Remove any formatting
value = value.replace(/[^\d.-]/g, '');
const parts = value.split('.');
const integerPart = parts[0] || '0';
const decimalPart = (parts[1] || '').padEnd(scale, '0').slice(0, scale);
this.value = BigInt(integerPart + decimalPart);
} else if (typeof value === 'number') {
// Handle floating point numbers
if (!isFinite(value)) {
throw new Error(`Invalid number value: ${value}`);
}
const multiplier = Math.pow(10, scale);
this.value = BigInt(Math.round(value * multiplier));
} else {
// bigint
this.value = value * BigInt(Math.pow(10, scale));
}
}
/**
* Convert to string representation
*/
toString(decimalPlaces?: number): string {
const absValue = this.value < 0n ? -this.value : this.value;
const str = absValue.toString().padStart(this.scale + 1, '0');
const integerPart = this.scale > 0 ? (str.slice(0, -this.scale) || '0') : str;
let decimalPart = this.scale > 0 ? str.slice(-this.scale) : '';
// Apply decimal places if specified
if (decimalPlaces !== undefined) {
if (decimalPlaces === 0) {
return (this.value < 0n ? '-' : '') + integerPart;
}
decimalPart = decimalPart.padEnd(decimalPlaces, '0').slice(0, decimalPlaces);
}
// Remove trailing zeros if no specific decimal places requested
if (decimalPlaces === undefined) {
decimalPart = decimalPart.replace(/0+$/, '');
}
const result = decimalPart ? `${integerPart}.${decimalPart}` : integerPart;
return this.value < 0n ? '-' + result : result;
}
/**
* Convert to number (may lose precision)
*/
toNumber(): number {
return Number(this.value) / Math.pow(10, this.scale);
}
/**
* Convert to fixed decimal places string
*/
toFixed(decimalPlaces: number): string {
return this.round(decimalPlaces).toString(decimalPlaces);
}
/**
* Add two decimals
*/
add(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales
if (this.scale === otherDecimal.scale) {
return new Decimal(this.value + otherDecimal.value, this.scale);
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
return new Decimal(thisScaled.value + otherScaled.value, maxScale);
}
/**
* Subtract another decimal
*/
subtract(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales
if (this.scale === otherDecimal.scale) {
return new Decimal(this.value - otherDecimal.value, this.scale);
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
return new Decimal(thisScaled.value - otherScaled.value, maxScale);
}
/**
* Multiply by another decimal
*/
multiply(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Multiply values and add scales
const newValue = this.value * otherDecimal.value;
const newScale = this.scale + otherDecimal.scale;
// Reduce scale if possible to avoid overflow
const result = new Decimal(newValue, newScale);
return result.normalize();
}
/**
* Divide by another decimal
*/
divide(other: Decimal | number | string, precision: number = 10): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
if (otherDecimal.value === 0n) {
throw new Error('Division by zero');
}
// Scale up the dividend to maintain precision
const scaledDividend = this.value * BigInt(Math.pow(10, precision));
const quotient = scaledDividend / otherDecimal.value;
return new Decimal(quotient, this.scale + precision - otherDecimal.scale).normalize();
}
/**
* Calculate percentage (this * rate / 100)
*/
percentage(rate: Decimal | number | string): Decimal {
const rateDecimal = rate instanceof Decimal ? rate : new Decimal(rate);
return this.multiply(rateDecimal).divide(100);
}
/**
* Round to specified decimal places using a specific rounding mode
*/
round(decimalPlaces: number, mode: 'HALF_UP' | 'HALF_DOWN' | 'HALF_EVEN' | 'UP' | 'DOWN' | 'CEILING' | 'FLOOR' = 'HALF_UP'): Decimal {
if (decimalPlaces === this.scale) {
return this;
}
if (decimalPlaces > this.scale) {
// Just add zeros
return this.rescale(decimalPlaces);
}
// Need to round
const factor = BigInt(Math.pow(10, this.scale - decimalPlaces));
const halfFactor = factor / 2n;
let rounded: bigint;
const isNegative = this.value < 0n;
const absValue = isNegative ? -this.value : this.value;
switch (mode) {
case 'HALF_UP':
// Round half away from zero
rounded = (absValue + halfFactor) / factor;
break;
case 'HALF_DOWN':
// Round half toward zero
rounded = (absValue + halfFactor - 1n) / factor;
break;
case 'HALF_EVEN':
// Banker's rounding
const quotient = absValue / factor;
const remainder = absValue % factor;
if (remainder > halfFactor || (remainder === halfFactor && quotient % 2n === 1n)) {
rounded = quotient + 1n;
} else {
rounded = quotient;
}
break;
case 'UP':
// Round away from zero
rounded = (absValue + factor - 1n) / factor;
break;
case 'DOWN':
// Round toward zero
rounded = absValue / factor;
break;
case 'CEILING':
// Round toward positive infinity
if (isNegative) {
rounded = absValue / factor;
} else {
rounded = (absValue + factor - 1n) / factor;
}
break;
case 'FLOOR':
// Round toward negative infinity
if (isNegative) {
rounded = (absValue + factor - 1n) / factor;
} else {
rounded = absValue / factor;
}
break;
default:
throw new Error(`Unknown rounding mode: ${mode}`);
}
const finalValue = isNegative ? -rounded : rounded;
return new Decimal(finalValue, decimalPlaces);
}
/**
* Compare with another decimal
*/
compareTo(other: Decimal | number | string): number {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales for comparison
if (this.scale === otherDecimal.scale) {
if (this.value < otherDecimal.value) return -1;
if (this.value > otherDecimal.value) return 1;
return 0;
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
if (thisScaled.value < otherScaled.value) return -1;
if (thisScaled.value > otherScaled.value) return 1;
return 0;
}
/**
* Check equality
*/
equals(other: Decimal | number | string, tolerance?: Decimal | number | string): boolean {
if (tolerance) {
const toleranceDecimal = tolerance instanceof Decimal ? tolerance : new Decimal(tolerance);
const diff = this.subtract(other);
const absDiff = diff.abs();
return absDiff.compareTo(toleranceDecimal) <= 0;
}
return this.compareTo(other) === 0;
}
/**
* Check if less than
*/
lessThan(other: Decimal | number | string): boolean {
return this.compareTo(other) < 0;
}
/**
* Check if less than or equal
*/
lessThanOrEqual(other: Decimal | number | string): boolean {
return this.compareTo(other) <= 0;
}
/**
* Check if greater than
*/
greaterThan(other: Decimal | number | string): boolean {
return this.compareTo(other) > 0;
}
/**
* Check if greater than or equal
*/
greaterThanOrEqual(other: Decimal | number | string): boolean {
return this.compareTo(other) >= 0;
}
/**
* Get absolute value
*/
abs(): Decimal {
return this.value < 0n ? new Decimal(-this.value, this.scale) : this;
}
/**
* Negate the value
*/
negate(): Decimal {
return new Decimal(-this.value, this.scale);
}
/**
* Check if zero
*/
isZero(): boolean {
return this.value === 0n;
}
/**
* Check if negative
*/
isNegative(): boolean {
return this.value < 0n;
}
/**
* Check if positive
*/
isPositive(): boolean {
return this.value > 0n;
}
/**
* Rescale to a different number of decimal places
*/
private rescale(newScale: number): Decimal {
if (newScale === this.scale) {
return this;
}
if (newScale > this.scale) {
// Add zeros
const factor = BigInt(Math.pow(10, newScale - this.scale));
return new Decimal(this.value * factor, newScale);
}
// This would lose precision, use round() instead
throw new Error('Use round() to reduce scale');
}
/**
* Normalize by removing trailing zeros
*/
private normalize(): Decimal {
if (this.value === 0n) {
return new Decimal(0n, 0);
}
let value = this.value;
let scale = this.scale;
while (scale > 0 && value % 10n === 0n) {
value = value / 10n;
scale--;
}
return new Decimal(value, scale);
}
/**
* Create a Decimal from a percentage string (e.g., "19%" -> 0.19)
*/
static fromPercentage(value: string): Decimal {
const cleaned = value.replace('%', '').trim();
return new Decimal(cleaned).divide(100);
}
/**
* Sum an array of decimals
*/
static sum(values: (Decimal | number | string)[]): Decimal {
return values.reduce<Decimal>((acc, val) => {
const decimal = val instanceof Decimal ? val : new Decimal(val);
return acc.add(decimal);
}, Decimal.ZERO);
}
/**
* Get the minimum value
*/
static min(...values: (Decimal | number | string)[]): Decimal {
if (values.length === 0) {
throw new Error('No values provided');
}
let min = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]);
for (let i = 1; i < values.length; i++) {
const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]);
const currentDecimal = current instanceof Decimal ? current : new Decimal(current);
if (currentDecimal.lessThan(min)) {
min = currentDecimal;
}
}
return min;
}
/**
* Get the maximum value
*/
static max(...values: (Decimal | number | string)[]): Decimal {
if (values.length === 0) {
throw new Error('No values provided');
}
let max = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]);
for (let i = 1; i < values.length; i++) {
const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]);
const currentDecimal = current instanceof Decimal ? current : new Decimal(current);
if (currentDecimal.greaterThan(max)) {
max = currentDecimal;
}
}
return max;
}
}
/**
* Helper function to create a Decimal
*/
export function decimal(value: string | number | bigint | Decimal): Decimal {
return new Decimal(value);
}
/**
* Export commonly used rounding modes
*/
export const RoundingMode = {
HALF_UP: 'HALF_UP' as const,
HALF_DOWN: 'HALF_DOWN' as const,
HALF_EVEN: 'HALF_EVEN' as const,
UP: 'UP' as const,
DOWN: 'DOWN' as const,
CEILING: 'CEILING' as const,
FLOOR: 'FLOOR' as const
} as const;
export type RoundingMode = typeof RoundingMode[keyof typeof RoundingMode];