448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
import { UBLBaseDecoder } from '../ubl.decoder.js';
|
|
import type { TInvoice, TCreditNote, TDebitNote } from '../../../interfaces/common.js';
|
|
import { business, finance } from '../../../plugins.js';
|
|
import { UBLDocumentType } from '../ubl.types.js';
|
|
|
|
/**
|
|
* Decoder for XRechnung (UBL) format
|
|
* Implements decoding of XRechnung invoices to TInvoice
|
|
*/
|
|
export class XRechnungDecoder extends UBLBaseDecoder {
|
|
/**
|
|
* Decodes a UBL credit note
|
|
* @returns Promise resolving to a TCreditNote object
|
|
*/
|
|
protected async decodeCreditNote(): Promise<TCreditNote> {
|
|
// Extract common data
|
|
const commonData = await this.extractCommonData();
|
|
|
|
// Return the invoice data as a credit note
|
|
return {
|
|
...commonData,
|
|
accountingDocType: 'creditnote' as const
|
|
} as unknown as TCreditNote;
|
|
}
|
|
|
|
/**
|
|
* Decodes a UBL debit note (invoice)
|
|
* @returns Promise resolving to a TDebitNote object
|
|
*/
|
|
protected async decodeDebitNote(): Promise<TDebitNote> {
|
|
// Extract common data
|
|
const commonData = await this.extractCommonData();
|
|
|
|
// Return the invoice data as a debit note
|
|
return {
|
|
...commonData,
|
|
accountingDocType: 'debitnote' as const
|
|
} as unknown as TDebitNote;
|
|
}
|
|
|
|
/**
|
|
* Extracts common invoice data from XRechnung XML
|
|
* @returns Common invoice data
|
|
*/
|
|
private async extractCommonData(): Promise<Partial<TInvoice>> {
|
|
try {
|
|
// Default values
|
|
const invoiceId = this.getText('//cbc:ID', this.doc) || `INV-${Date.now()}`;
|
|
const issueDateText = this.getText('//cbc:IssueDate', this.doc);
|
|
const issueDate = issueDateText ? new Date(issueDateText).getTime() : Date.now();
|
|
const currencyCode = this.getText('//cbc:DocumentCurrencyCode', this.doc) || 'EUR';
|
|
|
|
// Extract payment terms
|
|
let dueInDays = 30; // Default
|
|
const dueDateText = this.getText('//cac:PaymentTerms/cbc:PaymentDueDate', this.doc);
|
|
if (dueDateText) {
|
|
const dueDateObj = new Date(dueDateText);
|
|
const issueDateObj = new Date(issueDate);
|
|
const diffTime = Math.abs(dueDateObj.getTime() - issueDateObj.getTime());
|
|
dueInDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
// Extract items
|
|
const items: finance.TAccountingDocItem[] = [];
|
|
const invoiceLines = this.select('//cac:InvoiceLine', this.doc);
|
|
|
|
if (invoiceLines && Array.isArray(invoiceLines)) {
|
|
for (let i = 0; i < invoiceLines.length; i++) {
|
|
const line = invoiceLines[i];
|
|
|
|
const position = i + 1;
|
|
const name = this.getText('./cac:Item/cbc:Name', line) || `Item ${position}`;
|
|
const description = this.getText('./cac:Item/cbc:Description', line) || '';
|
|
const articleNumber = this.getText('./cac:Item/cac:SellersItemIdentification/cbc:ID', line) || '';
|
|
const buyerItemID = this.getText('./cac:Item/cac:BuyersItemIdentification/cbc:ID', line) || '';
|
|
const standardItemID = this.getText('./cac:Item/cac:StandardItemIdentification/cbc:ID', line) || '';
|
|
const commodityClassification = this.getText('./cac:Item/cac:CommodityClassification/cbc:ItemClassificationCode', line) || '';
|
|
const unitType = this.getText('./cbc:InvoicedQuantity/@unitCode', line) || 'EA';
|
|
|
|
let unitQuantity = 1;
|
|
const quantityText = this.getText('./cbc:InvoicedQuantity', line);
|
|
if (quantityText) {
|
|
unitQuantity = parseFloat(quantityText) || 1;
|
|
}
|
|
|
|
let unitNetPrice = 0;
|
|
const priceText = this.getText('./cac:Price/cbc:PriceAmount', line);
|
|
if (priceText) {
|
|
unitNetPrice = parseFloat(priceText) || 0;
|
|
}
|
|
|
|
let vatPercentage = 0;
|
|
const percentText = this.getText('./cac:Item/cac:ClassifiedTaxCategory/cbc:Percent', line);
|
|
if (percentText) {
|
|
vatPercentage = parseFloat(percentText) || 0;
|
|
}
|
|
|
|
// Create item with extended metadata
|
|
const item: finance.TAccountingDocItem & { metadata?: any } = {
|
|
position,
|
|
name,
|
|
articleNumber,
|
|
unitType,
|
|
unitQuantity,
|
|
unitNetPrice,
|
|
vatPercentage
|
|
};
|
|
|
|
// Extract order line reference
|
|
const orderLineReference = this.getText('./cac:OrderLineReference/cac:OrderReference/cbc:ID', line) || '';
|
|
const orderLineReferenceId = this.getText('./cac:OrderLineReference/cbc:LineID', line) || '';
|
|
|
|
// Extract additional item properties
|
|
const additionalProps: Record<string, string> = {};
|
|
const propNodes = this.select('./cac:Item/cac:AdditionalItemProperty', line);
|
|
if (propNodes && Array.isArray(propNodes)) {
|
|
for (const propNode of propNodes) {
|
|
const propName = this.getText('./cbc:Name', propNode);
|
|
const propValue = this.getText('./cbc:Value', propNode);
|
|
if (propName && propValue) {
|
|
additionalProps[propName] = propValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store additional item data in metadata
|
|
if (description || buyerItemID || standardItemID || commodityClassification || orderLineReference || Object.keys(additionalProps).length > 0) {
|
|
item.metadata = {
|
|
description,
|
|
buyerItemID,
|
|
standardItemID,
|
|
commodityClassification,
|
|
orderLineReference,
|
|
orderLineReferenceId,
|
|
additionalProperties: additionalProps
|
|
};
|
|
}
|
|
|
|
items.push(item);
|
|
}
|
|
}
|
|
|
|
// Extract business references
|
|
const buyerReference = this.getText('//cbc:BuyerReference', this.doc);
|
|
const orderReference = this.getText('//cac:OrderReference/cbc:ID', this.doc);
|
|
const contractReference = this.getText('//cac:ContractDocumentReference/cbc:ID', this.doc);
|
|
const projectReference = this.getText('//cac:ProjectReference/cbc:ID', this.doc);
|
|
|
|
// Extract payment information
|
|
const paymentMeansCode = this.getText('//cac:PaymentMeans/cbc:PaymentMeansCode', this.doc);
|
|
const paymentID = this.getText('//cac:PaymentMeans/cbc:PaymentID', this.doc);
|
|
const paymentDueDate = this.getText('//cac:PaymentMeans/cbc:PaymentDueDate', this.doc);
|
|
const iban = this.getText('//cac:PaymentMeans/cac:PayeeFinancialAccount/cbc:ID', this.doc);
|
|
const bic = this.getText('//cac:PaymentMeans/cac:PayeeFinancialAccount/cac:FinancialInstitutionBranch/cbc:ID', this.doc);
|
|
const bankName = this.getText('//cac:PaymentMeans/cac:PayeeFinancialAccount/cac:FinancialInstitutionBranch/cbc:Name', this.doc);
|
|
const accountName = this.getText('//cac:PaymentMeans/cac:PayeeFinancialAccount/cbc:Name', this.doc);
|
|
|
|
// Extract payment terms with discount
|
|
const paymentTermsNote = this.getText('//cac:PaymentTerms/cbc:Note', this.doc);
|
|
const discountPercent = this.getText('//cac:PaymentTerms/cbc:SettlementDiscountPercent', this.doc);
|
|
|
|
// Extract period information
|
|
const periodStart = this.getText('//cac:InvoicePeriod/cbc:StartDate', this.doc);
|
|
const periodEnd = this.getText('//cac:InvoicePeriod/cbc:EndDate', this.doc);
|
|
const deliveryDate = this.getText('//cac:Delivery/cbc:ActualDeliveryDate', this.doc);
|
|
|
|
// Extract notes
|
|
const notes: string[] = [];
|
|
const noteNodes = this.select('//cbc:Note', this.doc);
|
|
if (noteNodes && Array.isArray(noteNodes)) {
|
|
for (let i = 0; i < noteNodes.length; i++) {
|
|
const noteText = noteNodes[i].textContent || '';
|
|
if (noteText) {
|
|
notes.push(noteText);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract seller and buyer information
|
|
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 invoiceData: any = {
|
|
type: 'accounting-doc' as const,
|
|
accountingDocType: 'invoice' as const,
|
|
id: invoiceId,
|
|
accountingDocId: invoiceId,
|
|
date: issueDate,
|
|
accountingDocStatus: 'issued' as const,
|
|
versionInfo: {
|
|
type: 'final',
|
|
version: '1.0.0'
|
|
},
|
|
language: 'en',
|
|
incidenceId: invoiceId,
|
|
from: seller,
|
|
to: buyer,
|
|
subject: notes.length > 0 ? notes[0] : `Invoice ${invoiceId}`,
|
|
items: items,
|
|
dueInDays: dueInDays,
|
|
reverseCharge: false,
|
|
currency: currencyCode as finance.TCurrency,
|
|
notes: notes,
|
|
objectActions: [],
|
|
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
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
return invoiceData;
|
|
} catch (error) {
|
|
console.error('Error extracting common data:', error);
|
|
// Return default data
|
|
return {
|
|
type: 'accounting-doc' as const,
|
|
accountingDocType: 'invoice' as const,
|
|
id: `INV-${Date.now()}`,
|
|
accountingDocId: `INV-${Date.now()}`,
|
|
date: Date.now(),
|
|
accountingDocStatus: 'issued' as const,
|
|
versionInfo: {
|
|
type: 'final',
|
|
version: '1.0.0'
|
|
},
|
|
language: 'en',
|
|
incidenceId: `INV-${Date.now()}`,
|
|
from: this.createEmptyContact(),
|
|
to: this.createEmptyContact(),
|
|
subject: 'Invoice',
|
|
items: [],
|
|
dueInDays: 30,
|
|
reverseCharge: false,
|
|
currency: 'EUR',
|
|
notes: [],
|
|
objectActions: []
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts party information from XML
|
|
* @param partyPath XPath to the party element
|
|
* @returns TContact object
|
|
*/
|
|
private extractParty(partyPath: string): business.TContact {
|
|
try {
|
|
// Default values
|
|
let name = '';
|
|
let streetName = '';
|
|
let houseNumber = '0';
|
|
let city = '';
|
|
let postalCode = '';
|
|
let country = '';
|
|
let countryCode = '';
|
|
let vatId = '';
|
|
let registrationId = '';
|
|
let registrationName = '';
|
|
let contactPhone = '';
|
|
let contactEmail = '';
|
|
let contactName = '';
|
|
let gln = '';
|
|
const additionalIdentifiers: any[] = [];
|
|
|
|
// Try to extract party information
|
|
const partyNodes = this.select(partyPath, this.doc);
|
|
|
|
if (partyNodes && Array.isArray(partyNodes) && partyNodes.length > 0) {
|
|
const party = partyNodes[0];
|
|
|
|
// Extract GLN from EndpointID
|
|
const endpointId = this.getText('./cbc:EndpointID[@schemeID="0088"]', party);
|
|
if (endpointId) {
|
|
gln = endpointId;
|
|
}
|
|
|
|
// Extract additional party identifications
|
|
const partyIdNodes = this.select('./cac:PartyIdentification', party);
|
|
if (partyIdNodes && Array.isArray(partyIdNodes)) {
|
|
for (const idNode of partyIdNodes) {
|
|
const idValue = this.getText('./cbc:ID', idNode);
|
|
const idElement = (idNode as Element).getElementsByTagName('cbc:ID')[0];
|
|
const schemeId = idElement?.getAttribute('schemeID');
|
|
if (idValue) {
|
|
additionalIdentifiers.push({
|
|
value: idValue,
|
|
scheme: schemeId || ''
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract name
|
|
name = this.getText('./cac:PartyName/cbc:Name', party) || '';
|
|
|
|
// Extract address
|
|
const addressNodes = this.select('./cac:PostalAddress', party);
|
|
if (addressNodes && Array.isArray(addressNodes) && addressNodes.length > 0) {
|
|
const address = addressNodes[0];
|
|
|
|
streetName = this.getText('./cbc:StreetName', address) || '';
|
|
houseNumber = this.getText('./cbc:BuildingNumber', address) || '0';
|
|
city = this.getText('./cbc:CityName', address) || '';
|
|
postalCode = this.getText('./cbc:PostalZone', address) || '';
|
|
|
|
const countryNodes = this.select('./cac:Country', address);
|
|
if (countryNodes && Array.isArray(countryNodes) && countryNodes.length > 0) {
|
|
const countryNode = countryNodes[0];
|
|
country = this.getText('./cbc:Name', countryNode) || '';
|
|
countryCode = this.getText('./cbc:IdentificationCode', countryNode) || '';
|
|
}
|
|
}
|
|
|
|
// Extract tax information
|
|
const taxSchemeNodes = this.select('./cac:PartyTaxScheme', party);
|
|
if (taxSchemeNodes && Array.isArray(taxSchemeNodes) && taxSchemeNodes.length > 0) {
|
|
vatId = this.getText('./cbc:CompanyID', taxSchemeNodes[0]) || '';
|
|
}
|
|
|
|
// Extract registration information
|
|
const legalEntityNodes = this.select('./cac:PartyLegalEntity', party);
|
|
if (legalEntityNodes && Array.isArray(legalEntityNodes) && legalEntityNodes.length > 0) {
|
|
registrationId = this.getText('./cbc:CompanyID', legalEntityNodes[0]) || '';
|
|
registrationName = this.getText('./cbc:RegistrationName', legalEntityNodes[0]) || name;
|
|
}
|
|
|
|
// Extract contact information
|
|
const contactNodes = this.select('./cac:Contact', party);
|
|
if (contactNodes && Array.isArray(contactNodes) && contactNodes.length > 0) {
|
|
const contact = contactNodes[0];
|
|
contactPhone = this.getText('./cbc:Telephone', contact) || '';
|
|
contactEmail = this.getText('./cbc:ElectronicMail', contact) || '';
|
|
contactName = this.getText('./cbc:Name', contact) || '';
|
|
}
|
|
}
|
|
|
|
// Create contact with additional metadata for contact information
|
|
const contact: business.TContact & { metadata?: any } = {
|
|
type: 'company',
|
|
name: name,
|
|
description: '',
|
|
address: {
|
|
streetName: streetName,
|
|
houseNumber: houseNumber,
|
|
city: city,
|
|
postalCode: postalCode,
|
|
country: country,
|
|
countryCode: countryCode
|
|
},
|
|
status: 'active',
|
|
foundedDate: {
|
|
year: 2000,
|
|
month: 1,
|
|
day: 1
|
|
},
|
|
registrationDetails: {
|
|
vatId: vatId,
|
|
registrationId: registrationId,
|
|
registrationName: registrationName
|
|
}
|
|
};
|
|
|
|
// Store contact information and additional identifiers in metadata if available
|
|
const metadata: any = {};
|
|
|
|
if (contactPhone || contactEmail || contactName) {
|
|
metadata.contactInformation = {
|
|
phone: contactPhone,
|
|
email: contactEmail,
|
|
name: contactName
|
|
};
|
|
}
|
|
|
|
if (gln) {
|
|
(contact as any).gln = gln;
|
|
}
|
|
|
|
if (additionalIdentifiers.length > 0) {
|
|
(contact as any).additionalIdentifiers = additionalIdentifiers;
|
|
}
|
|
|
|
if (Object.keys(metadata).length > 0) {
|
|
contact.metadata = metadata;
|
|
}
|
|
|
|
return contact;
|
|
} catch (error) {
|
|
console.error('Error extracting party information:', error);
|
|
return this.createEmptyContact();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates an empty TContact object
|
|
* @returns Empty TContact object
|
|
*/
|
|
private createEmptyContact(): business.TContact {
|
|
return {
|
|
type: 'company',
|
|
name: '',
|
|
description: '',
|
|
address: {
|
|
streetName: '',
|
|
houseNumber: '0',
|
|
city: '',
|
|
country: '',
|
|
postalCode: ''
|
|
},
|
|
status: 'active',
|
|
foundedDate: {
|
|
year: 2000,
|
|
month: 1,
|
|
day: 1
|
|
},
|
|
registrationDetails: {
|
|
vatId: '',
|
|
registrationId: '',
|
|
registrationName: ''
|
|
}
|
|
};
|
|
}
|
|
}
|