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 { const groups = new Map(); 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 }); } }