update
This commit is contained in:
122
ts/formats/ubl/ubl.decoder.ts
Normal file
122
ts/formats/ubl/ubl.decoder.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { BaseDecoder } from '../base/base.decoder.js';
|
||||
import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.js';
|
||||
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
|
||||
import { DOMParser } from 'xmldom';
|
||||
import * as xpath from 'xpath';
|
||||
|
||||
/**
|
||||
* Base decoder for UBL-based invoice formats
|
||||
*/
|
||||
export abstract class UBLBaseDecoder extends BaseDecoder {
|
||||
protected doc: Document;
|
||||
protected namespaces: Record<string, string>;
|
||||
protected select: xpath.XPathSelect;
|
||||
|
||||
constructor(xml: string) {
|
||||
super(xml);
|
||||
|
||||
// Parse XML document
|
||||
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
|
||||
// Set up namespaces for XPath queries
|
||||
this.namespaces = {
|
||||
cbc: UBL_NAMESPACES.CBC,
|
||||
cac: UBL_NAMESPACES.CAC
|
||||
};
|
||||
|
||||
// Create XPath selector with namespaces
|
||||
this.select = xpath.useNamespaces(this.namespaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes UBL XML into a TInvoice object
|
||||
* @returns Promise resolving to a TInvoice object
|
||||
*/
|
||||
public async decode(): Promise<TInvoice> {
|
||||
// Determine document type
|
||||
const documentType = this.getDocumentType();
|
||||
|
||||
if (documentType === UBLDocumentType.CREDIT_NOTE) {
|
||||
return this.decodeCreditNote();
|
||||
} else {
|
||||
return this.decodeDebitNote();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UBL document type
|
||||
* @returns UBL document type
|
||||
*/
|
||||
protected getDocumentType(): UBLDocumentType {
|
||||
const rootName = this.doc.documentElement.nodeName;
|
||||
|
||||
if (rootName === UBLDocumentType.CREDIT_NOTE) {
|
||||
return UBLDocumentType.CREDIT_NOTE;
|
||||
} else {
|
||||
return UBLDocumentType.INVOICE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a UBL credit note
|
||||
* @returns Promise resolving to a TCreditNote object
|
||||
*/
|
||||
protected abstract decodeCreditNote(): Promise<TCreditNote>;
|
||||
|
||||
/**
|
||||
* Decodes a UBL debit note (invoice)
|
||||
* @returns Promise resolving to a TDebitNote object
|
||||
*/
|
||||
protected abstract decodeDebitNote(): Promise<TDebitNote>;
|
||||
|
||||
/**
|
||||
* Gets a text value from an XPath expression
|
||||
* @param xpath XPath expression
|
||||
* @param context Optional context node
|
||||
* @returns Text value or empty string if not found
|
||||
*/
|
||||
protected getText(xpathExpr: string, context?: Node): string {
|
||||
const node = this.select(xpathExpr, context || this.doc)[0];
|
||||
return node ? (node.textContent || '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a number value from an XPath expression
|
||||
* @param xpath XPath expression
|
||||
* @param context Optional context node
|
||||
* @returns Number value or 0 if not found or not a number
|
||||
*/
|
||||
protected getNumber(xpathExpr: string, context?: Node): number {
|
||||
const text = this.getText(xpathExpr, context);
|
||||
const num = parseFloat(text);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a date value from an XPath expression
|
||||
* @param xpath XPath expression
|
||||
* @param context Optional context node
|
||||
* @returns Date timestamp or current time if not found or invalid
|
||||
*/
|
||||
protected getDate(xpathExpr: string, context?: Node): number {
|
||||
const text = this.getText(xpathExpr, context);
|
||||
if (!text) return Date.now();
|
||||
|
||||
const date = new Date(text);
|
||||
return isNaN(date.getTime()) ? Date.now() : date.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node exists
|
||||
* @param xpath XPath expression
|
||||
* @param context Optional context node
|
||||
* @returns True if node exists
|
||||
*/
|
||||
protected exists(xpathExpr: string, context?: Node): boolean {
|
||||
const nodes = this.select(xpathExpr, context || this.doc);
|
||||
if (Array.isArray(nodes)) {
|
||||
return nodes.length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
59
ts/formats/ubl/ubl.encoder.ts
Normal file
59
ts/formats/ubl/ubl.encoder.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { BaseEncoder } from '../base/base.encoder.js';
|
||||
import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.js';
|
||||
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
|
||||
|
||||
/**
|
||||
* Base encoder for UBL-based invoice formats
|
||||
*/
|
||||
export abstract class UBLBaseEncoder extends BaseEncoder {
|
||||
/**
|
||||
* Encodes a TInvoice object into UBL XML
|
||||
* @param invoice TInvoice object to encode
|
||||
* @returns UBL XML string
|
||||
*/
|
||||
public async encode(invoice: TInvoice): Promise<string> {
|
||||
// Determine if it's a credit note or debit note
|
||||
if (invoice.invoiceType === 'creditnote') {
|
||||
return this.encodeCreditNote(invoice as TCreditNote);
|
||||
} else {
|
||||
return this.encodeDebitNote(invoice as TDebitNote);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a TCreditNote object into UBL XML
|
||||
* @param creditNote TCreditNote object to encode
|
||||
* @returns UBL XML string
|
||||
*/
|
||||
protected abstract encodeCreditNote(creditNote: TCreditNote): Promise<string>;
|
||||
|
||||
/**
|
||||
* Encodes a TDebitNote object into UBL XML
|
||||
* @param debitNote TDebitNote object to encode
|
||||
* @returns UBL XML string
|
||||
*/
|
||||
protected abstract encodeDebitNote(debitNote: TDebitNote): Promise<string>;
|
||||
|
||||
/**
|
||||
* Creates the XML declaration and root element
|
||||
* @param documentType UBL document type
|
||||
* @returns XML string with declaration and root element
|
||||
*/
|
||||
protected createXmlRoot(documentType: UBLDocumentType): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<${documentType} xmlns="urn:oasis:names:specification:ubl:schema:xsd:${documentType}-2"
|
||||
xmlns:cac="${UBL_NAMESPACES.CAC}"
|
||||
xmlns:cbc="${UBL_NAMESPACES.CBC}">
|
||||
</${documentType}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as an ISO string (YYYY-MM-DD)
|
||||
* @param timestamp Timestamp to format
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
protected formatDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
22
ts/formats/ubl/ubl.types.ts
Normal file
22
ts/formats/ubl/ubl.types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* UBL-specific types and constants
|
||||
*/
|
||||
|
||||
// UBL namespaces
|
||||
export const UBL_NAMESPACES = {
|
||||
CBC: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
|
||||
CAC: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
|
||||
UBL: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'
|
||||
};
|
||||
|
||||
// UBL document types
|
||||
export enum UBLDocumentType {
|
||||
INVOICE = 'Invoice',
|
||||
CREDIT_NOTE = 'CreditNote'
|
||||
}
|
||||
|
||||
// UBL customization IDs for different formats
|
||||
export const UBL_CUSTOMIZATION_IDS = {
|
||||
XRECHNUNG: 'urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0',
|
||||
PEPPOL_BIS: 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0'
|
||||
};
|
134
ts/formats/ubl/ubl.validator.ts
Normal file
134
ts/formats/ubl/ubl.validator.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { BaseValidator } from '../base/base.validator.js';
|
||||
import { ValidationLevel } from '../../interfaces/common.js';
|
||||
import type { ValidationResult } from '../../interfaces/common.js';
|
||||
import { UBLDocumentType } from './ubl.types.js';
|
||||
import { DOMParser } from 'xmldom';
|
||||
import * as xpath from 'xpath';
|
||||
|
||||
/**
|
||||
* Base validator for UBL-based invoice formats
|
||||
*/
|
||||
export abstract class UBLBaseValidator extends BaseValidator {
|
||||
protected doc: Document;
|
||||
protected namespaces: Record<string, string>;
|
||||
protected select: xpath.XPathSelect;
|
||||
|
||||
constructor(xml: string) {
|
||||
super(xml);
|
||||
|
||||
try {
|
||||
// Parse XML document
|
||||
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
|
||||
// Set up namespaces for XPath queries
|
||||
this.namespaces = {
|
||||
cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
|
||||
cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'
|
||||
};
|
||||
|
||||
// Create XPath selector with namespaces
|
||||
this.select = xpath.useNamespaces(this.namespaces);
|
||||
} catch (error) {
|
||||
this.addError('UBL-PARSE', `Failed to parse XML: ${error}`, '/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates UBL XML against the specified level of validation
|
||||
* @param level Validation level
|
||||
* @returns Result of validation
|
||||
*/
|
||||
public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult {
|
||||
// Reset errors
|
||||
this.errors = [];
|
||||
|
||||
// Check if document was parsed successfully
|
||||
if (!this.doc) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: this.errors,
|
||||
level: level
|
||||
};
|
||||
}
|
||||
|
||||
// Perform validation based on level
|
||||
let valid = true;
|
||||
|
||||
if (level === ValidationLevel.SYNTAX) {
|
||||
valid = this.validateSchema();
|
||||
} else if (level === ValidationLevel.SEMANTIC) {
|
||||
valid = this.validateSchema() && this.validateStructure();
|
||||
} else if (level === ValidationLevel.BUSINESS) {
|
||||
valid = this.validateSchema() &&
|
||||
this.validateStructure() &&
|
||||
this.validateBusinessRules();
|
||||
}
|
||||
|
||||
return {
|
||||
valid,
|
||||
errors: this.errors,
|
||||
level
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates UBL XML against schema
|
||||
* @returns True if schema validation passed
|
||||
*/
|
||||
protected validateSchema(): boolean {
|
||||
// Basic schema validation (simplified for now)
|
||||
if (!this.doc) return false;
|
||||
|
||||
// Check for root element
|
||||
const root = this.doc.documentElement;
|
||||
if (!root || (root.nodeName !== UBLDocumentType.INVOICE && root.nodeName !== UBLDocumentType.CREDIT_NOTE)) {
|
||||
this.addError('UBL-SCHEMA-1', `Root element must be ${UBLDocumentType.INVOICE} or ${UBLDocumentType.CREDIT_NOTE}`, '/');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates structure of the UBL XML document
|
||||
* @returns True if structure validation passed
|
||||
*/
|
||||
protected abstract validateStructure(): boolean;
|
||||
|
||||
/**
|
||||
* Gets a text value from an XPath expression
|
||||
* @param xpath XPath expression
|
||||
* @param context Optional context node
|
||||
* @returns Text value or empty string if not found
|
||||
*/
|
||||
protected getText(xpathExpr: string, context?: Node): string {
|
||||
const node = this.select(xpathExpr, context || this.doc)[0];
|
||||
return node ? (node.textContent || '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a number value from an XPath expression
|
||||
* @param xpath XPath expression
|
||||
* @param context Optional context node
|
||||
* @returns Number value or 0 if not found or not a number
|
||||
*/
|
||||
protected getNumber(xpathExpr: string, context?: Node): number {
|
||||
const text = this.getText(xpathExpr, context);
|
||||
const num = parseFloat(text);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node exists
|
||||
* @param xpath XPath expression
|
||||
* @param context Optional context node
|
||||
* @returns True if node exists
|
||||
*/
|
||||
protected exists(xpathExpr: string, context?: Node): boolean {
|
||||
const nodes = this.select(xpathExpr, context || this.doc);
|
||||
if (Array.isArray(nodes)) {
|
||||
return nodes.length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user