Files
einvoice/ts/formats/validation/en16931.business-rules.validator.ts
Juergen Kunz 10e14af85b 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.
2025-08-11 12:25:32 +00:00

553 lines
18 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 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;
/**
* Validate an invoice against EN16931 business rules
*/
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
this.results = [];
// Initialize currency calculator if currency is available
if (invoice.currency) {
this.currencyCalculator = new CurrencyCalculator(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;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = this.calculateLineTotal(invoice.items);
const declaredLineTotal = invoice.totalNet || 0;
const isEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal, declaredLineTotal)
: Math.abs(calculatedLineTotal - declaredLineTotal) < 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)})`,
'totalNet',
declaredLineTotal,
calculatedLineTotal
);
}
// BR-CO-11: Sum of allowances on document level
const documentAllowances = this.calculateDocumentAllowances(invoice);
// BR-CO-12: Sum of charges on document level
const documentCharges = this.calculateDocumentCharges(invoice);
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
const expectedTaxExclusive = calculatedLineTotal - documentAllowances + documentCharges;
const declaredTaxExclusive = invoice.totalNet || 0;
const isTaxExclusiveEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: Math.abs(expectedTaxExclusive - declaredTaxExclusive) < 0.01;
if (!isTaxExclusiveEqual) {
this.addError(
'BR-CO-13',
`Tax exclusive amount (${declaredTaxExclusive.toFixed(2)}) does not match calculation (${expectedTaxExclusive.toFixed(2)})`,
'totalNet',
declaredTaxExclusive,
expectedTaxExclusive
);
}
// BR-CO-14: Invoice total VAT amount = Σ(VAT category tax amount)
const calculatedVAT = this.calculateTotalVAT(invoice);
const declaredVAT = invoice.totalVat || 0;
const isVATEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT, declaredVAT)
: Math.abs(calculatedVAT - declaredVAT) < 0.01;
if (!isVATEqual) {
this.addError(
'BR-CO-14',
`Total VAT (${declaredVAT.toFixed(2)}) does not match calculation (${calculatedVAT.toFixed(2)})`,
'totalVat',
declaredVAT,
calculatedVAT
);
}
// BR-CO-15: Invoice total with VAT = Invoice total without VAT + Invoice total VAT
const expectedGrossTotal = expectedTaxExclusive + calculatedVAT;
const declaredGrossTotal = invoice.totalGross || 0;
const isGrossEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal, declaredGrossTotal)
: Math.abs(expectedGrossTotal - declaredGrossTotal) < 0.01;
if (!isGrossEqual) {
this.addError(
'BR-CO-15',
`Gross total (${declaredGrossTotal.toFixed(2)}) does not match calculation (${expectedGrossTotal.toFixed(2)})`,
'totalGross',
declaredGrossTotal,
expectedGrossTotal
);
}
// 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 isDueEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount, declaredDueAmount)
: Math.abs(expectedDueAmount - declaredDueAmount) < 0.01;
if (!isDueEqual) {
this.addError(
'BR-CO-16',
`Amount due (${declaredDueAmount.toFixed(2)}) does not match calculation (${expectedDueAmount.toFixed(2)})`,
'amountDue',
declaredDueAmount,
expectedDueAmount
);
}
}
/**
* Validate VAT rules
*/
private validateVATRules(invoice: EInvoice): void {
// 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 = group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxAmount = expectedTaxableAmount * (rate / 100);
// Find corresponding breakdown
const breakdown = invoice.taxBreakdown?.find(b =>
Math.abs((b.taxPercent || 0) - rate) < 0.01
);
if (breakdown) {
const isTaxableEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount)
: Math.abs(breakdown.netAmount - expectedTaxableAmount) < 0.01;
if (!isTaxableEqual) {
this.addError(
'BR-S-02',
`VAT taxable amount for ${rate}% incorrect`,
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxableAmount
);
}
const isTaxEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount)
: Math.abs(breakdown.taxAmount - expectedTaxAmount) < 0.01;
if (!isTaxEqual) {
this.addError(
'BR-S-03',
`VAT tax amount for ${rate}% incorrect`,
'taxBreakdown.vatAmount',
breakdown.taxAmount,
expectedTaxAmount
);
}
}
}
});
// 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);
}
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
});
}
}