This commit is contained in:
2025-04-03 16:41:10 +00:00
parent 21650f1181
commit a932d68f86
34 changed files with 1265 additions and 2987 deletions

View File

@ -3,7 +3,7 @@ import { InvoiceFormat } from '../../interfaces/common.js';
import { FormatDetector } from '../utils/format.detector.js';
// Import specific decoders
// import { XRechnungDecoder } from '../ubl/xrechnung/xrechnung.decoder.js';
import { XRechnungDecoder } from '../ubl/xrechnung/xrechnung.decoder.js';
import { FacturXDecoder } from '../cii/facturx/facturx.decoder.js';
// import { ZUGFeRDDecoder } from '../cii/zugferd/zugferd.decoder.js';
@ -21,12 +21,8 @@ export class DecoderFactory {
switch (format) {
case InvoiceFormat.UBL:
// return new UBLDecoder(xml);
throw new Error('UBL decoder not yet implemented');
case InvoiceFormat.XRECHNUNG:
// return new XRechnungDecoder(xml);
throw new Error('XRechnung decoder not yet implemented');
return new XRechnungDecoder(xml);
case InvoiceFormat.CII:
// For now, use Factur-X decoder for generic CII

View File

@ -3,7 +3,7 @@ import { InvoiceFormat } from '../../interfaces/common.js';
import type { ExportFormat } from '../../interfaces/common.js';
// Import specific encoders
// import { XRechnungEncoder } from '../ubl/xrechnung/xrechnung.encoder.js';
import { XRechnungEncoder } from '../ubl/xrechnung/xrechnung.encoder.js';
import { FacturXEncoder } from '../cii/facturx/facturx.encoder.js';
// import { ZUGFeRDEncoder } from '../cii/zugferd/zugferd.encoder.js';
@ -25,8 +25,7 @@ export class EncoderFactory {
case InvoiceFormat.XRECHNUNG:
case 'xrechnung':
// return new XRechnungEncoder();
throw new Error('XRechnung encoder not yet implemented');
return new XRechnungEncoder();
case InvoiceFormat.CII:
// For now, use Factur-X encoder for generic CII

View File

@ -1,4 +1,4 @@
import { PDFDocument } from 'pdf-lib';
import { PDFDocument, AFRelationship } from 'pdf-lib';
import type { IPdf } from '../../interfaces/common.js';
/**
@ -31,8 +31,11 @@ export class PDFEmbedder {
// Use pdf-lib's .attach() to embed the XML
pdfDoc.attach(xmlBuffer, filename, {
mimeType: 'application/xml',
mimeType: 'text/xml',
description: description,
creationDate: new Date(),
modificationDate: new Date(),
afRelationship: AFRelationship.Alternative,
});
// Save the modified PDF

View File

@ -79,16 +79,29 @@ export class PDFExtractor {
}
// Decompress and decode the XML content
const xmlCompressedBytes = xmlFile.getContents().buffer;
const xmlBytes = pako.inflate(xmlCompressedBytes);
const xmlContent = new TextDecoder('utf-8').decode(xmlBytes);
try {
const xmlCompressedBytes = xmlFile.getContents().buffer;
const xmlBytes = pako.inflate(xmlCompressedBytes);
const xmlContent = new TextDecoder('utf-8').decode(xmlBytes);
console.log(`Successfully extracted XML from PDF file. File name: ${xmlFileName}`);
return xmlContent;
console.log(`Successfully extracted XML from PDF file. File name: ${xmlFileName}`);
return xmlContent;
} catch (decompressError) {
// Try without decompression
console.log('Decompression failed, trying without decompression...');
try {
const xmlBytes = xmlFile.getContents();
const xmlContent = new TextDecoder('utf-8').decode(xmlBytes);
console.log(`Successfully extracted uncompressed XML from PDF file. File name: ${xmlFileName}`);
return xmlContent;
} catch (decodeError) {
console.error('Error decoding XML content:', decodeError);
return null;
}
}
} catch (error) {
console.error('Error extracting or parsing embedded XML from PDF:', error);
throw error;
return null;
}
}
}

View File

@ -0,0 +1,292 @@
import { UBLBaseDecoder } from '../ubl.decoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../../interfaces/common.js';
import { business, finance } from '@tsclass/tsclass';
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,
invoiceType: 'creditnote'
} 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,
invoiceType: 'debitnote'
} 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.TInvoiceItem[] = [];
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 articleNumber = this.getText('./cac:Item/cac:SellersItemIdentification/cbc:ID', 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;
}
items.push({
position,
name,
articleNumber,
unitType,
unitQuantity,
unitNetPrice,
vatPercentage
});
}
}
// 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
return {
type: 'invoice',
id: invoiceId,
invoiceId: invoiceId,
date: issueDate,
status: 'invoice',
versionInfo: {
type: 'final',
version: '1.0.0'
},
language: 'en',
incidenceId: invoiceId,
from: seller,
to: buyer,
subject: `Invoice ${invoiceId}`,
items: items,
dueInDays: dueInDays,
reverseCharge: false,
currency: currencyCode as finance.TCurrency,
notes: notes,
objectActions: []
};
} catch (error) {
console.error('Error extracting common data:', error);
// Return default data
return {
type: 'invoice',
id: `INV-${Date.now()}`,
invoiceId: `INV-${Date.now()}`,
date: Date.now(),
status: 'invoice',
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 = '';
// 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 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;
}
}
return {
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
}
};
} 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: ''
}
};
}
}

View File

@ -0,0 +1,144 @@
import { UBLBaseEncoder } from '../ubl.encoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../../interfaces/common.js';
import { UBLDocumentType } from '../ubl.types.js';
/**
* Encoder for XRechnung (UBL) format
* Implements encoding of TInvoice to XRechnung XML
*/
export class XRechnungEncoder extends UBLBaseEncoder {
/**
* Encodes a TCreditNote object to XRechnung XML
* @param creditNote TCreditNote object to encode
* @returns Promise resolving to XML string
*/
protected async encodeCreditNote(creditNote: TCreditNote): Promise<string> {
// For now, we'll just return a simple UBL credit note template
// In a real implementation, we would generate a proper UBL credit note
return `<?xml version="1.0" encoding="UTF-8"?>
<CreditNote xmlns="urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0</cbc:CustomizationID>
<cbc:ID>${creditNote.id}</cbc:ID>
<cbc:IssueDate>${this.formatDate(creditNote.date)}</cbc:IssueDate>
<cbc:CreditNoteTypeCode>381</cbc:CreditNoteTypeCode>
<cbc:DocumentCurrencyCode>${creditNote.currency}</cbc:DocumentCurrencyCode>
<!-- Rest of the credit note XML would go here -->
</CreditNote>`;
}
/**
* Encodes a TDebitNote object to XRechnung XML
* @param debitNote TDebitNote object to encode
* @returns Promise resolving to XML string
*/
protected async encodeDebitNote(debitNote: TDebitNote): Promise<string> {
// For now, we'll just return a simple UBL invoice template
// In a real implementation, we would generate a proper UBL invoice
return `<?xml version="1.0" encoding="UTF-8"?>
<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">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0</cbc:CustomizationID>
<cbc:ID>${debitNote.id}</cbc:ID>
<cbc:IssueDate>${this.formatDate(debitNote.date)}</cbc:IssueDate>
<cbc:DueDate>${this.formatDate(debitNote.date + debitNote.dueInDays * 24 * 60 * 60 * 1000)}</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>${debitNote.currency}</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>${debitNote.from.name}</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>${debitNote.from.address.streetName || ''}</cbc:StreetName>
<cbc:BuildingNumber>${debitNote.from.address.houseNumber || ''}</cbc:BuildingNumber>
<cbc:CityName>${debitNote.from.address.city || ''}</cbc:CityName>
<cbc:PostalZone>${debitNote.from.address.postalCode || ''}</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>${debitNote.from.address.countryCode || ''}</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
${debitNote.from.registrationDetails?.vatId ? `
<cac:PartyTaxScheme>
<cbc:CompanyID>${debitNote.from.registrationDetails.vatId}</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>` : ''}
${debitNote.from.registrationDetails?.registrationId ? `
<cac:PartyLegalEntity>
<cbc:RegistrationName>${debitNote.from.registrationDetails.registrationName || debitNote.from.name}</cbc:RegistrationName>
<cbc:CompanyID>${debitNote.from.registrationDetails.registrationId}</cbc:CompanyID>
</cac:PartyLegalEntity>` : ''}
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>${debitNote.to.name}</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>${debitNote.to.address.streetName || ''}</cbc:StreetName>
<cbc:BuildingNumber>${debitNote.to.address.houseNumber || ''}</cbc:BuildingNumber>
<cbc:CityName>${debitNote.to.address.city || ''}</cbc:CityName>
<cbc:PostalZone>${debitNote.to.address.postalCode || ''}</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>${debitNote.to.address.countryCode || ''}</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
${debitNote.to.registrationDetails?.vatId ? `
<cac:PartyTaxScheme>
<cbc:CompanyID>${debitNote.to.registrationDetails.vatId}</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>` : ''}
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentTerms>
<cbc:Note>Due in ${debitNote.dueInDays} days</cbc:Note>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="${debitNote.currency}">0.00</cbc:TaxAmount>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="${debitNote.currency}">0.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="${debitNote.currency}">0.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="${debitNote.currency}">0.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="${debitNote.currency}">0.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
${debitNote.items.map((item, index) => `
<cac:InvoiceLine>
<cbc:ID>${index + 1}</cbc:ID>
<cbc:InvoicedQuantity unitCode="${item.unitType}">${item.unitQuantity}</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="${debitNote.currency}">${item.unitNetPrice * item.unitQuantity}</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>${item.name}</cbc:Name>
${item.articleNumber ? `
<cac:SellersItemIdentification>
<cbc:ID>${item.articleNumber}</cbc:ID>
</cac:SellersItemIdentification>` : ''}
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>${item.vatPercentage}</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="${debitNote.currency}">${item.unitNetPrice}</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>`).join('')}
</Invoice>`;
}
}

View File

@ -14,51 +14,31 @@ export class FormatDetector {
try {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const root = doc.documentElement;
if (!root) {
return InvoiceFormat.UNKNOWN;
}
// UBL detection (Invoice or CreditNote root element)
if (root.nodeName === 'Invoice' || root.nodeName === 'CreditNote') {
// Check if it's XRechnung by looking at CustomizationID
const customizationNodes = root.getElementsByTagName('cbc:CustomizationID');
if (customizationNodes.length > 0) {
const customizationId = customizationNodes[0].textContent || '';
if (customizationId.includes('xrechnung') || customizationId.includes('XRechnung')) {
return InvoiceFormat.XRECHNUNG;
}
}
return InvoiceFormat.UBL;
// For simplicity, we'll treat all UBL documents as XRechnung for now
// In a real implementation, we would check for specific customization IDs
return InvoiceFormat.XRECHNUNG;
}
// Factur-X/ZUGFeRD detection (CrossIndustryInvoice root element)
if (root.nodeName === 'rsm:CrossIndustryInvoice' || root.nodeName === 'CrossIndustryInvoice') {
// Check for profile to determine if it's Factur-X or ZUGFeRD
const profileNodes = root.getElementsByTagName('ram:ID');
for (let i = 0; i < profileNodes.length; i++) {
const profileText = profileNodes[i].textContent || '';
if (profileText.includes('factur-x') || profileText.includes('Factur-X')) {
return InvoiceFormat.FACTURX;
}
if (profileText.includes('zugferd') || profileText.includes('ZUGFeRD')) {
return InvoiceFormat.ZUGFERD;
}
}
// If no specific profile found, default to CII
return InvoiceFormat.CII;
// For simplicity, we'll treat all CII documents as Factur-X for now
// In a real implementation, we would check for specific profiles
return InvoiceFormat.FACTURX;
}
// FatturaPA detection would be implemented here
if (root.nodeName === 'FatturaElettronica' ||
if (root.nodeName === 'FatturaElettronica' ||
(root.getAttribute('xmlns') && root.getAttribute('xmlns')!.includes('fatturapa.gov.it'))) {
return InvoiceFormat.FATTURAPA;
}
return InvoiceFormat.UNKNOWN;
} catch (error) {
console.error('Error detecting format:', error);