import { UBLBaseValidator } from './ubl.validator.js'; import { ValidationLevel } from '../../interfaces/common.js'; import { xpath } from '../../plugins.js'; /** * EN16931-compliant UBL validator that implements all business rules */ export class EN16931UBLValidator extends UBLBaseValidator { /** * Validates the structure of the UBL document */ protected validateStructure(): boolean { let valid = true; // Check for required elements const requiredElements = [ { path: '//cbc:ID', error: 'Required element cbc:ID is missing' }, { path: '//cbc:IssueDate', error: 'Required element cbc:IssueDate is missing' }, { path: '//cbc:CustomizationID', error: 'Required element cbc:CustomizationID is missing' } ]; for (const element of requiredElements) { if (!this.exists(element.path)) { this.addError('STRUCT-REQUIRED', element.error, element.path); valid = false; } } // Check for at least one invoice line or credit note line const invoiceLines = this.select('//cac:InvoiceLine', this.doc) as Node[]; const creditNoteLines = this.select('//cac:CreditNoteLine', this.doc) as Node[]; if (invoiceLines.length === 0 && creditNoteLines.length === 0) { this.addError('STRUCT-LINE', 'At least one invoice line or credit note line is required', '/'); valid = false; } return valid; } /** * Validates EN16931 business rules */ protected validateBusinessRules(): boolean { let valid = true; // BR-01: An Invoice shall have a Specification identifier (BT-24). if (!this.exists('//cbc:CustomizationID')) { this.addError('BR-01', 'An Invoice shall have a Specification identifier', '//cbc:CustomizationID'); valid = false; } // BR-02: An Invoice shall have an Invoice number (BT-1). if (!this.exists('//cbc:ID')) { this.addError('BR-02', 'An Invoice shall have an Invoice number', '//cbc:ID'); valid = false; } // BR-03: An Invoice shall have an Invoice issue date (BT-2). if (!this.exists('//cbc:IssueDate')) { this.addError('BR-03', 'An Invoice shall have an Invoice issue date', '//cbc:IssueDate'); valid = false; } // BR-04: An Invoice shall have an Invoice type code (BT-3). const isInvoice = this.doc.documentElement.localName === 'Invoice'; if (isInvoice && !this.exists('//cbc:InvoiceTypeCode')) { this.addError('BR-04', 'An Invoice shall have an Invoice type code', '//cbc:InvoiceTypeCode'); valid = false; } // BR-05: An Invoice shall have an Invoice currency code (BT-5). if (!this.exists('//cbc:DocumentCurrencyCode')) { this.addError('BR-05', 'An Invoice shall have an Invoice currency code', '//cbc:DocumentCurrencyCode'); valid = false; } // BR-06: An Invoice shall contain the Seller name (BT-27). if (!this.exists('//cac:AccountingSupplierParty//cbc:RegistrationName') && !this.exists('//cac:AccountingSupplierParty//cbc:Name')) { this.addError('BR-06', 'An Invoice shall contain the Seller name', '//cac:AccountingSupplierParty'); valid = false; } // BR-07: An Invoice shall contain the Buyer name (BT-44). if (!this.exists('//cac:AccountingCustomerParty//cbc:RegistrationName') && !this.exists('//cac:AccountingCustomerParty//cbc:Name')) { this.addError('BR-07', 'An Invoice shall contain the Buyer name', '//cac:AccountingCustomerParty'); valid = false; } // BR-08: An Invoice shall contain the Seller postal address (BG-5). const sellerAddress = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc)[0]; if (!sellerAddress || !this.exists('.//cbc:IdentificationCode', sellerAddress)) { this.addError('BR-08', 'An Invoice shall contain the Seller postal address', '//cac:AccountingSupplierParty//cac:PostalAddress'); valid = false; } // BR-09: The Seller postal address (BG-5) shall contain a Seller country code (BT-40). if (sellerAddress && !this.exists('.//cac:Country/cbc:IdentificationCode', sellerAddress)) { this.addError('BR-09', 'The Seller postal address shall contain a Seller country code', '//cac:AccountingSupplierParty//cac:PostalAddress//cac:Country'); valid = false; } // BR-10: An Invoice shall contain the Buyer postal address (BG-8). const buyerAddress = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc)[0]; if (!buyerAddress || !this.exists('.//cbc:IdentificationCode', buyerAddress)) { this.addError('BR-10', 'An Invoice shall contain the Buyer postal address', '//cac:AccountingCustomerParty//cac:PostalAddress'); valid = false; } // BR-11: The Buyer postal address (BG-8) shall contain a Buyer country code (BT-55). if (buyerAddress && !this.exists('.//cac:Country/cbc:IdentificationCode', buyerAddress)) { this.addError('BR-11', 'The Buyer postal address shall contain a Buyer country code', '//cac:AccountingCustomerParty//cac:PostalAddress//cac:Country'); valid = false; } // BR-12: An Invoice shall have the Sum of Invoice line net amount (BT-106). if (!this.exists('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount')) { this.addError('BR-12', 'An Invoice shall have the Sum of Invoice line net amount', '//cac:LegalMonetaryTotal/cbc:LineExtensionAmount'); valid = false; } // BR-13: An Invoice shall have the Invoice total amount without VAT (BT-109). if (!this.exists('//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount')) { this.addError('BR-13', 'An Invoice shall have the Invoice total amount without VAT', '//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount'); valid = false; } // BR-14: An Invoice shall have the Invoice total amount with VAT (BT-112). if (!this.exists('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount')) { this.addError('BR-14', 'An Invoice shall have the Invoice total amount with VAT', '//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount'); valid = false; } // BR-15: An Invoice shall have the Amount due for payment (BT-115). if (!this.exists('//cac:LegalMonetaryTotal/cbc:PayableAmount')) { this.addError('BR-15', 'An Invoice shall have the Amount due for payment', '//cac:LegalMonetaryTotal/cbc:PayableAmount'); valid = false; } // BR-16: An Invoice shall have at least one Invoice line (BG-25). const lines = this.select('//cac:InvoiceLine | //cac:CreditNoteLine', this.doc) as Node[]; if (lines.length === 0) { this.addError('BR-16', 'An Invoice shall have at least one Invoice line', '//cac:InvoiceLine'); valid = false; } // Validate calculation rules if we have the necessary data if (this.exists('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount')) { valid = this.validateCalculationRules() && valid; } return valid; } /** * Validates calculation rules (BR-CO-*) */ private validateCalculationRules(): boolean { let valid = true; // BR-CO-10: Sum of Invoice line net amount = Σ Invoice line net amount. const lineExtensionAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount'); const lines = this.select('//cac:InvoiceLine | //cac:CreditNoteLine', this.doc) as Node[]; let calculatedSum = 0; for (const line of lines) { const lineAmount = this.getNumber('.//cbc:LineExtensionAmount', line); calculatedSum += lineAmount; } // Allow for small rounding differences (0.01) if (Math.abs(lineExtensionAmount - calculatedSum) > 0.01) { this.addError( 'BR-CO-10', `Sum of Invoice line net amount (${lineExtensionAmount}) must equal sum of line amounts (${calculatedSum})`, '//cac:LegalMonetaryTotal/cbc:LineExtensionAmount' ); valid = false; } // BR-CO-13: Invoice total amount without VAT = Σ Invoice line net amount - Sum of allowances on document level + Sum of charges on document level. const taxExclusiveAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount'); const allowanceTotal = this.getNumber('//cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount') || 0; const chargeTotal = this.getNumber('//cac:LegalMonetaryTotal/cbc:ChargeTotalAmount') || 0; const calculatedTaxExclusive = lineExtensionAmount - allowanceTotal + chargeTotal; if (Math.abs(taxExclusiveAmount - calculatedTaxExclusive) > 0.01) { this.addError( 'BR-CO-13', `Invoice total amount without VAT (${taxExclusiveAmount}) must equal calculated amount (${calculatedTaxExclusive})`, '//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount' ); valid = false; } // BR-CO-15: Invoice total amount with VAT = Invoice total amount without VAT + Invoice total VAT amount. const taxInclusiveAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount'); const totalTaxAmount = this.getNumber('//cac:TaxTotal/cbc:TaxAmount') || 0; const calculatedTaxInclusive = taxExclusiveAmount + totalTaxAmount; if (Math.abs(taxInclusiveAmount - calculatedTaxInclusive) > 0.01) { this.addError( 'BR-CO-15', `Invoice total amount with VAT (${taxInclusiveAmount}) must equal calculated amount (${calculatedTaxInclusive})`, '//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount' ); valid = false; } return valid; } }