feat(validation): add validators
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user