update
This commit is contained in:
111
ts/formats/base.decoder.ts
Normal file
111
ts/formats/base.decoder.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
52
ts/formats/decoder.factory.ts
Normal file
52
ts/formats/decoder.factory.ts
Normal 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';
|
||||
}
|
||||
}
|
192
ts/formats/facturx.decoder.ts
Normal file
192
ts/formats/facturx.decoder.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
345
ts/formats/facturx.encoder.ts
Normal file
345
ts/formats/facturx.encoder.ts
Normal file
@ -0,0 +1,345 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* A class to convert a given ILetter with invoice data
|
||||
* into a Factur-X compliant XML (also compatible with ZUGFeRD and EN16931).
|
||||
*
|
||||
* Factur-X is the French implementation of the European e-invoicing standard EN16931,
|
||||
* which is also implemented in Germany as ZUGFeRD. Both formats are based on
|
||||
* UN/CEFACT Cross Industry Invoice (CII) XML schemas.
|
||||
*/
|
||||
export class FacturXEncoder {
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Alias for createFacturXXml to maintain backward compatibility
|
||||
*/
|
||||
public createZugferdXml(letterArg: plugins.tsclass.business.ILetter): string {
|
||||
return this.createFacturXXml(letterArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Factur-X compliant XML based on the provided letter data.
|
||||
* This XML is also compliant with ZUGFeRD and EN16931 standards.
|
||||
*/
|
||||
public createFacturXXml(letterArg: plugins.tsclass.business.ILetter): string {
|
||||
// 1) Get your "SmartXml" or "xmlbuilder2" instance
|
||||
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;
|
||||
|
||||
// 2) Start building the document
|
||||
const doc = smartxmlInstance
|
||||
.create({ version: '1.0', encoding: 'UTF-8' })
|
||||
.ele('rsm:CrossIndustryInvoice', {
|
||||
'xmlns:rsm': 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
|
||||
'xmlns:udt': 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100',
|
||||
'xmlns:qdt': 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
|
||||
'xmlns:ram': 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'
|
||||
});
|
||||
|
||||
// 3) Exchanged Document Context
|
||||
const docContext = doc.ele('rsm:ExchangedDocumentContext');
|
||||
|
||||
// Add test indicator
|
||||
docContext.ele('ram:TestIndicator')
|
||||
.ele('udt:Indicator')
|
||||
.txt(this.isDraft(letterArg) ? 'true' : 'false')
|
||||
.up()
|
||||
.up();
|
||||
|
||||
// Add Factur-X profile information
|
||||
// EN16931 profile is compliant with both Factur-X and ZUGFeRD
|
||||
docContext.ele('ram:GuidelineSpecifiedDocumentContextParameter')
|
||||
.ele('ram:ID')
|
||||
.txt('urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:en16931')
|
||||
.up()
|
||||
.up();
|
||||
|
||||
docContext.up(); // </rsm:ExchangedDocumentContext>
|
||||
|
||||
// 4) Exchanged Document (Invoice Header Info)
|
||||
const exchangedDoc = doc.ele('rsm:ExchangedDocument');
|
||||
|
||||
// Invoice ID
|
||||
exchangedDoc.ele('ram:ID').txt(invoice.id).up();
|
||||
|
||||
// Document type code
|
||||
// 380 = commercial invoice, 381 = credit note
|
||||
const documentTypeCode = invoice.type === 'creditnote' ? '381' : '380';
|
||||
exchangedDoc.ele('ram:TypeCode').txt(documentTypeCode).up();
|
||||
|
||||
// Issue date
|
||||
exchangedDoc
|
||||
.ele('ram:IssueDateTime')
|
||||
.ele('udt:DateTimeString', { format: '102' })
|
||||
// Format 'YYYYMMDD' as per Factur-X specification
|
||||
.txt(this.formatDate(letterArg.date))
|
||||
.up()
|
||||
.up();
|
||||
|
||||
// Document name - Factur-X recommended field
|
||||
const documentName = invoice.type === 'creditnote' ? 'CREDIT NOTE' : 'INVOICE';
|
||||
exchangedDoc.ele('ram:Name').txt(documentName).up();
|
||||
|
||||
// Optional: Add language indicator (recommended for Factur-X)
|
||||
// Use document language if specified, default to 'en'
|
||||
const languageCode = letterArg.language?.toUpperCase() || 'EN';
|
||||
exchangedDoc
|
||||
.ele('ram:IncludedNote')
|
||||
.ele('ram:Content').txt('Invoice created with Factur-X compliant software').up()
|
||||
.ele('ram:SubjectCode').txt('REG').up() // REG = regulatory information
|
||||
.up();
|
||||
|
||||
exchangedDoc.up(); // </rsm:ExchangedDocument>
|
||||
|
||||
// 5) Supply Chain Trade Transaction
|
||||
const supplyChainEle = doc.ele('rsm:SupplyChainTradeTransaction');
|
||||
|
||||
// 5.1) Included Supply Chain Trade Line Items
|
||||
invoice.items.forEach((item) => {
|
||||
const lineItemEle = supplyChainEle.ele('ram:IncludedSupplyChainTradeLineItem');
|
||||
|
||||
lineItemEle.ele('ram:SpecifiedTradeProduct')
|
||||
.ele('ram:Name')
|
||||
.txt(item.name)
|
||||
.up()
|
||||
.up(); // </ram:SpecifiedTradeProduct>
|
||||
|
||||
lineItemEle.ele('ram:SpecifiedLineTradeAgreement')
|
||||
.ele('ram:GrossPriceProductTradePrice')
|
||||
.ele('ram:ChargeAmount')
|
||||
.txt(item.unitNetPrice.toFixed(2))
|
||||
.up()
|
||||
.up()
|
||||
.up(); // </ram:SpecifiedLineTradeAgreement>
|
||||
|
||||
lineItemEle.ele('ram:SpecifiedLineTradeDelivery')
|
||||
.ele('ram:BilledQuantity')
|
||||
.txt(item.unitQuantity.toString())
|
||||
.up()
|
||||
.up(); // </ram:SpecifiedLineTradeDelivery>
|
||||
|
||||
lineItemEle.ele('ram:SpecifiedLineTradeSettlement')
|
||||
.ele('ram:ApplicableTradeTax')
|
||||
.ele('ram:RateApplicablePercent')
|
||||
.txt(item.vatPercentage.toFixed(2))
|
||||
.up()
|
||||
.up()
|
||||
.ele('ram:SpecifiedTradeSettlementLineMonetarySummation')
|
||||
.ele('ram:LineTotalAmount')
|
||||
.txt(
|
||||
(
|
||||
item.unitQuantity *
|
||||
item.unitNetPrice *
|
||||
(1 + item.vatPercentage / 100)
|
||||
).toFixed(2)
|
||||
)
|
||||
.up()
|
||||
.up()
|
||||
.up(); // </ram:SpecifiedLineTradeSettlement>
|
||||
});
|
||||
|
||||
// 5.2) Applicable Header Trade Agreement
|
||||
const headerTradeAgreementEle = supplyChainEle.ele('ram:ApplicableHeaderTradeAgreement');
|
||||
// Seller
|
||||
const sellerPartyEle = headerTradeAgreementEle.ele('ram:SellerTradeParty');
|
||||
sellerPartyEle.ele('ram:Name').txt(billedBy.name).up();
|
||||
// Example: If it's a company, put company name, etc.
|
||||
const sellerAddressEle = sellerPartyEle.ele('ram:PostalTradeAddress');
|
||||
sellerAddressEle.ele('ram:PostcodeCode').txt(billedBy.address.postalCode).up();
|
||||
sellerAddressEle.ele('ram:LineOne').txt(billedBy.address.streetName).up();
|
||||
sellerAddressEle.ele('ram:CityName').txt(billedBy.address.city).up();
|
||||
// Typically you'd include 'ram:CountryID' with ISO2 code, e.g. "DE"
|
||||
sellerAddressEle.up(); // </ram:PostalTradeAddress>
|
||||
sellerPartyEle.up(); // </ram:SellerTradeParty>
|
||||
|
||||
// Buyer
|
||||
const buyerPartyEle = headerTradeAgreementEle.ele('ram:BuyerTradeParty');
|
||||
buyerPartyEle.ele('ram:Name').txt(billedTo.name).up();
|
||||
const buyerAddressEle = buyerPartyEle.ele('ram:PostalTradeAddress');
|
||||
buyerAddressEle.ele('ram:PostcodeCode').txt(billedTo.address.postalCode).up();
|
||||
buyerAddressEle.ele('ram:LineOne').txt(billedTo.address.streetName).up();
|
||||
buyerAddressEle.ele('ram:CityName').txt(billedTo.address.city).up();
|
||||
buyerAddressEle.up(); // </ram:PostalTradeAddress>
|
||||
buyerPartyEle.up(); // </ram:BuyerTradeParty>
|
||||
headerTradeAgreementEle.up(); // </ram:ApplicableHeaderTradeAgreement>
|
||||
|
||||
// 5.3) Applicable Header Trade Delivery
|
||||
const headerTradeDeliveryEle = supplyChainEle.ele('ram:ApplicableHeaderTradeDelivery');
|
||||
const actualDeliveryEle = headerTradeDeliveryEle.ele('ram:ActualDeliverySupplyChainEvent');
|
||||
const occurrenceEle = actualDeliveryEle.ele('ram:OccurrenceDateTime')
|
||||
.ele('udt:DateTimeString', { format: '102' });
|
||||
|
||||
const deliveryDate = invoice.deliveryDate || letterArg.date;
|
||||
occurrenceEle.txt(this.formatDate(deliveryDate)).up();
|
||||
actualDeliveryEle.up(); // </ram:ActualDeliverySupplyChainEvent>
|
||||
headerTradeDeliveryEle.up(); // </ram:ApplicableHeaderTradeDelivery>
|
||||
|
||||
// 5.4) Applicable Header Trade Settlement
|
||||
const headerTradeSettlementEle = supplyChainEle.ele('ram:ApplicableHeaderTradeSettlement');
|
||||
// Tax currency code, doc currency code, etc.
|
||||
headerTradeSettlementEle.ele('ram:InvoiceCurrencyCode').txt(invoice.currency).up();
|
||||
|
||||
// Example single tax breakdown
|
||||
const tradeTaxEle = headerTradeSettlementEle.ele('ram:ApplicableTradeTax');
|
||||
tradeTaxEle.ele('ram:TypeCode').txt('VAT').up();
|
||||
tradeTaxEle.ele('ram:CalculatedAmount').txt(this.sumAllVat(invoice).toFixed(2)).up();
|
||||
tradeTaxEle
|
||||
.ele('ram:RateApplicablePercent')
|
||||
.txt(this.extractMainVatRate(invoice.items).toFixed(2))
|
||||
.up();
|
||||
tradeTaxEle.up(); // </ram:ApplicableTradeTax>
|
||||
|
||||
// Payment Terms
|
||||
const paymentTermsEle = headerTradeSettlementEle.ele('ram:SpecifiedTradePaymentTerms');
|
||||
|
||||
// Payment description
|
||||
paymentTermsEle.ele('ram:Description').txt(`Payment due in ${invoice.dueInDays} days.`).up();
|
||||
|
||||
// Due date calculation
|
||||
const dueDate = new Date(letterArg.date);
|
||||
dueDate.setDate(dueDate.getDate() + invoice.dueInDays);
|
||||
|
||||
// Add due date as per Factur-X spec
|
||||
paymentTermsEle
|
||||
.ele('ram:DueDateDateTime')
|
||||
.ele('udt:DateTimeString', { format: '102' })
|
||||
.txt(this.formatDate(dueDate.getTime()))
|
||||
.up()
|
||||
.up();
|
||||
|
||||
// Add payment means if available
|
||||
if (invoice.billedBy.sepaConnection) {
|
||||
// Add SEPA information as per Factur-X standard
|
||||
const paymentMeans = headerTradeSettlementEle.ele('ram:SpecifiedTradeSettlementPaymentMeans');
|
||||
paymentMeans.ele('ram:TypeCode').txt('58').up(); // 58 = SEPA credit transfer
|
||||
|
||||
// Payment reference (for bank statement reconciliation)
|
||||
paymentMeans.ele('ram:Information').txt(`Reference: ${invoice.id}`).up();
|
||||
|
||||
// Payee account (IBAN)
|
||||
if (invoice.billedBy.sepaConnection.iban) {
|
||||
const payeeAccount = paymentMeans.ele('ram:PayeePartyCreditorFinancialAccount');
|
||||
payeeAccount.ele('ram:IBANID').txt(invoice.billedBy.sepaConnection.iban).up();
|
||||
payeeAccount.up();
|
||||
}
|
||||
|
||||
// Bank BIC
|
||||
if (invoice.billedBy.sepaConnection.bic) {
|
||||
const payeeBank = paymentMeans.ele('ram:PayeeSpecifiedCreditorFinancialInstitution');
|
||||
payeeBank.ele('ram:BICID').txt(invoice.billedBy.sepaConnection.bic).up();
|
||||
payeeBank.up();
|
||||
}
|
||||
|
||||
paymentMeans.up();
|
||||
}
|
||||
|
||||
paymentTermsEle.up(); // </ram:SpecifiedTradePaymentTerms>
|
||||
|
||||
// Monetary Summation
|
||||
const monetarySummationEle = headerTradeSettlementEle.ele('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
|
||||
monetarySummationEle
|
||||
.ele('ram:LineTotalAmount')
|
||||
.txt(this.calcLineTotalNet(invoice).toFixed(2))
|
||||
.up();
|
||||
monetarySummationEle
|
||||
.ele('ram:TaxTotalAmount')
|
||||
.txt(this.sumAllVat(invoice).toFixed(2))
|
||||
.up();
|
||||
monetarySummationEle
|
||||
.ele('ram:GrandTotalAmount')
|
||||
.txt(this.calcGrandTotal(invoice).toFixed(2))
|
||||
.up();
|
||||
monetarySummationEle.up(); // </ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
headerTradeSettlementEle.up(); // </ram:ApplicableHeaderTradeSettlement>
|
||||
|
||||
supplyChainEle.up(); // </rsm:SupplyChainTradeTransaction>
|
||||
doc.up(); // </rsm:CrossIndustryInvoice>
|
||||
|
||||
// 6) Return the final XML string
|
||||
return doc.end({ prettyPrint: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Determine if the letter is in draft or final.
|
||||
*/
|
||||
private isDraft(letterArg: plugins.tsclass.business.ILetter): boolean {
|
||||
return letterArg.versionInfo?.type === 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format date to certain patterns (very minimal example).
|
||||
* e.g. 'yyyyMMdd' => '20231231'
|
||||
*/
|
||||
private formatDate(timestampMs: number): string {
|
||||
const date = new Date(timestampMs);
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${yyyy}${mm}${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Map your custom 'unitType' to an ISO code or similar.
|
||||
*/
|
||||
private mapUnitType(unitType: string): string {
|
||||
switch (unitType.toLowerCase()) {
|
||||
case 'hour':
|
||||
return 'HUR';
|
||||
case 'piece':
|
||||
return 'C62';
|
||||
default:
|
||||
return 'C62'; // fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Sum all VAT amounts from items.
|
||||
*/
|
||||
private sumAllVat(invoice: plugins.tsclass.finance.IInvoice): number {
|
||||
return invoice.items.reduce((acc, item) => {
|
||||
const net = item.unitNetPrice * item.unitQuantity;
|
||||
const vat = net * (item.vatPercentage / 100);
|
||||
return acc + vat;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Extract main (or highest) VAT rate from items as representative.
|
||||
* In reality, you might list multiple 'ApplicableTradeTax' blocks by group.
|
||||
*/
|
||||
private extractMainVatRate(items: plugins.tsclass.finance.IInvoiceItem[]): number {
|
||||
let max = 0;
|
||||
items.forEach((item) => {
|
||||
if (item.vatPercentage > max) max = item.vatPercentage;
|
||||
});
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Sum net amounts (without VAT).
|
||||
*/
|
||||
private calcLineTotalNet(invoice: plugins.tsclass.finance.IInvoice): number {
|
||||
return invoice.items.reduce((acc, item) => {
|
||||
const net = item.unitNetPrice * item.unitQuantity;
|
||||
return acc + net;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: net + VAT = grand total
|
||||
*/
|
||||
private calcGrandTotal(invoice: plugins.tsclass.finance.IInvoice): number {
|
||||
const net = this.calcLineTotalNet(invoice);
|
||||
const vat = this.sumAllVat(invoice);
|
||||
return net + vat;
|
||||
}
|
||||
}
|
326
ts/formats/xinvoice.decoder.ts
Normal file
326
ts/formats/xinvoice.decoder.ts
Normal 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',
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
335
ts/formats/xinvoice.encoder.ts
Normal file
335
ts/formats/xinvoice.encoder.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user