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(); 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 { 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 }); } }