import { BaseValidator } from '../base/base.validator.js'; import { ValidationLevel } from '../../interfaces/common.js'; import type { ValidationResult } from '../../interfaces/common.js'; import { CII_NAMESPACES, CIIProfile } from './cii.types.js'; import { DOMParser } from 'xmldom'; import * as xpath from 'xpath'; /** * Base validator for CII-based invoice formats */ export abstract class CIIBaseValidator extends BaseValidator { protected doc: Document; protected namespaces: Record; protected select: xpath.XPathSelect; protected profile: CIIProfile = CIIProfile.EN16931; constructor(xml: string) { super(xml); try { // Parse XML document this.doc = new DOMParser().parseFromString(xml, 'application/xml'); // Set up namespaces for XPath queries this.namespaces = { rsm: CII_NAMESPACES.RSM, ram: CII_NAMESPACES.RAM, udt: CII_NAMESPACES.UDT }; // Create XPath selector with namespaces this.select = xpath.useNamespaces(this.namespaces); // Detect profile this.detectProfile(); } catch (error) { this.addError('CII-PARSE', `Failed to parse XML: ${error}`, '/'); } } /** * Validates CII XML against the specified level of validation * @param level Validation level * @returns Result of validation */ public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult { // Reset errors this.errors = []; // Check if document was parsed successfully if (!this.doc) { 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 CII XML against schema * @returns True if schema validation passed */ protected validateSchema(): boolean { // Basic schema validation (simplified for now) if (!this.doc) return false; // Check for root element const root = this.doc.documentElement; if (!root || root.nodeName !== 'rsm:CrossIndustryInvoice') { this.addError('CII-SCHEMA-1', 'Root element must be rsm:CrossIndustryInvoice', '/'); return false; } // Check for required namespaces if (!root.lookupNamespaceURI('rsm') || !root.lookupNamespaceURI('ram')) { this.addError('CII-SCHEMA-2', 'Required namespaces rsm and ram must be declared', '/'); return false; } return true; } /** * Validates structure of the CII XML document * @returns True if structure validation passed */ protected abstract validateStructure(): boolean; /** * Detects the CII profile from the XML */ protected detectProfile(): void { // Look for profile identifier const profileNode = this.select( 'string(//rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID)', this.doc ); if (profileNode) { const profileText = profileNode.toString(); if (profileText.includes('BASIC')) { this.profile = CIIProfile.BASIC; } else if (profileText.includes('EN16931')) { this.profile = CIIProfile.EN16931; } else if (profileText.includes('EXTENDED')) { this.profile = CIIProfile.EXTENDED; } else if (profileText.includes('MINIMUM')) { this.profile = CIIProfile.MINIMUM; } else if (profileText.includes('COMFORT')) { this.profile = CIIProfile.COMFORT; } } } /** * Gets a text value from an XPath expression * @param xpath XPath expression * @param context Optional context node * @returns Text value or empty string if not found */ protected getText(xpathExpr: string, context?: Node): string { const node = this.select(xpathExpr, context || this.doc)[0]; return node ? (node.textContent || '') : ''; } /** * Gets a number value from an XPath expression * @param xpath XPath expression * @param context Optional context node * @returns Number value or 0 if not found or not a number */ protected getNumber(xpathExpr: string, context?: Node): number { const text = this.getText(xpathExpr, context); const num = parseFloat(text); return isNaN(num) ? 0 : num; } /** * Checks if a node exists * @param xpath XPath expression * @param context Optional context node * @returns True if node exists */ protected exists(xpathExpr: string, context?: Node): boolean { const nodes = this.select(xpathExpr, context || this.doc); if (Array.isArray(nodes)) { return nodes.length > 0; } return false; } }