feat(core): improve in-memory validation, FatturaPA detection coverage, and published type compatibility
This commit is contained in:
@@ -90,7 +90,8 @@ export class EN16931UBLValidator extends UBLBaseValidator {
|
||||
}
|
||||
|
||||
// BR-08: An Invoice shall contain the Seller postal address (BG-5).
|
||||
const sellerAddress = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc)[0];
|
||||
const sellerAddressResult = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc);
|
||||
const sellerAddress = Array.isArray(sellerAddressResult) ? sellerAddressResult[0] : null;
|
||||
if (!sellerAddress || !this.exists('.//cbc:IdentificationCode', sellerAddress)) {
|
||||
this.addError('BR-08', 'An Invoice shall contain the Seller postal address', '//cac:AccountingSupplierParty//cac:PostalAddress');
|
||||
valid = false;
|
||||
@@ -103,7 +104,8 @@ export class EN16931UBLValidator extends UBLBaseValidator {
|
||||
}
|
||||
|
||||
// BR-10: An Invoice shall contain the Buyer postal address (BG-8).
|
||||
const buyerAddress = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc)[0];
|
||||
const buyerAddressResult = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc);
|
||||
const buyerAddress = Array.isArray(buyerAddressResult) ? buyerAddressResult[0] : null;
|
||||
if (!buyerAddress || !this.exists('.//cbc:IdentificationCode', buyerAddress)) {
|
||||
this.addError('BR-10', 'An Invoice shall contain the Buyer postal address', '//cac:AccountingCustomerParty//cac:PostalAddress');
|
||||
valid = false;
|
||||
@@ -213,4 +215,4 @@ export class EN16931UBLValidator extends UBLBaseValidator {
|
||||
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +263,11 @@ export class UBLEncoder extends UBLBaseEncoder {
|
||||
* @param invoice Invoice data
|
||||
*/
|
||||
private addPaymentMeans(doc: Document, parentElement: Element, invoice: TInvoice): void {
|
||||
const paymentOptions = invoice.paymentOptions;
|
||||
if (!paymentOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentMeansNode = doc.createElement('cac:PaymentMeans');
|
||||
parentElement.appendChild(paymentMeansNode);
|
||||
|
||||
@@ -276,26 +281,26 @@ export class UBLEncoder extends UBLBaseEncoder {
|
||||
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentDueDate', this.formatDate(dueDate.getTime()));
|
||||
|
||||
// Add payment channel code if available
|
||||
if (invoice.paymentOptions.description) {
|
||||
this.appendElement(doc, paymentMeansNode, 'cbc:InstructionNote', invoice.paymentOptions.description);
|
||||
if (paymentOptions.description) {
|
||||
this.appendElement(doc, paymentMeansNode, 'cbc:InstructionNote', paymentOptions.description);
|
||||
}
|
||||
|
||||
// Add payment ID information if available - use invoice ID as payment reference
|
||||
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentID', invoice.id);
|
||||
|
||||
// Add bank account information if available
|
||||
if (invoice.paymentOptions.sepaConnection && invoice.paymentOptions.sepaConnection.iban) {
|
||||
if (paymentOptions.sepaConnection && paymentOptions.sepaConnection.iban) {
|
||||
const payeeFinancialAccountNode = doc.createElement('cac:PayeeFinancialAccount');
|
||||
paymentMeansNode.appendChild(payeeFinancialAccountNode);
|
||||
|
||||
this.appendElement(doc, payeeFinancialAccountNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.iban);
|
||||
this.appendElement(doc, payeeFinancialAccountNode, 'cbc:ID', paymentOptions.sepaConnection.iban);
|
||||
|
||||
// Add financial institution information if BIC is available
|
||||
if (invoice.paymentOptions.sepaConnection.bic) {
|
||||
if (paymentOptions.sepaConnection.bic) {
|
||||
const financialInstitutionNode = doc.createElement('cac:FinancialInstitutionBranch');
|
||||
payeeFinancialAccountNode.appendChild(financialInstitutionNode);
|
||||
|
||||
this.appendElement(doc, financialInstitutionNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.bic);
|
||||
this.appendElement(doc, financialInstitutionNode, 'cbc:ID', paymentOptions.sepaConnection.bic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1038,4 +1043,4 @@ export class UBLEncoder extends UBLBaseEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.
|
||||
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
|
||||
import { DOMParser, xpath } from '../../plugins.js';
|
||||
|
||||
const ublParser = new DOMParser();
|
||||
const ublNamespaces = {
|
||||
cbc: UBL_NAMESPACES.CBC,
|
||||
cac: UBL_NAMESPACES.CAC,
|
||||
};
|
||||
const ublSelect = xpath.useNamespaces(ublNamespaces);
|
||||
|
||||
/**
|
||||
* Base decoder for UBL-based invoice formats
|
||||
*/
|
||||
@@ -15,16 +22,17 @@ export abstract class UBLBaseDecoder extends BaseDecoder {
|
||||
super(xml, skipValidation);
|
||||
|
||||
// Parse XML document
|
||||
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
this.doc = ublParser.parseFromString(xml, 'application/xml');
|
||||
|
||||
// Set up namespaces for XPath queries
|
||||
this.namespaces = {
|
||||
cbc: UBL_NAMESPACES.CBC,
|
||||
cac: UBL_NAMESPACES.CAC
|
||||
};
|
||||
// Set up namespaces for XPath queries
|
||||
this.namespaces = ublNamespaces;
|
||||
|
||||
// Create XPath selector with namespaces
|
||||
this.select = xpath.useNamespaces(this.namespaces);
|
||||
// Create XPath selector with namespaces
|
||||
this.select = ublSelect;
|
||||
}
|
||||
|
||||
public override getParsedDocument(): Document {
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +83,8 @@ export abstract class UBLBaseDecoder extends BaseDecoder {
|
||||
* @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];
|
||||
const result = this.select(xpathExpr, context || this.doc);
|
||||
const node = Array.isArray(result) ? result[0] : null;
|
||||
return node ? (node.textContent || '') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,29 +4,33 @@ import type { ValidationResult } from '../../interfaces/common.js';
|
||||
import { UBLDocumentType } from './ubl.types.js';
|
||||
import { DOMParser, xpath } from '../../plugins.js';
|
||||
|
||||
const ublValidatorParser = new DOMParser();
|
||||
const ublValidatorNamespaces = {
|
||||
cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
|
||||
cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
|
||||
};
|
||||
const ublValidatorSelect = xpath.useNamespaces(ublValidatorNamespaces);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
protected doc!: Document;
|
||||
protected namespaces!: Record<string, string>;
|
||||
protected select!: xpath.XPathSelect;
|
||||
|
||||
constructor(xml: string) {
|
||||
constructor(xml: string, doc?: Document) {
|
||||
super(xml);
|
||||
|
||||
try {
|
||||
// Parse XML document
|
||||
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
// Reuse an existing parsed document when available.
|
||||
this.doc = doc ?? ublValidatorParser.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'
|
||||
};
|
||||
this.namespaces = ublValidatorNamespaces;
|
||||
|
||||
// Create XPath selector with namespaces
|
||||
this.select = xpath.useNamespaces(this.namespaces);
|
||||
this.select = ublValidatorSelect;
|
||||
} catch (error) {
|
||||
this.addError('UBL-PARSE', `Failed to parse XML: ${error}`, '/');
|
||||
}
|
||||
@@ -101,7 +105,8 @@ export abstract class UBLBaseValidator extends BaseValidator {
|
||||
* @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];
|
||||
const result = this.select(xpathExpr, context || this.doc);
|
||||
const node = Array.isArray(result) ? result[0] : null;
|
||||
return node ? (node.textContent || '') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,36 @@ export class XRechnungDecoder extends UBLBaseDecoder {
|
||||
const seller = this.extractParty('//cac:AccountingSupplierParty/cac:Party');
|
||||
const buyer = this.extractParty('//cac:AccountingCustomerParty/cac:Party');
|
||||
|
||||
// Create the common invoice data with metadata for business references
|
||||
const businessReferences = {
|
||||
buyerReference,
|
||||
orderReference,
|
||||
contractReference,
|
||||
projectReference
|
||||
};
|
||||
|
||||
const paymentInformation = {
|
||||
paymentMeansCode,
|
||||
paymentID,
|
||||
paymentDueDate,
|
||||
iban,
|
||||
bic,
|
||||
bankName,
|
||||
accountName,
|
||||
paymentTermsNote,
|
||||
discountPercent
|
||||
};
|
||||
|
||||
const dateInformation = {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
deliveryDate
|
||||
};
|
||||
|
||||
const hasBusinessReferences = Object.values(businessReferences).some(value => Boolean(value));
|
||||
const hasPaymentInformation = Object.values(paymentInformation).some(value => Boolean(value));
|
||||
const hasDateInformation = Object.values(dateInformation).some(value => Boolean(value));
|
||||
|
||||
// Create the common invoice data with metadata only when the source XML actually contains it.
|
||||
const invoiceData: any = {
|
||||
type: 'accounting-doc' as const,
|
||||
accountingDocType: 'invoice' as const,
|
||||
@@ -216,36 +245,20 @@ export class XRechnungDecoder extends UBLBaseDecoder {
|
||||
reverseCharge: false,
|
||||
currency: currencyCode as finance.TCurrency,
|
||||
notes: notes,
|
||||
objectActions: [],
|
||||
metadata: {
|
||||
objectActions: []
|
||||
};
|
||||
|
||||
if (hasBusinessReferences || hasPaymentInformation || hasDateInformation) {
|
||||
invoiceData.metadata = {
|
||||
format: 'xrechnung' as any,
|
||||
version: '1.0.0',
|
||||
extensions: {
|
||||
businessReferences: {
|
||||
buyerReference,
|
||||
orderReference,
|
||||
contractReference,
|
||||
projectReference
|
||||
},
|
||||
paymentInformation: {
|
||||
paymentMeansCode,
|
||||
paymentID,
|
||||
paymentDueDate,
|
||||
iban,
|
||||
bic,
|
||||
bankName,
|
||||
accountName,
|
||||
paymentTermsNote,
|
||||
discountPercent
|
||||
},
|
||||
dateInformation: {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
deliveryDate
|
||||
}
|
||||
...(hasBusinessReferences ? { businessReferences } : {}),
|
||||
...(hasPaymentInformation ? { paymentInformation } : {}),
|
||||
...(hasDateInformation ? { dateInformation } : {}),
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Validate mandatory EN16931 fields unless validation is skipped
|
||||
if (!this.skipValidation) {
|
||||
@@ -255,7 +268,7 @@ export class XRechnungDecoder extends UBLBaseDecoder {
|
||||
return invoiceData;
|
||||
} catch (error) {
|
||||
// Re-throw validation errors
|
||||
if (error.message && error.message.includes('EN16931 validation failed')) {
|
||||
if (error instanceof Error && error.message.includes('EN16931 validation failed')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user