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:
@@ -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) =>
|
||||
|
Reference in New Issue
Block a user