358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import * as xmldom from 'xmldom';
|
|
import { BaseDecoder } from './base.decoder.js';
|
|
|
|
/**
|
|
* A decoder specifically for XInvoice/XRechnung format.
|
|
* XRechnung is the German implementation of the European standard EN16931
|
|
* for electronic invoices to the German public sector.
|
|
*/
|
|
export class XInvoiceDecoder extends BaseDecoder {
|
|
private xmlDoc: Document | null = null;
|
|
private namespaces: { [key: string]: string } = {
|
|
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'
|
|
};
|
|
|
|
constructor(xmlString: string) {
|
|
super(xmlString);
|
|
|
|
// Parse XML to DOM
|
|
try {
|
|
const parser = new xmldom.DOMParser();
|
|
this.xmlDoc = parser.parseFromString(this.xmlString, 'text/xml');
|
|
|
|
// Try to detect if this is actually UBL (which XRechnung is based on)
|
|
if (this.xmlString.includes('oasis:names:specification:ubl')) {
|
|
// Set up appropriate namespaces
|
|
this.setupNamespaces();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing XInvoice XML:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up namespaces from the document
|
|
*/
|
|
private setupNamespaces(): void {
|
|
if (!this.xmlDoc) return;
|
|
|
|
// Try to extract namespaces from the document
|
|
const root = this.xmlDoc.documentElement;
|
|
if (root) {
|
|
// Look for common UBL namespaces
|
|
for (let i = 0; i < root.attributes.length; i++) {
|
|
const attr = root.attributes[i];
|
|
if (attr.name.startsWith('xmlns:')) {
|
|
const prefix = attr.name.substring(6);
|
|
this.namespaces[prefix] = attr.value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract element text by tag name with namespace awareness
|
|
*/
|
|
private getElementText(tagName: string): string {
|
|
if (!this.xmlDoc) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
// Handle namespace prefixes
|
|
if (tagName.includes(':')) {
|
|
const [nsPrefix, localName] = tagName.split(':');
|
|
|
|
// Find elements with this tag name
|
|
const elements = this.xmlDoc.getElementsByTagNameNS(this.namespaces[nsPrefix] || '', localName);
|
|
if (elements.length > 0) {
|
|
return elements[0].textContent || '';
|
|
}
|
|
}
|
|
|
|
// Fallback to direct tag name lookup
|
|
const elements = this.xmlDoc.getElementsByTagName(tagName);
|
|
if (elements.length > 0) {
|
|
return elements[0].textContent || '';
|
|
}
|
|
|
|
return '';
|
|
} catch (error) {
|
|
console.error(`Error extracting XInvoice element ${tagName}:`, error);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts XInvoice/XRechnung XML to a structured letter object
|
|
*/
|
|
public async getLetterData(): Promise<plugins.tsclass.business.ILetter> {
|
|
try {
|
|
// Extract invoice ID - typically in cbc:ID or Invoice/cbc:ID
|
|
let invoiceId = this.getElementText('cbc:ID');
|
|
if (!invoiceId) {
|
|
invoiceId = this.getElementText('Invoice/cbc:ID') || 'Unknown';
|
|
}
|
|
|
|
// Extract invoice issue date
|
|
const issueDateStr = this.getElementText('cbc:IssueDate') || '';
|
|
const issueDate = issueDateStr ? new Date(issueDateStr).getTime() : Date.now();
|
|
|
|
// Extract seller information
|
|
const sellerName = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name') ||
|
|
this.getElementText('cac:SellerSupplierParty/cac:Party/cac:PartyName/cbc:Name') ||
|
|
'Unknown Seller';
|
|
|
|
// Extract seller address
|
|
const sellerStreet = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName') || 'Unknown';
|
|
const sellerCity = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:CityName') || 'Unknown';
|
|
const sellerPostcode = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:PostalZone') || 'Unknown';
|
|
const sellerCountry = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cac:Country/cbc:IdentificationCode') || 'Unknown';
|
|
|
|
// Extract buyer information
|
|
const buyerName = this.getElementText('cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name') ||
|
|
this.getElementText('cac:BuyerCustomerParty/cac:Party/cac:PartyName/cbc:Name') ||
|
|
'Unknown Buyer';
|
|
|
|
// Create seller contact
|
|
const seller: plugins.tsclass.business.TContact = {
|
|
name: sellerName,
|
|
type: 'company',
|
|
description: sellerName,
|
|
address: {
|
|
streetName: sellerStreet,
|
|
houseNumber: '0',
|
|
city: sellerCity,
|
|
country: sellerCountry,
|
|
postalCode: sellerPostcode,
|
|
},
|
|
registrationDetails: {
|
|
vatId: this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID') || 'Unknown',
|
|
registrationId: this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID') || 'Unknown',
|
|
registrationName: sellerName
|
|
},
|
|
foundedDate: {
|
|
year: 2000,
|
|
month: 1,
|
|
day: 1
|
|
},
|
|
closedDate: {
|
|
year: 9999,
|
|
month: 12,
|
|
day: 31
|
|
},
|
|
status: 'active'
|
|
};
|
|
|
|
// Create buyer contact
|
|
const buyer: plugins.tsclass.business.TContact = {
|
|
name: buyerName,
|
|
type: 'company',
|
|
description: buyerName,
|
|
address: {
|
|
streetName: 'Unknown',
|
|
houseNumber: '0',
|
|
city: 'Unknown',
|
|
country: 'Unknown',
|
|
postalCode: 'Unknown',
|
|
},
|
|
registrationDetails: {
|
|
vatId: this.getElementText('cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID') || 'Unknown',
|
|
registrationId: this.getElementText('cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID') || 'Unknown',
|
|
registrationName: buyerName
|
|
},
|
|
foundedDate: {
|
|
year: 2000,
|
|
month: 1,
|
|
day: 1
|
|
},
|
|
closedDate: {
|
|
year: 9999,
|
|
month: 12,
|
|
day: 31
|
|
},
|
|
status: 'active'
|
|
};
|
|
|
|
// Extract invoice type
|
|
let invoiceType = 'debitnote';
|
|
const typeCode = this.getElementText('cbc:InvoiceTypeCode');
|
|
if (typeCode === '380') {
|
|
invoiceType = 'debitnote'; // Standard invoice
|
|
} else if (typeCode === '381') {
|
|
invoiceType = 'creditnote'; // Credit note
|
|
}
|
|
|
|
// Create invoice data
|
|
const invoiceData: plugins.tsclass.finance.IInvoice = {
|
|
id: invoiceId,
|
|
status: null,
|
|
type: invoiceType as 'debitnote' | 'creditnote',
|
|
billedBy: seller,
|
|
billedTo: buyer,
|
|
deliveryDate: issueDate,
|
|
dueInDays: 30,
|
|
periodOfPerformance: null,
|
|
printResult: null,
|
|
currency: (this.getElementText('cbc:DocumentCurrencyCode') || 'EUR') as plugins.tsclass.finance.TCurrency,
|
|
notes: [],
|
|
items: this.extractInvoiceItems(),
|
|
reverseCharge: false,
|
|
};
|
|
|
|
// Return a letter
|
|
return {
|
|
versionInfo: {
|
|
type: 'draft',
|
|
version: '1.0.0',
|
|
},
|
|
type: 'invoice',
|
|
date: issueDate,
|
|
subject: `XInvoice: ${invoiceId}`,
|
|
from: seller,
|
|
to: buyer,
|
|
content: {
|
|
invoiceData: invoiceData,
|
|
textData: null,
|
|
timesheetData: null,
|
|
contractData: null,
|
|
},
|
|
needsCoverSheet: false,
|
|
objectActions: [],
|
|
pdf: null,
|
|
incidenceId: null,
|
|
language: null,
|
|
legalContact: null,
|
|
logoUrl: null,
|
|
pdfAttachments: null,
|
|
accentColor: null,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error converting XInvoice XML to letter data:', error);
|
|
return this.createDefaultLetter();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts invoice items from XInvoice document
|
|
*/
|
|
private extractInvoiceItems(): plugins.tsclass.finance.IInvoiceItem[] {
|
|
if (!this.xmlDoc) {
|
|
return [
|
|
{
|
|
name: 'Unknown Item',
|
|
unitQuantity: 1,
|
|
unitNetPrice: 0,
|
|
vatPercentage: 0,
|
|
position: 0,
|
|
unitType: 'units',
|
|
}
|
|
];
|
|
}
|
|
|
|
try {
|
|
const items: plugins.tsclass.finance.IInvoiceItem[] = [];
|
|
|
|
// Get all invoice line elements
|
|
const lines = this.xmlDoc.getElementsByTagName('cac:InvoiceLine');
|
|
if (!lines || lines.length === 0) {
|
|
// Fallback to a default item
|
|
return [
|
|
{
|
|
name: 'Item from XInvoice XML',
|
|
unitQuantity: 1,
|
|
unitNetPrice: 0,
|
|
vatPercentage: 0,
|
|
position: 0,
|
|
unitType: 'units',
|
|
}
|
|
];
|
|
}
|
|
|
|
// Process each line
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
// Extract item details
|
|
let name = '';
|
|
let quantity = 1;
|
|
let price = 0;
|
|
let vatRate = 0;
|
|
|
|
// Find description element
|
|
const descElements = line.getElementsByTagName('cbc:Description');
|
|
if (descElements.length > 0) {
|
|
name = descElements[0].textContent || '';
|
|
}
|
|
|
|
// Fallback to item name if description is empty
|
|
if (!name) {
|
|
const itemNameElements = line.getElementsByTagName('cbc:Name');
|
|
if (itemNameElements.length > 0) {
|
|
name = itemNameElements[0].textContent || '';
|
|
}
|
|
}
|
|
|
|
// Find quantity
|
|
const quantityElements = line.getElementsByTagName('cbc:InvoicedQuantity');
|
|
if (quantityElements.length > 0) {
|
|
const quantityText = quantityElements[0].textContent || '1';
|
|
quantity = parseFloat(quantityText) || 1;
|
|
}
|
|
|
|
// Find price
|
|
const priceElements = line.getElementsByTagName('cbc:PriceAmount');
|
|
if (priceElements.length > 0) {
|
|
const priceText = priceElements[0].textContent || '0';
|
|
price = parseFloat(priceText) || 0;
|
|
}
|
|
|
|
// Find VAT rate - this is a bit more complex in UBL/XRechnung
|
|
const taxCategoryElements = line.getElementsByTagName('cac:ClassifiedTaxCategory');
|
|
if (taxCategoryElements.length > 0) {
|
|
const rateElements = taxCategoryElements[0].getElementsByTagName('cbc:Percent');
|
|
if (rateElements.length > 0) {
|
|
const rateText = rateElements[0].textContent || '0';
|
|
vatRate = parseFloat(rateText) || 0;
|
|
}
|
|
}
|
|
|
|
// Add the item to the list
|
|
items.push({
|
|
name: name || `Item ${i+1}`,
|
|
unitQuantity: quantity,
|
|
unitNetPrice: price,
|
|
vatPercentage: vatRate,
|
|
position: i,
|
|
unitType: 'units',
|
|
});
|
|
}
|
|
|
|
return items.length > 0 ? items : [
|
|
{
|
|
name: 'Item from XInvoice XML',
|
|
unitQuantity: 1,
|
|
unitNetPrice: 0,
|
|
vatPercentage: 0,
|
|
position: 0,
|
|
unitType: 'units',
|
|
}
|
|
];
|
|
} catch (error) {
|
|
console.error('Error extracting XInvoice items:', error);
|
|
return [
|
|
{
|
|
name: 'Error extracting items',
|
|
unitQuantity: 1,
|
|
unitNetPrice: 0,
|
|
vatPercentage: 0,
|
|
position: 0,
|
|
unitType: 'units',
|
|
}
|
|
];
|
|
}
|
|
}
|
|
} |