diff --git a/package.json b/package.json index f5df074..e3d04ac 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "jsdom": "^24.1.3", "pako": "^2.1.0", "pdf-lib": "^1.17.1", - "xmldom": "^0.6.0" + "xmldom": "^0.6.0", + "xpath": "^0.0.34" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e808355..a3d632f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: xmldom: specifier: ^0.6.0 version: 0.6.0 + xpath: + specifier: ^0.0.34 + version: 0.0.34 devDependencies: '@git.zone/tsbuild': specifier: ^2.2.7 @@ -4340,6 +4343,10 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -10188,6 +10195,8 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} + xpath@0.0.34: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/test/test.validators.ts b/test/test.validators.ts new file mode 100644 index 0000000..5bb1a78 --- /dev/null +++ b/test/test.validators.ts @@ -0,0 +1,70 @@ +import { tap, expect } from '@push.rocks/tapbundle'; +import * as getInvoices from './assets/getasset.js'; +import { ValidatorFactory } from '../ts/formats/validator.factory.js'; +import { ValidationLevel } from '../ts/interfaces.js'; +import { validateXml } from '../ts/index.js'; + +// Test ValidatorFactory format detection +tap.test('ValidatorFactory should detect UBL format', async () => { + const path = getInvoices.invoices.XMLRechnung.UBL['EN16931_Einfach.ubl.xml']; + const invoice = await getInvoices.getInvoice(path); + const xml = invoice.toString('utf8'); + + const validator = ValidatorFactory.createValidator(xml); + expect(validator.constructor.name).toBeTypeOf('string'); + expect(validator.constructor.name).toInclude('UBL'); +}); + +tap.test('ValidatorFactory should detect CII/Factur-X format', async () => { + const path = getInvoices.invoices.XMLRechnung.CII['EN16931_Einfach.cii.xml']; + const invoice = await getInvoices.getInvoice(path); + const xml = invoice.toString('utf8'); + + const validator = ValidatorFactory.createValidator(xml); + expect(validator.constructor.name).toBeTypeOf('string'); + expect(validator.constructor.name).toInclude('FacturX'); +}); + +// Test UBL validation +tap.test('UBL validator should validate valid XML at syntax level', async () => { + const path = getInvoices.invoices.XMLRechnung.UBL['EN16931_Einfach.ubl.xml']; + const invoice = await getInvoices.getInvoice(path); + const xml = invoice.toString('utf8'); + + const result = validateXml(xml, ValidationLevel.SYNTAX); + expect(result.valid).toBeTrue(); + expect(result.errors.length).toEqual(0); +}); + +// Test CII validation +tap.test('CII validator should validate valid XML at syntax level', async () => { + const path = getInvoices.invoices.XMLRechnung.CII['EN16931_Einfach.cii.xml']; + const invoice = await getInvoices.getInvoice(path); + const xml = invoice.toString('utf8'); + + const result = validateXml(xml, ValidationLevel.SYNTAX); + expect(result.valid).toBeTrue(); + expect(result.errors.length).toEqual(0); +}); + +// Test XInvoice integration +tap.test('XInvoice class should validate invoices on load when requested', async () => { + // Import XInvoice dynamically to prevent circular dependencies + const { XInvoice } = await import('../ts/index.js'); + const invoice = new XInvoice(); + + // Load a UBL invoice with validation + const path = getInvoices.invoices.XMLRechnung.UBL['EN16931_Einfach.ubl.xml']; + const invoiceBuffer = await getInvoices.getInvoice(path); + const xml = invoiceBuffer.toString('utf8'); + + // Add XML with validation enabled + await invoice.addXmlString(xml, true); + + // Check validation results + expect(invoice.isValid()).toBeTrue(); + expect(invoice.getValidationErrors().length).toEqual(0); +}); + +// Mark the test file as complete +tap.start(); \ No newline at end of file diff --git a/ts/classes.xinvoice.ts b/ts/classes.xinvoice.ts index c3d6ddc..960545b 100644 --- a/ts/classes.xinvoice.ts +++ b/ts/classes.xinvoice.ts @@ -11,6 +11,8 @@ import { import { FacturXEncoder } from './formats/facturx.encoder.js'; import { DecoderFactory } from './formats/decoder.factory.js'; import { BaseDecoder } from './formats/base.decoder.js'; +import { ValidatorFactory } from './formats/validator.factory.js'; +import { BaseValidator } from './formats/base.validator.js'; export class XInvoice { private xmlString: string; @@ -19,6 +21,10 @@ export class XInvoice { private encoderInstance = new FacturXEncoder(); private decoderInstance: BaseDecoder; + private validatorInstance: BaseValidator; + + // Validation errors from last validation + private validationErrors: interfaces.ValidationError[] = []; constructor() { // Decoder will be initialized when we have XML data @@ -28,7 +34,7 @@ export class XInvoice { this.pdfUint8Array = Uint8Array.from(pdfBuffer); } - public async addXmlString(xmlString: string): Promise { + public async addXmlString(xmlString: string, validate: boolean = false): Promise { // Basic XML validation - just check if it starts with { + if (!this.xmlString) { + throw new Error('No XML to validate. Use addXmlString() first.'); + } + + if (!this.validatorInstance) { + // Initialize the validator with the XML string if not already done + this.validatorInstance = ValidatorFactory.createValidator(this.xmlString); + } + + // Run validation + const result = this.validatorInstance.validate(level); + + // Store validation errors + this.validationErrors = result.errors; + + return result; + } + + /** + * Checks if the document is valid based on the last validation + * @returns True if the document is valid + */ + public isValid(): boolean { + if (!this.validatorInstance) { + return false; + } + + return this.validatorInstance.isValid(); + } + + /** + * Gets validation errors from the last validation + * @returns Array of validation errors + */ + public getValidationErrors(): interfaces.ValidationError[] { + return this.validationErrors; } public async addLetterData(letterData: plugins.tsclass.business.ILetter): Promise { @@ -183,13 +241,28 @@ export class XInvoice { if (xmlContent.includes('CrossIndustryInvoice') || xmlContent.includes('rsm:') || xmlContent.includes('ram:')) { - return 'ZUGFeRD/CII'; + + // Check for specific profiles + if (xmlContent.includes('factur-x') || xmlContent.includes('Factur-X')) { + return 'Factur-X'; + } + if (xmlContent.includes('zugferd') || xmlContent.includes('ZUGFeRD')) { + return 'ZUGFeRD'; + } + + return 'CII'; } // Check for UBL if (xmlContent.includes(' { if (!this.xmlString && !this.pdfUint8Array) { diff --git a/ts/formats/base.validator.ts b/ts/formats/base.validator.ts new file mode 100644 index 0000000..82e4083 --- /dev/null +++ b/ts/formats/base.validator.ts @@ -0,0 +1,64 @@ +import { ValidationLevel } from '../interfaces.js'; +import type { ValidationResult, ValidationError } from '../interfaces.js'; + +/** + * Base validator class that defines common validation functionality + * for all invoice format validators + */ +export abstract class BaseValidator { + protected xml: string; + protected errors: ValidationError[] = []; + + constructor(xml: string) { + this.xml = xml; + } + + /** + * Validates XML against the specified level of validation + * @param level Validation level (syntax, semantic, business) + * @returns Result of validation + */ + abstract validate(level?: ValidationLevel): ValidationResult; + + /** + * Gets all validation errors found during validation + * @returns Array of validation errors + */ + public getValidationErrors(): ValidationError[] { + return this.errors; + } + + /** + * Checks if the document is valid + * @returns True if no validation errors were found + */ + public isValid(): boolean { + return this.errors.length === 0; + } + + /** + * Validates XML against schema + * @returns True if schema validation passed + */ + protected abstract validateSchema(): boolean; + + /** + * Validates business rules + * @returns True if business rule validation passed + */ + protected abstract validateBusinessRules(): boolean; + + /** + * Adds an error to the validation errors list + * @param code Error code + * @param message Error message + * @param location Location in the XML where the error occurred + */ + protected addError(code: string, message: string, location: string = ''): void { + this.errors.push({ + code, + message, + location + }); + } +} \ No newline at end of file diff --git a/ts/formats/facturx.validator.ts b/ts/formats/facturx.validator.ts new file mode 100644 index 0000000..13109fa --- /dev/null +++ b/ts/formats/facturx.validator.ts @@ -0,0 +1,322 @@ +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 Factur-X/ZUGFeRD invoice format + * Implements validation rules according to EN16931 and Factur-X specification + */ +export class FacturXValidator extends BaseValidator { + // XML namespaces for Factur-X/ZUGFeRD + private static NS_RSMT = 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'; + private static NS_RAM = 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'; + private static NS_UDT = 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'; + + // XML document for processing + private xmlDoc: Document | null = null; + + // Factur-X profile (BASIC, EN16931, EXTENDED, etc.) + private profile: string = ''; + + constructor(xml: string) { + super(xml); + + try { + // Parse XML document + this.xmlDoc = new DOMParser().parseFromString(xml, 'application/xml'); + + // Determine Factur-X profile + this.detectProfile(); + } catch (error) { + this.addError('FX-PARSE', `Failed to parse XML: ${error}`, '/'); + } + } + + /** + * Validates the Factur-X 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 !== 'rsm:CrossIndustryInvoice') { + this.addError('FX-SCHEMA-1', 'Root element must be rsm:CrossIndustryInvoice', '/'); + return false; + } + + // Check for required namespaces + if (!root.lookupNamespaceURI('rsm') || !root.lookupNamespaceURI('ram')) { + this.addError('FX-SCHEMA-2', 'Required namespaces rsm and ram 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 = [ + 'rsm:ExchangedDocumentContext', + 'rsm:ExchangedDocument', + 'rsm:SupplyChainTradeTransaction' + ]; + + for (const section of sections) { + if (!this.exists(section)) { + this.addError('FX-STRUCT-1', `Required section ${section} is missing`, '/rsm:CrossIndustryInvoice'); + valid = false; + } + } + + // Check for SupplyChainTradeTransaction sections + if (this.exists('rsm:SupplyChainTradeTransaction')) { + const tradeSubsections = [ + 'ram:ApplicableHeaderTradeAgreement', + 'ram:ApplicableHeaderTradeDelivery', + 'ram:ApplicableHeaderTradeSettlement' + ]; + + for (const subsection of tradeSubsections) { + if (!this.exists(`rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeTransaction/${subsection}`)) { + this.addError('FX-STRUCT-2', `Required subsection ${subsection} is missing`, + '/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeTransaction'); + 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 (BG-25) where the Invoiced item VAT category code (BT-151) is "Standard rated" + // shall contain the Seller VAT Identifier (BT-31), the Seller tax registration identifier (BT-32) + // and/or the Seller tax representative VAT identifier (BT-63). + valid = this.validateSellerVatIdentifier() && valid; + + return valid; + } + + /** + * Detects Factur-X profile from the XML + */ + private detectProfile(): void { + if (!this.xmlDoc) return; + + // Look for profile identifier + const profileNode = xpath.select1( + 'string(//rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID)', + this.xmlDoc + ); + + if (profileNode) { + const profileText = profileNode.toString(); + + if (profileText.includes('BASIC')) { + this.profile = 'BASIC'; + } else if (profileText.includes('EN16931')) { + this.profile = 'EN16931'; + } else if (profileText.includes('EXTENDED')) { + this.profile = 'EXTENDED'; + } else if (profileText.includes('MINIMUM')) { + this.profile = 'MINIMUM'; + } + } + } + + /** + * 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( + '//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount' + ); + + const paidAmount = this.getNumberValue( + '//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TotalPrepaidAmount' + ) || 0; + + const dueAmount = this.getNumberValue( + '//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:DuePayableAmount' + ); + + // 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})`, + '//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation' + ); + return false; + } + + return true; + } catch (error) { + this.addError('FX-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('//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:TaxPointDate'); + const vatPointDateCode = this.exists('//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:DueDateTypeCode'); + + if (vatPointDate && vatPointDateCode) { + this.addError( + 'BR-CO-3', + 'Value added tax point date and Value added tax point date code are mutually exclusive', + '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax' + ); + return false; + } + + return true; + } catch (error) { + this.addError('FX-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( + '//ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode[text()="S"]' + ); + + if (standardRatedItems) { + // Check for seller VAT identifier + const sellerVatId = this.exists('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]'); + const sellerTaxId = this.exists('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]'); + const sellerTaxRepId = this.exists('//ram:ApplicableHeaderTradeAgreement/ram:SellerTaxRepresentativeTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]'); + + if (!sellerVatId && !sellerTaxId && !sellerTaxRepId) { + this.addError( + 'BR-S-1', + 'An Invoice with standard rated items must contain the Seller VAT Identifier, Tax registration identifier or Tax representative VAT identifier', + '//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty' + ); + return false; + } + } + + return true; + } catch (error) { + this.addError('FX-VAT', `Error validating seller VAT identifier: ${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; + } +} \ No newline at end of file diff --git a/ts/formats/ubl.validator.ts b/ts/formats/ubl.validator.ts new file mode 100644 index 0000000..7e691c9 --- /dev/null +++ b/ts/formats/ubl.validator.ts @@ -0,0 +1,382 @@ +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; + } +} \ No newline at end of file diff --git a/ts/formats/validator.factory.ts b/ts/formats/validator.factory.ts new file mode 100644 index 0000000..cebabbe --- /dev/null +++ b/ts/formats/validator.factory.ts @@ -0,0 +1,92 @@ +import { InvoiceFormat } from '../interfaces.js'; +import type { IValidator } from '../interfaces.js'; +import { BaseValidator } from './base.validator.js'; +import { FacturXValidator } from './facturx.validator.js'; +import { UBLValidator } from './ubl.validator.js'; +import { DOMParser } from 'xmldom'; + +/** + * Factory to create the appropriate validator based on the XML format + */ +export class ValidatorFactory { + /** + * Creates a validator for the specified XML content + * @param xml XML content to validate + * @returns Appropriate validator instance + */ + public static createValidator(xml: string): BaseValidator { + const format = ValidatorFactory.detectFormat(xml); + + switch (format) { + case InvoiceFormat.UBL: + case InvoiceFormat.XRECHNUNG: + return new UBLValidator(xml); + + case InvoiceFormat.CII: + case InvoiceFormat.ZUGFERD: + case InvoiceFormat.FACTURX: + return new FacturXValidator(xml); + + // FatturaPA and other formats would be implemented here + + default: + throw new Error(`Unsupported invoice format: ${format}`); + } + } + + /** + * Detects the invoice format from XML content + * @param xml XML content to analyze + * @returns Detected invoice format + */ + private static detectFormat(xml: string): InvoiceFormat { + try { + const doc = new DOMParser().parseFromString(xml, 'application/xml'); + const root = doc.documentElement; + + if (!root) { + return InvoiceFormat.UNKNOWN; + } + + // UBL detection (Invoice or CreditNote root element) + if (root.nodeName === 'Invoice' || root.nodeName === 'CreditNote') { + // Check if it's XRechnung by looking at CustomizationID + const customizationNodes = root.getElementsByTagName('cbc:CustomizationID'); + if (customizationNodes.length > 0) { + const customizationId = customizationNodes[0].textContent || ''; + if (customizationId.includes('xrechnung') || customizationId.includes('XRechnung')) { + return InvoiceFormat.XRECHNUNG; + } + } + + return InvoiceFormat.UBL; + } + + // Factur-X/ZUGFeRD detection (CrossIndustryInvoice root element) + if (root.nodeName === 'rsm:CrossIndustryInvoice' || root.nodeName === 'CrossIndustryInvoice') { + // Check for profile to determine if it's Factur-X or ZUGFeRD + const profileNodes = root.getElementsByTagName('ram:ID'); + for (let i = 0; i < profileNodes.length; i++) { + const profileText = profileNodes[i].textContent || ''; + + if (profileText.includes('factur-x') || profileText.includes('Factur-X')) { + return InvoiceFormat.FACTURX; + } + + if (profileText.includes('zugferd') || profileText.includes('ZUGFeRD')) { + return InvoiceFormat.ZUGFERD; + } + } + + // If no specific profile found, default to CII + return InvoiceFormat.CII; + } + + // FatturaPA detection would be implemented here + + return InvoiceFormat.UNKNOWN; + } catch (error) { + return InvoiceFormat.UNKNOWN; + } + } +} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts index 2ea5c00..0bdc383 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -9,7 +9,28 @@ import { XInvoiceDecoder } from './formats/xinvoice.decoder.js'; import { DecoderFactory } from './formats/decoder.factory.js'; import { BaseDecoder } from './formats/base.decoder.js'; -// Export interfaces +// Import validator classes +import { ValidatorFactory } from './formats/validator.factory.js'; +import { BaseValidator } from './formats/base.validator.js'; +import { FacturXValidator } from './formats/facturx.validator.js'; +import { UBLValidator } from './formats/ubl.validator.js'; + +// Export specific interfaces for easier use +export type { + IXInvoice, + IParty, + IAddress, + IContact, + IInvoiceItem, + ValidationError, + ValidationResult, + ValidationLevel, + InvoiceFormat, + XInvoiceOptions, + IValidator +} from './interfaces.js'; + +// Export interfaces (legacy support) export { interfaces }; // Export main class @@ -30,6 +51,47 @@ export { XInvoiceDecoder }; +// Export validator classes +export const Validators = { + ValidatorFactory, + BaseValidator, + FacturXValidator, + UBLValidator +}; + // For backward compatibility export { FacturXEncoder as ZugferdXmlEncoder }; -export { FacturXDecoder as ZUGFeRDXmlDecoder }; \ No newline at end of file +export { FacturXDecoder as ZUGFeRDXmlDecoder }; + +/** + * Validates an XML string against the appropriate format rules + * @param xml XML content to validate + * @param level Validation level (syntax, semantic, business) + * @returns ValidationResult with the result of validation + */ +export function validateXml( + xml: string, + level: interfaces.ValidationLevel = interfaces.ValidationLevel.SYNTAX +): interfaces.ValidationResult { + try { + const validator = ValidatorFactory.createValidator(xml); + return validator.validate(level); + } catch (error) { + return { + valid: false, + errors: [{ + code: 'VAL-ERROR', + message: `Validation error: ${error.message}` + }], + level + }; + } +} + +/** + * Creates a new XInvoice instance + * @returns A new XInvoice instance + */ +export function createXInvoice(): XInvoice { + return new XInvoice(); +} \ No newline at end of file diff --git a/ts/interfaces.ts b/ts/interfaces.ts index b828a02..6f97551 100644 --- a/ts/interfaces.ts +++ b/ts/interfaces.ts @@ -31,3 +31,60 @@ export interface IInvoiceItem { UnitPrice: number; TotalPrice: number; } + +/** + * Supported electronic invoice formats + */ +export enum InvoiceFormat { + UNKNOWN = 'unknown', + UBL = 'ubl', // Universal Business Language + CII = 'cii', // Cross-Industry Invoice + ZUGFERD = 'zugferd', // ZUGFeRD (German e-invoice format) + FACTURX = 'facturx', // Factur-X (French e-invoice format) + XRECHNUNG = 'xrechnung', // XRechnung (German e-invoice implementation of EN16931) + FATTURAPA = 'fatturapa' // FatturaPA (Italian e-invoice format) +} + +/** + * Describes a validation level for invoice validation + */ +export enum ValidationLevel { + SYNTAX = 'syntax', // Schema validation only + SEMANTIC = 'semantic', // Semantic validation (field types, required fields, etc.) + BUSINESS = 'business' // Business rule validation +} + +/** + * Describes a validation error + */ +export interface ValidationError { + code: string; // Error code (e.g. "BR-16") + message: string; // Error message + location?: string; // XPath or location in the document +} + +/** + * Result of a validation operation + */ +export interface ValidationResult { + valid: boolean; // Overall validation result + errors: ValidationError[]; // List of validation errors + level: ValidationLevel; // The level that was validated +} + +/** + * Options for the XInvoice class + */ +export interface XInvoiceOptions { + validateOnLoad?: boolean; // Whether to validate when loading an invoice + validationLevel?: ValidationLevel; // Level of validation to perform +} + +/** + * Interface for validator implementations + */ +export interface IValidator { + validate(level?: ValidationLevel): ValidationResult; + isValid(): boolean; + getValidationErrors(): ValidationError[]; +}