xinvoice/ts/formats/xinvoice.decoder.ts

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',
}
];
}
}
}