This commit is contained in:
2025-03-17 16:30:23 +00:00
parent bbc9b837f4
commit e929281861
20 changed files with 1777 additions and 381 deletions

View File

@ -1,346 +0,0 @@
import * as plugins from './plugins.js';
import * as xmldom from 'xmldom';
/**
* A class to convert a given XML string (ZUGFeRD/Factur-X, UBL or fatturaPA)
* into a structured ILetter with invoice data.
*
* Handles different invoice XML formats:
* - ZUGFeRD/Factur-X (CII)
* - UBL
* - FatturaPA
*/
export class ZUGFeRDXmlDecoder {
private xmlString: string;
private xmlFormat: string;
private xmlDoc: Document | null = null;
constructor(xmlString: string) {
if (!xmlString) {
throw new Error('No XML string provided to decoder');
}
this.xmlString = xmlString;
// Simple format detection based on string contents
this.xmlFormat = this.detectFormat();
// Parse XML to DOM
try {
const parser = new xmldom.DOMParser();
this.xmlDoc = parser.parseFromString(this.xmlString, 'text/xml');
} catch (error) {
console.error('Error parsing XML:', error);
}
}
/**
* Detects the XML invoice format using simple string checks
*/
private detectFormat(): string {
// ZUGFeRD/Factur-X (CII format)
if (this.xmlString.includes('CrossIndustryInvoice') ||
this.xmlString.includes('un/cefact') ||
this.xmlString.includes('rsm:')) {
return 'CII';
}
// UBL format
if (this.xmlString.includes('Invoice') ||
this.xmlString.includes('oasis:names:specification:ubl')) {
return 'UBL';
}
// FatturaPA format
if (this.xmlString.includes('FatturaElettronica') ||
this.xmlString.includes('fatturapa.gov.it')) {
return 'FatturaPA';
}
// Default to generic
return 'unknown';
}
/**
* Extracts text from the first element matching the XPath-like selector
*/
private getElementText(tagName: string): string {
if (!this.xmlDoc) {
return '';
}
try {
// Basic handling for namespaced tags
let namespace = '';
let localName = tagName;
if (tagName.includes(':')) {
const parts = tagName.split(':');
namespace = parts[0];
localName = parts[1];
}
// Find all elements with this name
const elements = this.xmlDoc.getElementsByTagName(tagName);
if (elements.length > 0) {
return elements[0].textContent || '';
}
// Try with just the local name if we didn't find it with the namespace
if (namespace) {
const elements = this.xmlDoc.getElementsByTagName(localName);
if (elements.length > 0) {
return elements[0].textContent || '';
}
}
return '';
} catch (error) {
console.error(`Error extracting element ${tagName}:`, error);
return '';
}
}
/**
* Converts XML to a structured letter object
*/
public async getLetterData(): Promise<plugins.tsclass.business.ILetter> {
try {
if (this.xmlFormat === 'CII') {
return this.parseCII();
} else if (this.xmlFormat === 'UBL') {
// For now, use the default implementation
return this.parseGeneric();
} else if (this.xmlFormat === 'FatturaPA') {
// For now, use the default implementation
return this.parseGeneric();
} else {
return this.parseGeneric();
}
} catch (error) {
console.error('Error converting XML to letter data:', error);
// If all else fails, return a minimal letter object
return this.createDefaultLetter();
}
}
/**
* Parse CII (ZUGFeRD/Factur-X) formatted XML
*/
private parseCII(): plugins.tsclass.business.ILetter {
// Extract invoice ID
let invoiceId = this.getElementText('ram:ID');
if (!invoiceId) {
// Try alternative locations
invoiceId = this.getElementText('rsm:ExchangedDocument ram:ID') || 'Unknown';
}
// Extract seller name
let sellerName = this.getElementText('ram:Name');
if (!sellerName) {
sellerName = this.getElementText('ram:SellerTradeParty ram:Name') || 'Unknown Seller';
}
// Extract buyer name
let buyerName = '';
// Try to find BuyerTradeParty Name specifically
if (this.xmlDoc) {
const buyerParties = this.xmlDoc.getElementsByTagName('ram:BuyerTradeParty');
if (buyerParties.length > 0) {
const nameElements = buyerParties[0].getElementsByTagName('ram:Name');
if (nameElements.length > 0) {
buyerName = nameElements[0].textContent || '';
}
}
}
if (!buyerName) {
buyerName = 'Unknown Buyer';
}
// Create seller
const seller: plugins.tsclass.business.IContact = {
name: sellerName,
type: 'company',
description: sellerName,
address: {
streetName: this.getElementText('ram:LineOne') || 'Unknown',
houseNumber: '0', // Required by IAddress interface
city: this.getElementText('ram:CityName') || 'Unknown',
country: this.getElementText('ram:CountryID') || 'Unknown',
postalCode: this.getElementText('ram:PostcodeCode') || 'Unknown',
},
};
// Create buyer
const buyer: plugins.tsclass.business.IContact = {
name: buyerName,
type: 'company',
description: buyerName,
address: {
streetName: 'Unknown',
houseNumber: '0',
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
};
// Extract invoice type
let invoiceType = 'debitnote';
const typeCode = this.getElementText('ram:TypeCode');
if (typeCode === '381') {
invoiceType = 'creditnote';
}
// Create invoice data
const invoiceData: plugins.tsclass.finance.IInvoice = {
id: invoiceId,
status: null,
type: invoiceType as 'debitnote' | 'creditnote',
billedBy: seller,
billedTo: buyer,
deliveryDate: Date.now(),
dueInDays: 30,
periodOfPerformance: null,
printResult: null,
currency: (this.getElementText('ram:InvoiceCurrencyCode') || 'EUR') as plugins.tsclass.finance.TCurrency,
notes: [],
items: [
{
name: 'Item from XML',
unitQuantity: 1,
unitNetPrice: 0,
vatPercentage: 0,
position: 0,
unitType: 'units',
}
],
reverseCharge: false,
};
// Return a letter
return {
versionInfo: {
type: 'draft',
version: '1.0.0',
},
type: 'invoice',
date: Date.now(),
subject: `Invoice: ${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,
};
}
/**
* Parse generic XML using default approach
*/
private parseGeneric(): plugins.tsclass.business.ILetter {
// Create a default letter with some extraction attempts
return this.createDefaultLetter();
}
/**
* Creates a default letter object with minimal data
*/
private createDefaultLetter(): plugins.tsclass.business.ILetter {
// Create a default seller
const seller: plugins.tsclass.business.IContact = {
name: 'Unknown Seller',
type: 'company',
description: 'Unknown Seller', // Required by IContact interface
address: {
streetName: 'Unknown',
houseNumber: '0', // Required by IAddress interface
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
};
// Create a default buyer
const buyer: plugins.tsclass.business.IContact = {
name: 'Unknown Buyer',
type: 'company',
description: 'Unknown Buyer', // Required by IContact interface
address: {
streetName: 'Unknown',
houseNumber: '0', // Required by IAddress interface
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
};
// Create default invoice data
const invoiceData: plugins.tsclass.finance.IInvoice = {
id: 'Unknown',
status: null,
type: 'debitnote',
billedBy: seller,
billedTo: buyer,
deliveryDate: Date.now(),
dueInDays: 30,
periodOfPerformance: null,
printResult: null,
currency: 'EUR' as plugins.tsclass.finance.TCurrency,
notes: [],
items: [
{
name: 'Unknown Item',
unitQuantity: 1,
unitNetPrice: 0,
vatPercentage: 0,
position: 0,
unitType: 'units',
}
],
reverseCharge: false,
};
// Return a default letter
return {
versionInfo: {
type: 'draft',
version: '1.0.0',
},
type: 'invoice',
date: Date.now(),
subject: `Extracted Invoice (${this.xmlFormat} format)`,
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,
};
}
}

View File

@ -8,8 +8,9 @@ import {
PDFArray,
PDFString,
} from 'pdf-lib';
import { FacturXEncoder } from './classes.encoder.js';
import { ZUGFeRDXmlDecoder } from './classes.decoder.js';
import { FacturXEncoder } from './formats/facturx.encoder.js';
import { DecoderFactory } from './formats/decoder.factory.js';
import { BaseDecoder } from './formats/base.decoder.js';
export class XInvoice {
private xmlString: string;
@ -17,7 +18,7 @@ export class XInvoice {
private pdfUint8Array: Uint8Array;
private encoderInstance = new FacturXEncoder();
private decoderInstance: ZUGFeRDXmlDecoder;
private decoderInstance: BaseDecoder;
constructor() {
// Decoder will be initialized when we have XML data
@ -36,8 +37,8 @@ export class XInvoice {
// Store the XML string
this.xmlString = xmlString;
// Initialize the decoder with the XML string
this.decoderInstance = new ZUGFeRDXmlDecoder(xmlString);
// Initialize the decoder with the XML string using the factory
this.decoderInstance = DecoderFactory.createDecoder(xmlString);
}
public async addLetterData(letterData: plugins.tsclass.business.ILetter): Promise<void> {
@ -156,7 +157,7 @@ export class XInvoice {
// Initialize the decoder with the XML string if needed
if (!this.decoderInstance) {
this.decoderInstance = new ZUGFeRDXmlDecoder(xmlContent);
this.decoderInstance = DecoderFactory.createDecoder(xmlContent);
}
// Validate the XML format
@ -226,7 +227,7 @@ export class XInvoice {
try {
// Initialize the decoder with XML content if not already done
this.decoderInstance = new ZUGFeRDXmlDecoder(xmlContent);
this.decoderInstance = DecoderFactory.createDecoder(xmlContent);
// First, attempt to identify the XML format
const format = this.identifyXmlFormat(xmlContent);

111
ts/formats/base.decoder.ts Normal file
View File

@ -0,0 +1,111 @@
import * as plugins from '../plugins.js';
/**
* Base decoder class for all invoice XML formats.
* Provides common functionality and interfaces for different format decoders.
*/
export abstract class BaseDecoder {
protected xmlString: string;
constructor(xmlString: string) {
if (!xmlString) {
throw new Error('No XML string provided to decoder');
}
this.xmlString = xmlString;
}
/**
* Abstract method that each format-specific decoder must implement.
* Converts XML into a structured letter object based on the XML format.
*/
public abstract getLetterData(): Promise<plugins.tsclass.business.ILetter>;
/**
* Creates a default letter object with minimal data.
* Used as a fallback when parsing fails.
*/
protected createDefaultLetter(): plugins.tsclass.business.ILetter {
// Create a default seller
const seller: plugins.tsclass.business.IContact = {
name: 'Unknown Seller',
type: 'company',
description: 'Unknown Seller', // Required by IContact interface
address: {
streetName: 'Unknown',
houseNumber: '0', // Required by IAddress interface
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
};
// Create a default buyer
const buyer: plugins.tsclass.business.IContact = {
name: 'Unknown Buyer',
type: 'company',
description: 'Unknown Buyer', // Required by IContact interface
address: {
streetName: 'Unknown',
houseNumber: '0', // Required by IAddress interface
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
};
// Create default invoice data
const invoiceData: plugins.tsclass.finance.IInvoice = {
id: 'Unknown',
status: null,
type: 'debitnote',
billedBy: seller,
billedTo: buyer,
deliveryDate: Date.now(),
dueInDays: 30,
periodOfPerformance: null,
printResult: null,
currency: 'EUR' as plugins.tsclass.finance.TCurrency,
notes: [],
items: [
{
name: 'Unknown Item',
unitQuantity: 1,
unitNetPrice: 0,
vatPercentage: 0,
position: 0,
unitType: 'units',
}
],
reverseCharge: false,
};
// Return a default letter
return {
versionInfo: {
type: 'draft',
version: '1.0.0',
},
type: 'invoice',
date: Date.now(),
subject: 'Unknown Invoice',
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,
};
}
}

View File

@ -0,0 +1,52 @@
import { BaseDecoder } from './base.decoder.js';
import { FacturXDecoder } from './facturx.decoder.js';
import { XInvoiceDecoder } from './xinvoice.decoder.js';
/**
* Factory class for creating the appropriate decoder based on XML format.
* Analyzes XML content and returns the best decoder for the given format.
*/
export class DecoderFactory {
/**
* Creates a decoder for the given XML content
*/
public static createDecoder(xmlString: string): BaseDecoder {
if (!xmlString) {
throw new Error('No XML string provided for decoder selection');
}
const format = DecoderFactory.detectFormat(xmlString);
switch (format) {
case 'XInvoice/UBL':
return new XInvoiceDecoder(xmlString);
case 'FacturX/ZUGFeRD':
default:
// Default to FacturX/ZUGFeRD decoder
return new FacturXDecoder(xmlString);
}
}
/**
* Detects the XML invoice format using string pattern matching
*/
private static detectFormat(xmlString: string): string {
// XInvoice/UBL format
if (xmlString.includes('oasis:names:specification:ubl') ||
xmlString.includes('Invoice xmlns') ||
xmlString.includes('xrechnung')) {
return 'XInvoice/UBL';
}
// ZUGFeRD/Factur-X (CII format)
if (xmlString.includes('CrossIndustryInvoice') ||
xmlString.includes('un/cefact') ||
xmlString.includes('rsm:')) {
return 'FacturX/ZUGFeRD';
}
// Default to FacturX/ZUGFeRD
return 'FacturX/ZUGFeRD';
}
}

View File

@ -0,0 +1,192 @@
import * as plugins from '../plugins.js';
import * as xmldom from 'xmldom';
import { BaseDecoder } from './base.decoder.js';
/**
* A decoder for Factur-X/ZUGFeRD XML format (based on UN/CEFACT CII).
* Converts XML into structured ILetter with invoice data.
*/
export class FacturXDecoder extends BaseDecoder {
private xmlDoc: Document | null = null;
constructor(xmlString: string) {
super(xmlString);
// Parse XML to DOM for easier element extraction
try {
const parser = new xmldom.DOMParser();
this.xmlDoc = parser.parseFromString(this.xmlString, 'text/xml');
} catch (error) {
console.error('Error parsing Factur-X XML:', error);
}
}
/**
* Extracts text from the first element matching the tag name
*/
private getElementText(tagName: string): string {
if (!this.xmlDoc) {
return '';
}
try {
// Basic handling for namespaced tags
let namespace = '';
let localName = tagName;
if (tagName.includes(':')) {
const parts = tagName.split(':');
namespace = parts[0];
localName = parts[1];
}
// Find all elements with this name
const elements = this.xmlDoc.getElementsByTagName(tagName);
if (elements.length > 0) {
return elements[0].textContent || '';
}
// Try with just the local name if we didn't find it with the namespace
if (namespace) {
const elements = this.xmlDoc.getElementsByTagName(localName);
if (elements.length > 0) {
return elements[0].textContent || '';
}
}
return '';
} catch (error) {
console.error(`Error extracting element ${tagName}:`, error);
return '';
}
}
/**
* Converts Factur-X/ZUGFeRD XML to a structured letter object
*/
public async getLetterData(): Promise<plugins.tsclass.business.ILetter> {
try {
// Extract invoice ID
let invoiceId = this.getElementText('ram:ID');
if (!invoiceId) {
// Try alternative locations
invoiceId = this.getElementText('rsm:ExchangedDocument ram:ID') || 'Unknown';
}
// Extract seller name
let sellerName = this.getElementText('ram:Name');
if (!sellerName) {
sellerName = this.getElementText('ram:SellerTradeParty ram:Name') || 'Unknown Seller';
}
// Extract buyer name
let buyerName = '';
// Try to find BuyerTradeParty Name specifically
if (this.xmlDoc) {
const buyerParties = this.xmlDoc.getElementsByTagName('ram:BuyerTradeParty');
if (buyerParties.length > 0) {
const nameElements = buyerParties[0].getElementsByTagName('ram:Name');
if (nameElements.length > 0) {
buyerName = nameElements[0].textContent || '';
}
}
}
if (!buyerName) {
buyerName = 'Unknown Buyer';
}
// Create seller
const seller: plugins.tsclass.business.IContact = {
name: sellerName,
type: 'company',
description: sellerName,
address: {
streetName: this.getElementText('ram:LineOne') || 'Unknown',
houseNumber: '0', // Required by IAddress interface
city: this.getElementText('ram:CityName') || 'Unknown',
country: this.getElementText('ram:CountryID') || 'Unknown',
postalCode: this.getElementText('ram:PostcodeCode') || 'Unknown',
},
};
// Create buyer
const buyer: plugins.tsclass.business.IContact = {
name: buyerName,
type: 'company',
description: buyerName,
address: {
streetName: 'Unknown',
houseNumber: '0',
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
};
// Extract invoice type
let invoiceType = 'debitnote';
const typeCode = this.getElementText('ram:TypeCode');
if (typeCode === '381') {
invoiceType = 'creditnote';
}
// Create invoice data
const invoiceData: plugins.tsclass.finance.IInvoice = {
id: invoiceId,
status: null,
type: invoiceType as 'debitnote' | 'creditnote',
billedBy: seller,
billedTo: buyer,
deliveryDate: Date.now(),
dueInDays: 30,
periodOfPerformance: null,
printResult: null,
currency: (this.getElementText('ram:InvoiceCurrencyCode') || 'EUR') as plugins.tsclass.finance.TCurrency,
notes: [],
items: [
{
name: 'Item from Factur-X XML',
unitQuantity: 1,
unitNetPrice: 0,
vatPercentage: 0,
position: 0,
unitType: 'units',
}
],
reverseCharge: false,
};
// Return a letter
return {
versionInfo: {
type: 'draft',
version: '1.0.0',
},
type: 'invoice',
date: Date.now(),
subject: `Invoice: ${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 Factur-X XML to letter data:', error);
return this.createDefaultLetter();
}
}
}

View File

@ -1,4 +1,4 @@
import * as plugins from './plugins.js';
import * as plugins from '../plugins.js';
/**
* A class to convert a given ILetter with invoice data

View File

@ -0,0 +1,326 @@
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.IContact = {
name: sellerName,
type: 'company',
description: sellerName,
address: {
streetName: sellerStreet,
houseNumber: '0', // Required by IAddress interface
city: sellerCity,
country: sellerCountry,
postalCode: sellerPostcode,
},
};
// Create buyer contact
const buyer: plugins.tsclass.business.IContact = {
name: buyerName,
type: 'company',
description: buyerName,
address: {
streetName: 'Unknown',
houseNumber: '0',
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
};
// 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',
}
];
}
}
}

View File

@ -0,0 +1,335 @@
import * as plugins from '../plugins.js';
/**
* A class to convert a given ILetter with invoice data
* into an XInvoice/XRechnung compliant XML (based on UBL).
*
* XRechnung is the German implementation of the European standard EN16931
* for electronic invoices to the German public sector.
*/
export class XInvoiceEncoder {
constructor() {}
/**
* Creates an XInvoice compliant XML based on the provided letter data.
*/
public createXInvoiceXml(letterArg: plugins.tsclass.business.ILetter): string {
// Use SmartXml for XML creation
const smartxmlInstance = new plugins.smartxml.SmartXml();
if (!letterArg?.content?.invoiceData) {
throw new Error('Letter does not contain invoice data.');
}
const invoice: plugins.tsclass.finance.IInvoice = letterArg.content.invoiceData;
const billedBy: plugins.tsclass.business.IContact = invoice.billedBy;
const billedTo: plugins.tsclass.business.IContact = invoice.billedTo;
// Create the XML document
const doc = smartxmlInstance
.create({ version: '1.0', encoding: 'UTF-8' })
.ele('Invoice', {
'xmlns': 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
'xmlns:cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
'xmlns:cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'
});
// UBL Version ID
doc.ele('cbc:UBLVersionID').txt('2.1').up();
// CustomizationID for XRechnung
doc.ele('cbc:CustomizationID').txt('urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0').up();
// ID - Invoice number
doc.ele('cbc:ID').txt(invoice.id).up();
// Issue date
const issueDate = new Date(letterArg.date);
const issueDateStr = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, '0')}-${String(issueDate.getDate()).padStart(2, '0')}`;
doc.ele('cbc:IssueDate').txt(issueDateStr).up();
// Due date
const dueDate = new Date(letterArg.date);
dueDate.setDate(dueDate.getDate() + invoice.dueInDays);
const dueDateStr = `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, '0')}-${String(dueDate.getDate()).padStart(2, '0')}`;
doc.ele('cbc:DueDate').txt(dueDateStr).up();
// Invoice type code
const invoiceTypeCode = invoice.type === 'creditnote' ? '381' : '380';
doc.ele('cbc:InvoiceTypeCode').txt(invoiceTypeCode).up();
// Note - optional invoice note
if (invoice.notes && invoice.notes.length > 0) {
doc.ele('cbc:Note').txt(invoice.notes[0]).up();
}
// Document currency code
doc.ele('cbc:DocumentCurrencyCode').txt(invoice.currency).up();
// Tax currency code - same as document currency in this case
doc.ele('cbc:TaxCurrencyCode').txt(invoice.currency).up();
// Accounting supplier party (seller)
const supplierParty = doc.ele('cac:AccountingSupplierParty');
const supplierPartyDetails = supplierParty.ele('cac:Party');
// Seller VAT ID
if (billedBy.vatId) {
const partyTaxScheme = supplierPartyDetails.ele('cac:PartyTaxScheme');
partyTaxScheme.ele('cbc:CompanyID').txt(billedBy.vatId).up();
partyTaxScheme.ele('cac:TaxScheme')
.ele('cbc:ID').txt('VAT').up()
.up();
}
// Seller name
supplierPartyDetails.ele('cac:PartyName')
.ele('cbc:Name').txt(billedBy.name).up()
.up();
// Seller postal address
const supplierAddress = supplierPartyDetails.ele('cac:PostalAddress');
supplierAddress.ele('cbc:StreetName').txt(billedBy.address.streetName).up();
if (billedBy.address.houseNumber) {
supplierAddress.ele('cbc:BuildingNumber').txt(billedBy.address.houseNumber).up();
}
supplierAddress.ele('cbc:CityName').txt(billedBy.address.city).up();
supplierAddress.ele('cbc:PostalZone').txt(billedBy.address.postalCode).up();
supplierAddress.ele('cac:Country')
.ele('cbc:IdentificationCode').txt(billedBy.address.country || 'DE').up()
.up();
// Seller contact
const supplierContact = supplierPartyDetails.ele('cac:Contact');
if (billedBy.email) {
supplierContact.ele('cbc:ElectronicMail').txt(billedBy.email).up();
}
if (billedBy.phone) {
supplierContact.ele('cbc:Telephone').txt(billedBy.phone).up();
}
supplierParty.up(); // Close AccountingSupplierParty
// Accounting customer party (buyer)
const customerParty = doc.ele('cac:AccountingCustomerParty');
const customerPartyDetails = customerParty.ele('cac:Party');
// Buyer VAT ID
if (billedTo.vatId) {
const partyTaxScheme = customerPartyDetails.ele('cac:PartyTaxScheme');
partyTaxScheme.ele('cbc:CompanyID').txt(billedTo.vatId).up();
partyTaxScheme.ele('cac:TaxScheme')
.ele('cbc:ID').txt('VAT').up()
.up();
}
// Buyer name
customerPartyDetails.ele('cac:PartyName')
.ele('cbc:Name').txt(billedTo.name).up()
.up();
// Buyer postal address
const customerAddress = customerPartyDetails.ele('cac:PostalAddress');
customerAddress.ele('cbc:StreetName').txt(billedTo.address.streetName).up();
if (billedTo.address.houseNumber) {
customerAddress.ele('cbc:BuildingNumber').txt(billedTo.address.houseNumber).up();
}
customerAddress.ele('cbc:CityName').txt(billedTo.address.city).up();
customerAddress.ele('cbc:PostalZone').txt(billedTo.address.postalCode).up();
customerAddress.ele('cac:Country')
.ele('cbc:IdentificationCode').txt(billedTo.address.country || 'DE').up()
.up();
// Buyer contact
if (billedTo.email || billedTo.phone) {
const customerContact = customerPartyDetails.ele('cac:Contact');
if (billedTo.email) {
customerContact.ele('cbc:ElectronicMail').txt(billedTo.email).up();
}
if (billedTo.phone) {
customerContact.ele('cbc:Telephone').txt(billedTo.phone).up();
}
}
customerParty.up(); // Close AccountingCustomerParty
// Payment means
if (billedBy.sepaConnection) {
const paymentMeans = doc.ele('cac:PaymentMeans');
paymentMeans.ele('cbc:PaymentMeansCode').txt('58').up(); // 58 = SEPA credit transfer
paymentMeans.ele('cbc:PaymentID').txt(invoice.id).up();
// IBAN
if (billedBy.sepaConnection.iban) {
const payeeAccount = paymentMeans.ele('cac:PayeeFinancialAccount');
payeeAccount.ele('cbc:ID').txt(billedBy.sepaConnection.iban).up();
// BIC
if (billedBy.sepaConnection.bic) {
payeeAccount.ele('cac:FinancialInstitutionBranch')
.ele('cbc:ID').txt(billedBy.sepaConnection.bic).up()
.up();
}
}
}
// Payment terms
const paymentTerms = doc.ele('cac:PaymentTerms');
paymentTerms.ele('cbc:Note').txt(`Payment due in ${invoice.dueInDays} days`).up();
// Tax summary
// Group items by VAT rate
const vatRates: { [rate: number]: plugins.tsclass.finance.IInvoiceItem[] } = {};
// Collect items by VAT rate
invoice.items.forEach(item => {
if (!vatRates[item.vatPercentage]) {
vatRates[item.vatPercentage] = [];
}
vatRates[item.vatPercentage].push(item);
});
// Calculate tax subtotals for each rate
Object.entries(vatRates).forEach(([rate, items]) => {
const taxRate = parseFloat(rate);
// Calculate base amount for this rate
let taxableAmount = 0;
items.forEach(item => {
taxableAmount += item.unitNetPrice * item.unitQuantity;
});
// Calculate tax amount
const taxAmount = taxableAmount * (taxRate / 100);
// Create tax subtotal
const taxSubtotal = doc.ele('cac:TaxTotal')
.ele('cbc:TaxAmount').txt(taxAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up();
taxSubtotal.ele('cac:TaxSubtotal')
.ele('cbc:TaxableAmount')
.txt(taxableAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up()
.ele('cbc:TaxAmount')
.txt(taxAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up()
.ele('cac:TaxCategory')
.ele('cbc:ID').txt('S').up() // Standard rate
.ele('cbc:Percent').txt(taxRate.toFixed(2)).up()
.ele('cac:TaxScheme')
.ele('cbc:ID').txt('VAT').up()
.up()
.up()
.up();
});
// Calculate invoice totals
let lineExtensionAmount = 0;
let taxExclusiveAmount = 0;
let taxInclusiveAmount = 0;
let totalVat = 0;
// Sum all items
invoice.items.forEach(item => {
const net = item.unitNetPrice * item.unitQuantity;
const vat = net * (item.vatPercentage / 100);
lineExtensionAmount += net;
taxExclusiveAmount += net;
totalVat += vat;
});
taxInclusiveAmount = taxExclusiveAmount + totalVat;
// Legal monetary total
const legalMonetaryTotal = doc.ele('cac:LegalMonetaryTotal');
legalMonetaryTotal.ele('cbc:LineExtensionAmount')
.txt(lineExtensionAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up();
legalMonetaryTotal.ele('cbc:TaxExclusiveAmount')
.txt(taxExclusiveAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up();
legalMonetaryTotal.ele('cbc:TaxInclusiveAmount')
.txt(taxInclusiveAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up();
legalMonetaryTotal.ele('cbc:PayableAmount')
.txt(taxInclusiveAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up();
// Invoice lines
invoice.items.forEach((item, index) => {
const invoiceLine = doc.ele('cac:InvoiceLine');
invoiceLine.ele('cbc:ID').txt((index + 1).toString()).up();
// Quantity
invoiceLine.ele('cbc:InvoicedQuantity')
.txt(item.unitQuantity.toString())
.att('unitCode', this.mapUnitType(item.unitType))
.up();
// Line extension amount (net)
const lineAmount = item.unitNetPrice * item.unitQuantity;
invoiceLine.ele('cbc:LineExtensionAmount')
.txt(lineAmount.toFixed(2))
.att('currencyID', invoice.currency)
.up();
// Item details
const itemEle = invoiceLine.ele('cac:Item');
itemEle.ele('cbc:Description').txt(item.name).up();
itemEle.ele('cbc:Name').txt(item.name).up();
// Classified tax category
itemEle.ele('cac:ClassifiedTaxCategory')
.ele('cbc:ID').txt('S').up() // Standard rate
.ele('cbc:Percent').txt(item.vatPercentage.toFixed(2)).up()
.ele('cac:TaxScheme')
.ele('cbc:ID').txt('VAT').up()
.up()
.up();
// Price
invoiceLine.ele('cac:Price')
.ele('cbc:PriceAmount')
.txt(item.unitNetPrice.toFixed(2))
.att('currencyID', invoice.currency)
.up()
.up();
});
// Return the formatted XML
return doc.end({ prettyPrint: true });
}
/**
* Helper: Map your custom 'unitType' to an ISO code.
*/
private mapUnitType(unitType: string): string {
switch (unitType.toLowerCase()) {
case 'hour':
case 'hours':
return 'HUR';
case 'day':
case 'days':
return 'DAY';
case 'piece':
case 'pieces':
return 'C62';
default:
return 'C62'; // fallback for unknown unit types
}
}
}

View File

@ -1,18 +1,35 @@
import * as interfaces from './interfaces.js';
import { ZUGFeRDXmlDecoder } from './classes.decoder.js';
import { FacturXEncoder } from './classes.encoder.js';
import { XInvoice } from './classes.xinvoice.js';
// Import format-specific encoder/decoder classes
import { FacturXEncoder } from './formats/facturx.encoder.js';
import { FacturXDecoder } from './formats/facturx.decoder.js';
import { XInvoiceEncoder } from './formats/xinvoice.encoder.js';
import { XInvoiceDecoder } from './formats/xinvoice.decoder.js';
import { DecoderFactory } from './formats/decoder.factory.js';
import { BaseDecoder } from './formats/base.decoder.js';
// Export interfaces
export {
interfaces,
}
export { interfaces };
// Export main class
export { XInvoice }
export { XInvoice };
// Export encoder/decoder classes
export { FacturXEncoder, ZUGFeRDXmlDecoder }
// Export format classes
export {
// Base classes
BaseDecoder,
DecoderFactory,
// Format-specific encoders
FacturXEncoder,
XInvoiceEncoder,
// Format-specific decoders
FacturXDecoder,
XInvoiceDecoder
};
// For backward compatibility
export { FacturXEncoder as ZugferdXmlEncoder }
export { FacturXEncoder as ZugferdXmlEncoder };
export { FacturXDecoder as ZUGFeRDXmlDecoder };