Files
einvoice/ts/formats/validation/en16931.business-rules.validator.ts
Juergen Kunz cbb297b0b1 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.
2025-08-11 18:07:01 +00:00

694 lines
25 KiB
TypeScript

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';
/**
* EN16931 Business Rules Validator
* Implements the full set of EN16931 business rules for invoice validation
*/
export class EN16931BusinessRulesValidator {
private results: ValidationResult[] = [];
private currencyCalculator?: CurrencyCalculator;
private decimalCalculator?: DecimalCurrencyCalculator;
/**
* Validate an invoice against EN16931 business rules
*/
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
this.results = [];
// 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)
this.validateDocumentRules(invoice);
// Calculation rules (BR-CO-*)
if (options.checkCalculations !== false) {
this.validateCalculationRules(invoice);
}
// VAT rules (BR-S-*, BR-Z-*, BR-E-*, BR-AE-*, BR-IC-*, BR-G-*, BR-O-*)
if (options.checkVAT !== false) {
this.validateVATRules(invoice);
}
// Line level rules (BR-21 to BR-30)
this.validateLineRules(invoice);
// Allowances and charges rules
if (options.checkAllowances !== false) {
this.validateAllowancesCharges(invoice);
}
return this.results;
}
/**
* Validate document level rules (BR-01 to BR-65)
*/
private validateDocumentRules(invoice: EInvoice): void {
// BR-01: An Invoice shall have a Specification identifier (BT-24)
if (!invoice.metadata?.customizationId) {
this.addError('BR-01', 'Invoice must have a Specification identifier (CustomizationID)', 'customizationId');
}
// BR-02: An Invoice shall have an Invoice number (BT-1)
if (!invoice.accountingDocId) {
this.addError('BR-02', 'Invoice must have an Invoice number', 'accountingDocId');
}
// BR-03: An Invoice shall have an Invoice issue date (BT-2)
if (!invoice.date) {
this.addError('BR-03', 'Invoice must have an issue date', 'date');
}
// BR-04: An Invoice shall have an Invoice type code (BT-3)
if (!invoice.accountingDocType) {
this.addError('BR-04', 'Invoice must have a type code', 'accountingDocType');
}
// BR-05: An Invoice shall have an Invoice currency code (BT-5)
if (!invoice.currency) {
this.addError('BR-05', 'Invoice must have a currency code', 'currency');
}
// BR-06: An Invoice shall contain the Seller name (BT-27)
if (!invoice.from?.name) {
this.addError('BR-06', 'Invoice must contain the Seller name', 'from.name');
}
// BR-07: An Invoice shall contain the Buyer name (BT-44)
if (!invoice.to?.name) {
this.addError('BR-07', 'Invoice must contain the Buyer name', 'to.name');
}
// BR-08: An Invoice shall contain the Seller postal address (BG-5)
if (!invoice.from?.address) {
this.addError('BR-08', 'Invoice must contain the Seller postal address', 'from.address');
}
// BR-09: The Seller postal address shall contain a Seller country code (BT-40)
if (!invoice.from?.address?.countryCode) {
this.addError('BR-09', 'Seller postal address must contain a country code', 'from.address.countryCode');
}
// BR-10: An Invoice shall contain the Buyer postal address (BG-8)
if (!invoice.to?.address) {
this.addError('BR-10', 'Invoice must contain the Buyer postal address', 'to.address');
}
// BR-11: The Buyer postal address shall contain a Buyer country code (BT-55)
if (!invoice.to?.address?.countryCode) {
this.addError('BR-11', 'Buyer postal address must contain a country code', 'to.address.countryCode');
}
// BR-16: An Invoice shall have at least one Invoice line (BG-25)
if (!invoice.items || invoice.items.length === 0) {
this.addError('BR-16', 'Invoice must have at least one invoice line', 'items');
}
}
/**
* Validate calculation rules (BR-CO-*)
*/
private validateCalculationRules(invoice: EInvoice): void {
if (!invoice.items || invoice.items.length === 0) return;
// Use decimal calculator for precise calculations
const useDecimal = this.decimalCalculator !== undefined;
// 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 (${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',
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 = useDecimal
? this.calculateDocumentAllowancesDecimal(invoice)
: this.calculateDocumentAllowances(invoice);
// BR-CO-12: Sum of charges on document level
const documentCharges = useDecimal
? this.calculateDocumentChargesDecimal(invoice)
: this.calculateDocumentCharges(invoice);
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
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 = 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 (${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',
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 = useDecimal
? this.calculateTotalVATDecimal(invoice)
: this.calculateTotalVAT(invoice);
const declaredVAT = useDecimal
? new Decimal(invoice.totalVat || 0)
: invoice.totalVat || 0;
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 (${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',
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 = 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 = 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 (${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',
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 = 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 = 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 (${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',
useDecimal ? (declaredDueAmount as Decimal).toNumber() : declaredDueAmount as number,
useDecimal ? (expectedDueAmount as Decimal).toNumber() : expectedDueAmount as number
);
}
}
/**
* Validate VAT rules
*/
private validateVATRules(invoice: EInvoice): void {
const useDecimal = this.decimalCalculator !== undefined;
// Group items by VAT rate
const vatGroups = this.groupItemsByVAT(invoice.items || []);
// BR-S-01: An Invoice that contains an Invoice line where VAT category code is "Standard rated"
// shall contain in the VAT breakdown at least one VAT category code equal to "Standard rated"
const hasStandardRatedLine = invoice.items?.some(item =>
item.vatPercentage && item.vatPercentage > 0
);
if (hasStandardRatedLine) {
const hasStandardRatedBreakdown = invoice.taxBreakdown?.some(breakdown =>
breakdown.taxPercent && breakdown.taxPercent > 0
);
if (!hasStandardRatedBreakdown) {
this.addError(
'BR-S-01',
'Invoice with standard rated lines must have standard rated VAT breakdown',
'taxBreakdown'
);
}
}
// BR-S-02: VAT category taxable amount for standard rated
// BR-S-03: VAT category tax amount for standard rated
vatGroups.forEach((group, rate) => {
if (rate > 0) { // Standard rated
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 = useDecimal
? this.decimalCalculator!.calculateVAT(expectedTaxableAmount, new Decimal(rate))
: (expectedTaxableAmount as number) * (rate / 100);
// Find corresponding breakdown
const breakdown = invoice.taxBreakdown?.find(b =>
Math.abs((b.taxPercent || 0) - rate) < 0.01
);
if (breakdown) {
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(
'BR-S-02',
`VAT taxable amount for ${rate}% incorrect`,
'taxBreakdown.netAmount',
breakdown.netAmount,
useDecimal ? (expectedTaxableAmount as Decimal).toNumber() : expectedTaxableAmount as number
);
}
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(
'BR-S-03',
`VAT tax amount for ${rate}% incorrect`,
'taxBreakdown.vatAmount',
breakdown.taxAmount,
useDecimal ? (expectedTaxAmount as Decimal).toNumber() : expectedTaxAmount as number
);
}
}
}
});
// BR-Z-01: Zero rated VAT rules
const hasZeroRatedLine = invoice.items?.some(item =>
item.vatPercentage === 0
);
if (hasZeroRatedLine) {
const hasZeroRatedBreakdown = invoice.taxBreakdown?.some(breakdown =>
breakdown.taxPercent === 0
);
if (!hasZeroRatedBreakdown) {
this.addError(
'BR-Z-01',
'Invoice with zero rated lines must have zero rated VAT breakdown',
'taxBreakdown'
);
}
}
}
/**
* Validate line level rules (BR-21 to BR-30)
*/
private validateLineRules(invoice: EInvoice): void {
invoice.items?.forEach((item, index) => {
// BR-21: Each Invoice line shall have an Invoice line identifier
if (!item.position && item.position !== 0) {
this.addError(
'BR-21',
`Invoice line ${index + 1} must have an identifier`,
`items[${index}].id`
);
}
// BR-22: Each Invoice line shall have an Item name
if (!item.name) {
this.addError(
'BR-22',
`Invoice line ${index + 1} must have an item name`,
`items[${index}].name`
);
}
// BR-23: An Invoice line shall have an Invoiced quantity
if (item.unitQuantity === undefined || item.unitQuantity === null) {
this.addError(
'BR-23',
`Invoice line ${index + 1} must have a quantity`,
`items[${index}].quantity`
);
}
// BR-24: An Invoice line shall have an Invoiced quantity unit of measure code
if (!item.unitType) {
this.addError(
'BR-24',
`Invoice line ${index + 1} must have a unit of measure code`,
`items[${index}].unitCode`
);
}
// BR-25: An Invoice line shall have an Invoice line net amount
const lineNetAmount = item.unitNetPrice * item.unitQuantity;
if (isNaN(lineNetAmount)) {
this.addError(
'BR-25',
`Invoice line ${index + 1} must have a valid net amount`,
`items[${index}]`
);
}
// BR-26: Each Invoice line shall have an Invoice line VAT category code
if (item.vatPercentage === undefined) {
this.addError(
'BR-26',
`Invoice line ${index + 1} must have a VAT category code`,
`items[${index}].vatPercentage`
);
}
// BR-27: Invoice line net price shall be present
if (item.unitNetPrice === undefined || item.unitNetPrice === null) {
this.addError(
'BR-27',
`Invoice line ${index + 1} must have a net price`,
`items[${index}].unitPrice`
);
}
// BR-28: Item price base quantity shall be greater than zero
const baseQuantity = 1; // Default to 1 as TAccountingDocItem doesn't have priceBaseQuantity
if (baseQuantity <= 0) {
this.addError(
'BR-28',
`Invoice line ${index + 1} price base quantity must be greater than zero`,
`items[${index}].metadata.priceBaseQuantity`,
baseQuantity,
'> 0'
);
}
});
}
/**
* Validate allowances and charges
*/
private validateAllowancesCharges(invoice: EInvoice): void {
// BR-31: Document level allowance shall have an amount
invoice.metadata?.allowances?.forEach((allowance: any, index: number) => {
if (!allowance.amount && allowance.amount !== 0) {
this.addError(
'BR-31',
`Document allowance ${index + 1} must have an amount`,
`metadata.allowances[${index}].amount`
);
}
// BR-32: Document level allowance shall have VAT category code
if (!allowance.vatCategoryCode) {
this.addError(
'BR-32',
`Document allowance ${index + 1} must have a VAT category code`,
`metadata.allowances[${index}].vatCategoryCode`
);
}
// BR-33: Document level allowance shall have a reason
if (!allowance.reason) {
this.addError(
'BR-33',
`Document allowance ${index + 1} must have a reason`,
`metadata.allowances[${index}].reason`
);
}
});
// BR-36: Document level charge shall have an amount
invoice.metadata?.charges?.forEach((charge: any, index: number) => {
if (!charge.amount && charge.amount !== 0) {
this.addError(
'BR-36',
`Document charge ${index + 1} must have an amount`,
`metadata.charges[${index}].amount`
);
}
// BR-37: Document level charge shall have VAT category code
if (!charge.vatCategoryCode) {
this.addError(
'BR-37',
`Document charge ${index + 1} must have a VAT category code`,
`metadata.charges[${index}].vatCategoryCode`
);
}
// BR-38: Document level charge shall have a reason
if (!charge.reason) {
this.addError(
'BR-38',
`Document charge ${index + 1} must have a reason`,
`metadata.charges[${index}].reason`
);
}
});
}
// Helper methods
private calculateLineTotal(items: TAccountingDocItem[]): number {
return items.reduce((sum, item) => {
const lineTotal = (item.unitNetPrice || 0) * (item.unitQuantity || 0);
const rounded = this.currencyCalculator
? this.currencyCalculator.round(lineTotal)
: lineTotal;
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) =>
sum + (allowance.amount || 0), 0
) || 0;
}
private calculateDocumentCharges(invoice: EInvoice): number {
return invoice.metadata?.charges?.reduce((sum: number, charge: any) =>
sum + (charge.amount || 0), 0
) || 0;
}
private calculateTotalVAT(invoice: EInvoice): number {
const vatGroups = this.groupItemsByVAT(invoice.items || []);
let totalVAT = 0;
vatGroups.forEach((items, rate) => {
const taxableAmount = items.reduce((sum, item) => {
const lineNet = item.unitNetPrice * item.unitQuantity;
return sum + (this.currencyCalculator ? this.currencyCalculator.round(lineNet) : lineNet);
}, 0);
const vatAmount = taxableAmount * (rate / 100);
const roundedVAT = this.currencyCalculator
? this.currencyCalculator.round(vatAmount)
: vatAmount;
totalVAT += roundedVAT;
});
return totalVAT;
}
private groupItemsByVAT(items: TAccountingDocItem[]): Map<number, TAccountingDocItem[]> {
const groups = new Map<number, TAccountingDocItem[]>();
items.forEach(item => {
const rate = item.vatPercentage || 0;
if (!groups.has(rate)) {
groups.set(rate, []);
}
groups.get(rate)!.push(item);
});
return groups;
}
private addError(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'error',
message,
field,
value,
expected
});
}
private addWarning(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'warning',
message,
field,
value,
expected
});
}
}