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

@@ -2,6 +2,8 @@ import * as plugins from '../../plugins.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import type { EInvoice } from '../../einvoice.js';
import { CurrencyCalculator, areMonetaryValuesEqual } from '../utils/currency.utils.js';
import { DecimalCurrencyCalculator } from '../utils/currency.calculator.decimal.js';
import { Decimal } from '../utils/decimal.js';
import type { ValidationResult, ValidationOptions } from './validation.types.js';
/**
@@ -11,6 +13,7 @@ import type { ValidationResult, ValidationOptions } from './validation.types.js'
export class EN16931BusinessRulesValidator {
private results: ValidationResult[] = [];
private currencyCalculator?: CurrencyCalculator;
private decimalCalculator?: DecimalCurrencyCalculator;
/**
* Validate an invoice against EN16931 business rules
@@ -18,9 +21,10 @@ export class EN16931BusinessRulesValidator {
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
this.results = [];
// Initialize currency calculator if currency is available
// Initialize currency calculators if currency is available
if (invoice.currency) {
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
this.decimalCalculator = new DecimalCurrencyCalculator(invoice.currency);
}
// Document level rules (BR-01 to BR-65)
@@ -118,100 +122,139 @@ export class EN16931BusinessRulesValidator {
private validateCalculationRules(invoice: EInvoice): void {
if (!invoice.items || invoice.items.length === 0) return;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = this.calculateLineTotal(invoice.items);
const declaredLineTotal = invoice.totalNet || 0;
// Use decimal calculator for precise calculations
const useDecimal = this.decimalCalculator !== undefined;
const isEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal, declaredLineTotal)
: Math.abs(calculatedLineTotal - declaredLineTotal) < 0.01;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = useDecimal
? this.calculateLineTotalDecimal(invoice.items)
: this.calculateLineTotal(invoice.items);
const declaredLineTotal = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedLineTotal, declaredLineTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal as number, declaredLineTotal as number)
: Math.abs((calculatedLineTotal as number) - (declaredLineTotal as number)) < 0.01;
if (!isEqual) {
this.addError(
'BR-CO-10',
`Sum of line net amounts (${calculatedLineTotal.toFixed(2)}) does not match declared total (${declaredLineTotal.toFixed(2)})`,
`Sum of line net amounts (${useDecimal ? (calculatedLineTotal as Decimal).toFixed(2) : (calculatedLineTotal as number).toFixed(2)}) does not match declared total (${useDecimal ? (declaredLineTotal as Decimal).toFixed(2) : (declaredLineTotal as number).toFixed(2)})`,
'totalNet',
declaredLineTotal,
calculatedLineTotal
useDecimal ? (declaredLineTotal as Decimal).toNumber() : declaredLineTotal as number,
useDecimal ? (calculatedLineTotal as Decimal).toNumber() : calculatedLineTotal as number
);
}
// BR-CO-11: Sum of allowances on document level
const documentAllowances = this.calculateDocumentAllowances(invoice);
const documentAllowances = useDecimal
? this.calculateDocumentAllowancesDecimal(invoice)
: this.calculateDocumentAllowances(invoice);
// BR-CO-12: Sum of charges on document level
const documentCharges = this.calculateDocumentCharges(invoice);
const documentCharges = useDecimal
? this.calculateDocumentChargesDecimal(invoice)
: this.calculateDocumentCharges(invoice);
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
const expectedTaxExclusive = calculatedLineTotal - documentAllowances + documentCharges;
const declaredTaxExclusive = invoice.totalNet || 0;
const expectedTaxExclusive = useDecimal
? (calculatedLineTotal as Decimal).subtract(documentAllowances).add(documentCharges)
: (calculatedLineTotal as number) - (documentAllowances as number) + (documentCharges as number);
const declaredTaxExclusive = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isTaxExclusiveEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: Math.abs(expectedTaxExclusive - declaredTaxExclusive) < 0.01;
const isTaxExclusiveEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive as number, declaredTaxExclusive as number)
: Math.abs((expectedTaxExclusive as number) - (declaredTaxExclusive as number)) < 0.01;
if (!isTaxExclusiveEqual) {
this.addError(
'BR-CO-13',
`Tax exclusive amount (${declaredTaxExclusive.toFixed(2)}) does not match calculation (${expectedTaxExclusive.toFixed(2)})`,
`Tax exclusive amount (${useDecimal ? (declaredTaxExclusive as Decimal).toFixed(2) : (declaredTaxExclusive as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedTaxExclusive as Decimal).toFixed(2) : (expectedTaxExclusive as number).toFixed(2)})`,
'totalNet',
declaredTaxExclusive,
expectedTaxExclusive
useDecimal ? (declaredTaxExclusive as Decimal).toNumber() : declaredTaxExclusive as number,
useDecimal ? (expectedTaxExclusive as Decimal).toNumber() : expectedTaxExclusive as number
);
}
// BR-CO-14: Invoice total VAT amount = Σ(VAT category tax amount)
const calculatedVAT = this.calculateTotalVAT(invoice);
const declaredVAT = invoice.totalVat || 0;
const calculatedVAT = useDecimal
? this.calculateTotalVATDecimal(invoice)
: this.calculateTotalVAT(invoice);
const declaredVAT = useDecimal
? new Decimal(invoice.totalVat || 0)
: invoice.totalVat || 0;
const isVATEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT, declaredVAT)
: Math.abs(calculatedVAT - declaredVAT) < 0.01;
const isVATEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedVAT, declaredVAT)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT as number, declaredVAT as number)
: Math.abs((calculatedVAT as number) - (declaredVAT as number)) < 0.01;
if (!isVATEqual) {
this.addError(
'BR-CO-14',
`Total VAT (${declaredVAT.toFixed(2)}) does not match calculation (${calculatedVAT.toFixed(2)})`,
`Total VAT (${useDecimal ? (declaredVAT as Decimal).toFixed(2) : (declaredVAT as number).toFixed(2)}) does not match calculation (${useDecimal ? (calculatedVAT as Decimal).toFixed(2) : (calculatedVAT as number).toFixed(2)})`,
'totalVat',
declaredVAT,
calculatedVAT
useDecimal ? (declaredVAT as Decimal).toNumber() : declaredVAT as number,
useDecimal ? (calculatedVAT as Decimal).toNumber() : calculatedVAT as number
);
}
// BR-CO-15: Invoice total with VAT = Invoice total without VAT + Invoice total VAT
const expectedGrossTotal = expectedTaxExclusive + calculatedVAT;
const declaredGrossTotal = invoice.totalGross || 0;
const expectedGrossTotal = useDecimal
? (expectedTaxExclusive as Decimal).add(calculatedVAT)
: (expectedTaxExclusive as number) + (calculatedVAT as number);
const declaredGrossTotal = useDecimal
? new Decimal(invoice.totalGross || 0)
: invoice.totalGross || 0;
const isGrossEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal, declaredGrossTotal)
: Math.abs(expectedGrossTotal - declaredGrossTotal) < 0.01;
const isGrossEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedGrossTotal, declaredGrossTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal as number, declaredGrossTotal as number)
: Math.abs((expectedGrossTotal as number) - (declaredGrossTotal as number)) < 0.01;
if (!isGrossEqual) {
this.addError(
'BR-CO-15',
`Gross total (${declaredGrossTotal.toFixed(2)}) does not match calculation (${expectedGrossTotal.toFixed(2)})`,
`Gross total (${useDecimal ? (declaredGrossTotal as Decimal).toFixed(2) : (declaredGrossTotal as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedGrossTotal as Decimal).toFixed(2) : (expectedGrossTotal as number).toFixed(2)})`,
'totalGross',
declaredGrossTotal,
expectedGrossTotal
useDecimal ? (declaredGrossTotal as Decimal).toNumber() : declaredGrossTotal as number,
useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal as number
);
}
// BR-CO-16: Amount due for payment = Invoice total with VAT - Paid amount
const paidAmount = invoice.metadata?.paidAmount || 0;
const expectedDueAmount = expectedGrossTotal - paidAmount;
const declaredDueAmount = invoice.metadata?.amountDue || expectedGrossTotal;
const paidAmount = useDecimal
? new Decimal(invoice.metadata?.paidAmount || 0)
: invoice.metadata?.paidAmount || 0;
const expectedDueAmount = useDecimal
? (expectedGrossTotal as Decimal).subtract(paidAmount)
: (expectedGrossTotal as number) - (paidAmount as number);
const declaredDueAmount = useDecimal
? new Decimal(invoice.metadata?.amountDue || (useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal))
: invoice.metadata?.amountDue || expectedGrossTotal;
const isDueEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount, declaredDueAmount)
: Math.abs(expectedDueAmount - declaredDueAmount) < 0.01;
const isDueEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedDueAmount, declaredDueAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount as number, declaredDueAmount as number)
: Math.abs((expectedDueAmount as number) - (declaredDueAmount as number)) < 0.01;
if (!isDueEqual) {
this.addError(
'BR-CO-16',
`Amount due (${declaredDueAmount.toFixed(2)}) does not match calculation (${expectedDueAmount.toFixed(2)})`,
`Amount due (${useDecimal ? (declaredDueAmount as Decimal).toFixed(2) : (declaredDueAmount as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedDueAmount as Decimal).toFixed(2) : (expectedDueAmount as number).toFixed(2)})`,
'amountDue',
declaredDueAmount,
expectedDueAmount
useDecimal ? (declaredDueAmount as Decimal).toNumber() : declaredDueAmount as number,
useDecimal ? (expectedDueAmount as Decimal).toNumber() : expectedDueAmount as number
);
}
}
@@ -220,6 +263,8 @@ export class EN16931BusinessRulesValidator {
* Validate VAT rules
*/
private validateVATRules(invoice: EInvoice): void {
const useDecimal = this.decimalCalculator !== undefined;
// Group items by VAT rate
const vatGroups = this.groupItemsByVAT(invoice.items || []);
@@ -247,11 +292,19 @@ export class EN16931BusinessRulesValidator {
// BR-S-03: VAT category tax amount for standard rated
vatGroups.forEach((group, rate) => {
if (rate > 0) { // Standard rated
const expectedTaxableAmount = group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxableAmount = useDecimal
? group.reduce((sum, item) => {
const unitPrice = new Decimal(item.unitNetPrice);
const quantity = new Decimal(item.unitQuantity);
return sum.add(unitPrice.multiply(quantity));
}, Decimal.ZERO)
: group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxAmount = expectedTaxableAmount * (rate / 100);
const expectedTaxAmount = useDecimal
? this.decimalCalculator!.calculateVAT(expectedTaxableAmount, new Decimal(rate))
: (expectedTaxableAmount as number) * (rate / 100);
// Find corresponding breakdown
const breakdown = invoice.taxBreakdown?.find(b =>
@@ -259,9 +312,11 @@ export class EN16931BusinessRulesValidator {
);
if (breakdown) {
const isTaxableEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount)
: Math.abs(breakdown.netAmount - expectedTaxableAmount) < 0.01;
const isTaxableEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxableAmount, breakdown.netAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount as number)
: Math.abs(breakdown.netAmount - (expectedTaxableAmount as number)) < 0.01;
if (!isTaxableEqual) {
this.addError(
@@ -269,13 +324,15 @@ export class EN16931BusinessRulesValidator {
`VAT taxable amount for ${rate}% incorrect`,
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxableAmount
useDecimal ? (expectedTaxableAmount as Decimal).toNumber() : expectedTaxableAmount as number
);
}
const isTaxEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount)
: Math.abs(breakdown.taxAmount - expectedTaxAmount) < 0.01;
const isTaxEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxAmount, breakdown.taxAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount as number)
: Math.abs(breakdown.taxAmount - (expectedTaxAmount as number)) < 0.01;
if (!isTaxEqual) {
this.addError(
@@ -283,7 +340,7 @@ export class EN16931BusinessRulesValidator {
`VAT tax amount for ${rate}% incorrect`,
'taxBreakdown.vatAmount',
breakdown.taxAmount,
expectedTaxAmount
useDecimal ? (expectedTaxAmount as Decimal).toNumber() : expectedTaxAmount as number
);
}
}
@@ -467,6 +524,90 @@ export class EN16931BusinessRulesValidator {
return sum + rounded;
}, 0);
}
/**
* Calculate line total using decimal arithmetic for precision
*/
private calculateLineTotalDecimal(items: TAccountingDocItem[]): Decimal {
let total = Decimal.ZERO;
for (const item of items) {
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineTotal = unitPrice.multiply(quantity);
total = total.add(this.decimalCalculator!.round(lineTotal));
}
return total;
}
/**
* Calculate document allowances using decimal arithmetic
*/
private calculateDocumentAllowancesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.allowances) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const allowance of invoice.metadata.allowances) {
const amount = new Decimal(allowance.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate document charges using decimal arithmetic
*/
private calculateDocumentChargesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.charges) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const charge of invoice.metadata.charges) {
const amount = new Decimal(charge.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate total VAT using decimal arithmetic
*/
private calculateTotalVATDecimal(invoice: EInvoice): Decimal {
let totalVAT = Decimal.ZERO;
// Group items by VAT rate
const vatGroups = new Map<string, Decimal>();
for (const item of invoice.items || []) {
const vatRate = item.vatPercentage || 0;
const rateKey = vatRate.toString();
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineNet = unitPrice.multiply(quantity);
if (vatGroups.has(rateKey)) {
vatGroups.set(rateKey, vatGroups.get(rateKey)!.add(lineNet));
} else {
vatGroups.set(rateKey, lineNet);
}
}
// Calculate VAT for each group
for (const [rateKey, baseAmount] of vatGroups) {
const rate = new Decimal(rateKey);
const vat = this.decimalCalculator!.calculateVAT(baseAmount, rate);
totalVAT = totalVAT.add(vat);
}
return totalVAT;
}
private calculateDocumentAllowances(invoice: EInvoice): number {
return invoice.metadata?.allowances?.reduce((sum: number, allowance: any) =>