import { BaseValidator } from './base.validator.js'; import { ValidationLevel } from '../interfaces.js'; import type { ValidationResult, ValidationError } from '../interfaces.js'; import * as xpath from 'xpath'; import { DOMParser } from 'xmldom'; /** * Validator for UBL (Universal Business Language) invoice format * Implements validation rules according to EN16931 and UBL 2.1 specification */ export class UBLValidator extends BaseValidator { // XML namespaces for UBL private static NS_INVOICE = 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'; private static NS_CAC = 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'; private static NS_CBC = 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'; // XML document for processing private xmlDoc: Document | null = null; // UBL profile or customization ID private customizationId: string = ''; constructor(xml: string) { super(xml); try { // Parse XML document this.xmlDoc = new DOMParser().parseFromString(xml, 'application/xml'); // Determine UBL customization ID (e.g. EN16931, XRechnung) this.detectCustomizationId(); } catch (error) { this.addError('UBL-PARSE', `Failed to parse XML: ${error}`, '/'); } } /** * Validates the UBL invoice against the specified level * @param level Validation level * @returns Validation result */ public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult { // Reset errors this.errors = []; // Check if document was parsed successfully if (!this.xmlDoc) { return { valid: false, errors: this.errors, level: level }; } // Perform validation based on level let valid = true; if (level === ValidationLevel.SYNTAX) { valid = this.validateSchema(); } else if (level === ValidationLevel.SEMANTIC) { valid = this.validateSchema() && this.validateStructure(); } else if (level === ValidationLevel.BUSINESS) { valid = this.validateSchema() && this.validateStructure() && this.validateBusinessRules(); } return { valid, errors: this.errors, level }; } /** * Validates XML against schema * @returns True if schema validation passed */ protected validateSchema(): boolean { // Basic schema validation (simplified for now) if (!this.xmlDoc) return false; // Check for root element const root = this.xmlDoc.documentElement; if (!root || (root.nodeName !== 'Invoice' && root.nodeName !== 'CreditNote')) { this.addError('UBL-SCHEMA-1', 'Root element must be Invoice or CreditNote', '/'); return false; } // Check for required namespaces if (!root.lookupNamespaceURI('cac') || !root.lookupNamespaceURI('cbc')) { this.addError('UBL-SCHEMA-2', 'Required namespaces cac and cbc must be declared', '/'); return false; } return true; } /** * Validates structure of the XML document * @returns True if structure validation passed */ private validateStructure(): boolean { if (!this.xmlDoc) return false; let valid = true; // Check for required main sections const sections = [ 'cbc:ID', 'cbc:IssueDate', 'cac:AccountingSupplierParty', 'cac:AccountingCustomerParty', 'cac:LegalMonetaryTotal' ]; for (const section of sections) { if (!this.exists(`/${this.getRootNodeName()}/${section}`)) { this.addError('UBL-STRUCT-1', `Required section ${section} is missing`, `/${this.getRootNodeName()}`); valid = false; } } // Check for TaxTotal section if (this.exists(`/${this.getRootNodeName()}/cac:TaxTotal`)) { const taxSubsections = [ 'cbc:TaxAmount', 'cac:TaxSubtotal' ]; for (const subsection of taxSubsections) { if (!this.exists(`/${this.getRootNodeName()}/cac:TaxTotal/${subsection}`)) { this.addError('UBL-STRUCT-2', `Required subsection ${subsection} is missing`, `/${this.getRootNodeName()}/cac:TaxTotal`); valid = false; } } } return valid; } /** * Validates business rules * @returns True if business rule validation passed */ protected validateBusinessRules(): boolean { if (!this.xmlDoc) return false; let valid = true; // BR-16: Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) - Paid amount (BT-113) valid = this.validateAmounts() && valid; // BR-CO-3: Value added tax point date (BT-7) and Value added tax point date code (BT-8) are mutually exclusive valid = this.validateMutuallyExclusiveFields() && valid; // BR-S-1: An Invoice that contains a line where the VAT category code is "Standard rated" // shall contain the Seller VAT Identifier or the Seller tax representative VAT identifier valid = this.validateSellerVatIdentifier() && valid; // XRechnung specific rules when customization ID matches if (this.isXRechnung()) { valid = this.validateXRechnungRules() && valid; } return valid; } /** * Gets the root node name (Invoice or CreditNote) * @returns Root node name */ private getRootNodeName(): string { if (!this.xmlDoc || !this.xmlDoc.documentElement) return 'Invoice'; return this.xmlDoc.documentElement.nodeName; } /** * Detects UBL customization ID from the XML */ private detectCustomizationId(): void { if (!this.xmlDoc) return; // Look for customization ID const customizationNode = xpath.select1( `string(/${this.getRootNodeName()}/cbc:CustomizationID)`, this.xmlDoc ); if (customizationNode) { this.customizationId = customizationNode.toString(); } } /** * Checks if invoice is an XRechnung * @returns True if XRechnung customization ID is present */ private isXRechnung(): boolean { return this.customizationId.includes('xrechnung') || this.customizationId.includes('XRechnung'); } /** * Validates amount calculations in the invoice * @returns True if amount validation passed */ private validateAmounts(): boolean { if (!this.xmlDoc) return false; try { // Extract amounts const totalAmount = this.getNumberValue( `/${this.getRootNodeName()}/cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount` ); const paidAmount = this.getNumberValue( `/${this.getRootNodeName()}/cac:LegalMonetaryTotal/cbc:PrepaidAmount` ) || 0; const dueAmount = this.getNumberValue( `/${this.getRootNodeName()}/cac:LegalMonetaryTotal/cbc:PayableAmount` ); // Calculate expected due amount const expectedDueAmount = totalAmount - paidAmount; // Compare with a small tolerance for rounding errors if (Math.abs(dueAmount - expectedDueAmount) > 0.01) { this.addError( 'BR-16', `Amount due for payment (${dueAmount}) must equal Invoice total amount with VAT (${totalAmount}) - Paid amount (${paidAmount})`, `/${this.getRootNodeName()}/cac:LegalMonetaryTotal` ); return false; } return true; } catch (error) { this.addError('UBL-AMOUNT', `Error validating amounts: ${error}`, '/'); return false; } } /** * Validates mutually exclusive fields * @returns True if validation passed */ private validateMutuallyExclusiveFields(): boolean { if (!this.xmlDoc) return false; try { // Check for VAT point date and code (BR-CO-3) const vatPointDate = this.exists(`/${this.getRootNodeName()}/cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory/cbc:TaxPointDate`); const vatPointDateCode = this.exists(`/${this.getRootNodeName()}/cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory/cbc:TaxExemptionReasonCode`); if (vatPointDate && vatPointDateCode) { this.addError( 'BR-CO-3', 'Value added tax point date and Value added tax point date code are mutually exclusive', `/${this.getRootNodeName()}/cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory` ); return false; } return true; } catch (error) { this.addError('UBL-MUTUAL', `Error validating mutually exclusive fields: ${error}`, '/'); return false; } } /** * Validates seller VAT identifier requirements * @returns True if validation passed */ private validateSellerVatIdentifier(): boolean { if (!this.xmlDoc) return false; try { // Check if there are any standard rated line items const standardRatedItems = this.exists( `/${this.getRootNodeName()}/cac:InvoiceLine/cac:Item/cac:ClassifiedTaxCategory/cbc:ID[text()="S"]` ); if (standardRatedItems) { // Check for seller VAT identifier const sellerVatId = this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID`); const sellerTaxRepId = this.exists(`/${this.getRootNodeName()}/cac:TaxRepresentativeParty/cac:PartyTaxScheme/cbc:CompanyID`); if (!sellerVatId && !sellerTaxRepId) { this.addError( 'BR-S-1', 'An Invoice with standard rated items must contain the Seller VAT Identifier or Tax representative VAT identifier', `/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party` ); return false; } } return true; } catch (error) { this.addError('UBL-VAT', `Error validating seller VAT identifier: ${error}`, '/'); return false; } } /** * Validates XRechnung specific rules * @returns True if validation passed */ private validateXRechnungRules(): boolean { if (!this.xmlDoc) return false; let valid = true; try { // BR-DE-1: Buyer reference must be present for German VAT compliance if (!this.exists(`/${this.getRootNodeName()}/cbc:BuyerReference`)) { this.addError( 'BR-DE-1', 'BuyerReference is mandatory for XRechnung', `/${this.getRootNodeName()}` ); valid = false; } // BR-DE-15: Contact information must be present if (!this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cac:Contact`)) { this.addError( 'BR-DE-15', 'Supplier contact information is mandatory for XRechnung', `/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party` ); valid = false; } // BR-DE-16: Electronic address identifier scheme (e.g. PEPPOL) must be present if (!this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID`) || !this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID`)) { this.addError( 'BR-DE-16', 'Supplier electronic address with scheme identifier is mandatory for XRechnung', `/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party` ); valid = false; } return valid; } catch (error) { this.addError('UBL-XRECHNUNG', `Error validating XRechnung rules: ${error}`, '/'); return false; } } /** * Helper method to check if a node exists * @param xpathExpression XPath to check * @returns True if node exists */ private exists(xpathExpression: string): boolean { if (!this.xmlDoc) return false; const nodes = xpath.select(xpathExpression, this.xmlDoc); // Handle different return types from xpath.select() if (Array.isArray(nodes)) { return nodes.length > 0; } return nodes ? true : false; } /** * Helper method to get a number value from XPath * @param xpathExpression XPath to get number from * @returns Number value or NaN if not found */ private getNumberValue(xpathExpression: string): number { if (!this.xmlDoc) return NaN; const node = xpath.select1(`string(${xpathExpression})`, this.xmlDoc); return node ? parseFloat(node.toString()) : NaN; } }