feat(validation): add validators
This commit is contained in:
parent
e929281861
commit
3fe7446a29
@ -28,7 +28,8 @@
|
|||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"xmldom": "^0.6.0"
|
"xmldom": "^0.6.0",
|
||||||
|
"xpath": "^0.0.34"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
|||||||
xmldom:
|
xmldom:
|
||||||
specifier: ^0.6.0
|
specifier: ^0.6.0
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
|
xpath:
|
||||||
|
specifier: ^0.0.34
|
||||||
|
version: 0.0.34
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^2.2.7
|
specifier: ^2.2.7
|
||||||
@ -4340,6 +4343,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
xpath@0.0.34:
|
||||||
|
resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==}
|
||||||
|
engines: {node: '>=0.6.0'}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@ -10188,6 +10195,8 @@ snapshots:
|
|||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2: {}
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
|
xpath@0.0.34: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
70
test/test.validators.ts
Normal file
70
test/test.validators.ts
Normal file
@ -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();
|
@ -11,6 +11,8 @@ import {
|
|||||||
import { FacturXEncoder } from './formats/facturx.encoder.js';
|
import { FacturXEncoder } from './formats/facturx.encoder.js';
|
||||||
import { DecoderFactory } from './formats/decoder.factory.js';
|
import { DecoderFactory } from './formats/decoder.factory.js';
|
||||||
import { BaseDecoder } from './formats/base.decoder.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 {
|
export class XInvoice {
|
||||||
private xmlString: string;
|
private xmlString: string;
|
||||||
@ -19,6 +21,10 @@ export class XInvoice {
|
|||||||
|
|
||||||
private encoderInstance = new FacturXEncoder();
|
private encoderInstance = new FacturXEncoder();
|
||||||
private decoderInstance: BaseDecoder;
|
private decoderInstance: BaseDecoder;
|
||||||
|
private validatorInstance: BaseValidator;
|
||||||
|
|
||||||
|
// Validation errors from last validation
|
||||||
|
private validationErrors: interfaces.ValidationError[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Decoder will be initialized when we have XML data
|
// Decoder will be initialized when we have XML data
|
||||||
@ -28,7 +34,7 @@ export class XInvoice {
|
|||||||
this.pdfUint8Array = Uint8Array.from(pdfBuffer);
|
this.pdfUint8Array = Uint8Array.from(pdfBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addXmlString(xmlString: string): Promise<void> {
|
public async addXmlString(xmlString: string, validate: boolean = false): Promise<void> {
|
||||||
// Basic XML validation - just check if it starts with <?xml
|
// Basic XML validation - just check if it starts with <?xml
|
||||||
if (!xmlString || !xmlString.trim().startsWith('<?xml')) {
|
if (!xmlString || !xmlString.trim().startsWith('<?xml')) {
|
||||||
throw new Error('Invalid XML: Missing XML declaration');
|
throw new Error('Invalid XML: Missing XML declaration');
|
||||||
@ -39,6 +45,58 @@ export class XInvoice {
|
|||||||
|
|
||||||
// Initialize the decoder with the XML string using the factory
|
// Initialize the decoder with the XML string using the factory
|
||||||
this.decoderInstance = DecoderFactory.createDecoder(xmlString);
|
this.decoderInstance = DecoderFactory.createDecoder(xmlString);
|
||||||
|
|
||||||
|
// Initialize the validator with the XML string using the factory
|
||||||
|
this.validatorInstance = ValidatorFactory.createValidator(xmlString);
|
||||||
|
|
||||||
|
// Validate the XML if requested
|
||||||
|
if (validate) {
|
||||||
|
await this.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the XML against the appropriate validation rules
|
||||||
|
* @param level Validation level (syntax, semantic, business)
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
public async validate(level: interfaces.ValidationLevel = interfaces.ValidationLevel.SYNTAX): Promise<interfaces.ValidationResult> {
|
||||||
|
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<void> {
|
public async addLetterData(letterData: plugins.tsclass.business.ILetter): Promise<void> {
|
||||||
@ -183,13 +241,28 @@ export class XInvoice {
|
|||||||
if (xmlContent.includes('CrossIndustryInvoice') ||
|
if (xmlContent.includes('CrossIndustryInvoice') ||
|
||||||
xmlContent.includes('rsm:') ||
|
xmlContent.includes('rsm:') ||
|
||||||
xmlContent.includes('ram:')) {
|
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
|
// Check for UBL
|
||||||
if (xmlContent.includes('<Invoice') ||
|
if (xmlContent.includes('<Invoice') ||
|
||||||
xmlContent.includes('ubl:Invoice') ||
|
xmlContent.includes('ubl:Invoice') ||
|
||||||
xmlContent.includes('oasis:names:specification:ubl')) {
|
xmlContent.includes('oasis:names:specification:ubl')) {
|
||||||
|
|
||||||
|
// Check for XRechnung
|
||||||
|
if (xmlContent.includes('xrechnung') || xmlContent.includes('XRechnung')) {
|
||||||
|
return 'XRechnung';
|
||||||
|
}
|
||||||
|
|
||||||
return 'UBL';
|
return 'UBL';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,6 +275,44 @@ export class XInvoice {
|
|||||||
// For unknown formats, return generic
|
// For unknown formats, return generic
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the invoice format as an enum value
|
||||||
|
* @returns InvoiceFormat enum value
|
||||||
|
*/
|
||||||
|
public getFormat(): interfaces.InvoiceFormat {
|
||||||
|
if (!this.xmlString) {
|
||||||
|
return interfaces.InvoiceFormat.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatString = this.identifyXmlFormat(this.xmlString);
|
||||||
|
|
||||||
|
switch (formatString) {
|
||||||
|
case 'UBL':
|
||||||
|
return interfaces.InvoiceFormat.UBL;
|
||||||
|
case 'XRechnung':
|
||||||
|
return interfaces.InvoiceFormat.XRECHNUNG;
|
||||||
|
case 'CII':
|
||||||
|
return interfaces.InvoiceFormat.CII;
|
||||||
|
case 'ZUGFeRD':
|
||||||
|
return interfaces.InvoiceFormat.ZUGFERD;
|
||||||
|
case 'Factur-X':
|
||||||
|
return interfaces.InvoiceFormat.FACTURX;
|
||||||
|
case 'FatturaPA':
|
||||||
|
return interfaces.InvoiceFormat.FATTURAPA;
|
||||||
|
default:
|
||||||
|
return interfaces.InvoiceFormat.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the invoice is in a specific format
|
||||||
|
* @param format Format to check
|
||||||
|
* @returns True if the invoice is in the specified format
|
||||||
|
*/
|
||||||
|
public isFormat(format: interfaces.InvoiceFormat): boolean {
|
||||||
|
return this.getFormat() === format;
|
||||||
|
}
|
||||||
|
|
||||||
public async getParsedXmlData(): Promise<interfaces.IXInvoice> {
|
public async getParsedXmlData(): Promise<interfaces.IXInvoice> {
|
||||||
if (!this.xmlString && !this.pdfUint8Array) {
|
if (!this.xmlString && !this.pdfUint8Array) {
|
||||||
|
64
ts/formats/base.validator.ts
Normal file
64
ts/formats/base.validator.ts
Normal file
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
322
ts/formats/facturx.validator.ts
Normal file
322
ts/formats/facturx.validator.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
382
ts/formats/ubl.validator.ts
Normal file
382
ts/formats/ubl.validator.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
92
ts/formats/validator.factory.ts
Normal file
92
ts/formats/validator.factory.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
66
ts/index.ts
66
ts/index.ts
@ -9,7 +9,28 @@ import { XInvoiceDecoder } from './formats/xinvoice.decoder.js';
|
|||||||
import { DecoderFactory } from './formats/decoder.factory.js';
|
import { DecoderFactory } from './formats/decoder.factory.js';
|
||||||
import { BaseDecoder } from './formats/base.decoder.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 { interfaces };
|
||||||
|
|
||||||
// Export main class
|
// Export main class
|
||||||
@ -30,6 +51,47 @@ export {
|
|||||||
XInvoiceDecoder
|
XInvoiceDecoder
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export validator classes
|
||||||
|
export const Validators = {
|
||||||
|
ValidatorFactory,
|
||||||
|
BaseValidator,
|
||||||
|
FacturXValidator,
|
||||||
|
UBLValidator
|
||||||
|
};
|
||||||
|
|
||||||
// For backward compatibility
|
// For backward compatibility
|
||||||
export { FacturXEncoder as ZugferdXmlEncoder };
|
export { FacturXEncoder as ZugferdXmlEncoder };
|
||||||
export { FacturXDecoder as ZUGFeRDXmlDecoder };
|
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();
|
||||||
|
}
|
@ -31,3 +31,60 @@ export interface IInvoiceItem {
|
|||||||
UnitPrice: number;
|
UnitPrice: number;
|
||||||
TotalPrice: 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[];
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user