Compare commits

...

2 Commits

Author SHA1 Message Date
ffacf12177 1.3.2
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-17 16:50:00 +00:00
3fe7446a29 feat(validation): add validators 2025-03-17 16:49:49 +00:00
10 changed files with 1176 additions and 6 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@fin.cx/xinvoice",
"version": "1.3.1",
"version": "1.3.2",
"private": false,
"description": "A TypeScript module for creating, manipulating, and embedding XML data within PDF files specifically tailored for xinvoice packages.",
"main": "dist_ts/index.js",
@ -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",

9
pnpm-lock.yaml generated
View File

@ -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: {}

70
test/test.validators.ts Normal file
View 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();

View File

@ -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<void> {
public async addXmlString(xmlString: string, validate: boolean = false): Promise<void> {
// Basic XML validation - just check if it starts with <?xml
if (!xmlString || !xmlString.trim().startsWith('<?xml')) {
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
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> {
@ -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('<Invoice') ||
xmlContent.includes('ubl:Invoice') ||
xmlContent.includes('oasis:names:specification:ubl')) {
// Check for XRechnung
if (xmlContent.includes('xrechnung') || xmlContent.includes('XRechnung')) {
return 'XRechnung';
}
return 'UBL';
}
@ -202,6 +275,44 @@ export class XInvoice {
// For unknown formats, return generic
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> {
if (!this.xmlString && !this.pdfUint8Array) {

View 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
});
}
}

View 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
View 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;
}
}

View 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;
}
}
}

View File

@ -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 };
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();
}

View File

@ -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[];
}