This commit is contained in:
2025-04-03 15:53:08 +00:00
parent 3e8b5c2869
commit 21650f1181
49 changed files with 4835 additions and 2878 deletions

View File

@ -1,143 +0,0 @@
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.TContact = {
name: 'Unknown Seller',
type: 'company',
description: 'Unknown Seller',
address: {
streetName: 'Unknown',
houseNumber: '0',
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
registrationDetails: {
vatId: 'Unknown',
registrationId: 'Unknown',
registrationName: 'Unknown'
},
foundedDate: {
year: 2000,
month: 1,
day: 1
},
closedDate: {
year: 9999,
month: 12,
day: 31
},
status: 'active'
};
// Create a default buyer
const buyer: plugins.tsclass.business.TContact = {
name: 'Unknown Buyer',
type: 'company',
description: 'Unknown Buyer',
address: {
streetName: 'Unknown',
houseNumber: '0',
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
registrationDetails: {
vatId: 'Unknown',
registrationId: 'Unknown',
registrationName: 'Unknown'
},
foundedDate: {
year: 2000,
month: 1,
day: 1
},
closedDate: {
year: 9999,
month: 12,
day: 31
},
status: 'active'
};
// 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,37 @@
import type { TInvoice } from '../../interfaces/common.js';
import { ValidationLevel } from '../../interfaces/common.js';
import type { ValidationResult } from '../../interfaces/common.js';
/**
* Base decoder class that defines common decoding functionality
* for all invoice format decoders
*/
export abstract class BaseDecoder {
protected xml: string;
constructor(xml: string) {
this.xml = xml;
}
/**
* Decodes XML into a TInvoice object
* @returns Promise resolving to a TInvoice object
*/
abstract decode(): Promise<TInvoice>;
/**
* Gets letter data in the standard format
* @returns Promise resolving to a TInvoice object
*/
public async getLetterData(): Promise<TInvoice> {
return this.decode();
}
/**
* Gets the raw XML content
* @returns XML string
*/
public getXml(): string {
return this.xml;
}
}

View File

@ -0,0 +1,14 @@
import type { TInvoice } from '../../interfaces/common.js';
/**
* Base encoder class that defines common encoding functionality
* for all invoice format encoders
*/
export abstract class BaseEncoder {
/**
* Encodes a TInvoice object into XML
* @param invoice TInvoice object to encode
* @returns XML string
*/
abstract encode(invoice: TInvoice): Promise<string>;
}

View File

@ -1,5 +1,5 @@
import { ValidationLevel } from '../interfaces.js';
import type { ValidationResult, ValidationError } from '../interfaces.js';
import { ValidationLevel } from '../../interfaces/common.js';
import type { ValidationResult, ValidationError } from '../../interfaces/common.js';
/**
* Base validator class that defines common validation functionality
@ -61,4 +61,4 @@ export abstract class BaseValidator {
location
});
}
}
}

View File

@ -0,0 +1,140 @@
import { BaseDecoder } from '../base/base.decoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.js';
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
import { DOMParser } from 'xmldom';
import * as xpath from 'xpath';
/**
* Base decoder for CII-based invoice formats
*/
export abstract class CIIBaseDecoder extends BaseDecoder {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
protected profile: CIIProfile = CIIProfile.EN16931;
constructor(xml: string) {
super(xml);
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT
};
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
// Detect profile
this.detectProfile();
}
/**
* Decodes CII XML into a TInvoice object
* @returns Promise resolving to a TInvoice object
*/
public async decode(): Promise<TInvoice> {
// Determine if it's a credit note or debit note based on type code
const typeCode = this.getText('//ram:TypeCode');
if (typeCode === '381') { // Credit note type code
return this.decodeCreditNote();
} else {
return this.decodeDebitNote();
}
}
/**
* Detects the CII profile from the XML
*/
protected detectProfile(): void {
// Look for profile identifier
const profileNode = this.select(
'string(//rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID)',
this.doc
);
if (profileNode) {
const profileText = profileNode.toString();
if (profileText.includes('BASIC')) {
this.profile = CIIProfile.BASIC;
} else if (profileText.includes('EN16931')) {
this.profile = CIIProfile.EN16931;
} else if (profileText.includes('EXTENDED')) {
this.profile = CIIProfile.EXTENDED;
} else if (profileText.includes('MINIMUM')) {
this.profile = CIIProfile.MINIMUM;
} else if (profileText.includes('COMFORT')) {
this.profile = CIIProfile.COMFORT;
}
}
}
/**
* Decodes a CII credit note
* @returns Promise resolving to a TCreditNote object
*/
protected abstract decodeCreditNote(): Promise<TCreditNote>;
/**
* Decodes a CII debit note (invoice)
* @returns Promise resolving to a TDebitNote object
*/
protected abstract decodeDebitNote(): Promise<TDebitNote>;
/**
* Gets a text value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
return node ? (node.textContent || '') : '';
}
/**
* Gets a number value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Number value or 0 if not found or not a number
*/
protected getNumber(xpathExpr: string, context?: Node): number {
const text = this.getText(xpathExpr, context);
const num = parseFloat(text);
return isNaN(num) ? 0 : num;
}
/**
* Gets a date value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Date timestamp or current time if not found or invalid
*/
protected getDate(xpathExpr: string, context?: Node): number {
const text = this.getText(xpathExpr, context);
if (!text) return Date.now();
const date = new Date(text);
return isNaN(date.getTime()) ? Date.now() : date.getTime();
}
/**
* Checks if a node exists
* @param xpath XPath expression
* @param context Optional context node
* @returns True if node exists
*/
protected exists(xpathExpr: string, context?: Node): boolean {
const nodes = this.select(xpathExpr, context || this.doc);
if (Array.isArray(nodes)) {
return nodes.length > 0;
}
return false;
}
}

View File

@ -0,0 +1,68 @@
import { BaseEncoder } from '../base/base.encoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.js';
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
/**
* Base encoder for CII-based invoice formats
*/
export abstract class CIIBaseEncoder extends BaseEncoder {
protected profile: CIIProfile = CIIProfile.EN16931;
/**
* Sets the CII profile to use for encoding
* @param profile CII profile
*/
public setProfile(profile: CIIProfile): void {
this.profile = profile;
}
/**
* Encodes a TInvoice object into CII XML
* @param invoice TInvoice object to encode
* @returns CII XML string
*/
public async encode(invoice: TInvoice): Promise<string> {
// Determine if it's a credit note or debit note
if (invoice.invoiceType === 'creditnote') {
return this.encodeCreditNote(invoice as TCreditNote);
} else {
return this.encodeDebitNote(invoice as TDebitNote);
}
}
/**
* Encodes a TCreditNote object into CII XML
* @param creditNote TCreditNote object to encode
* @returns CII XML string
*/
protected abstract encodeCreditNote(creditNote: TCreditNote): Promise<string>;
/**
* Encodes a TDebitNote object into CII XML
* @param debitNote TDebitNote object to encode
* @returns CII XML string
*/
protected abstract encodeDebitNote(debitNote: TDebitNote): Promise<string>;
/**
* Creates the XML declaration and root element
* @returns XML string with declaration and root element
*/
protected createXmlRoot(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="${CII_NAMESPACES.RSM}"
xmlns:ram="${CII_NAMESPACES.RAM}"
xmlns:udt="${CII_NAMESPACES.UDT}">
</rsm:CrossIndustryInvoice>`;
}
/**
* Formats a date as an ISO string (YYYY-MM-DD)
* @param timestamp Timestamp to format
* @returns Formatted date string
*/
protected formatDate(timestamp: number): string {
const date = new Date(timestamp);
return date.toISOString().split('T')[0];
}
}

View File

@ -0,0 +1,29 @@
/**
* CII-specific types and constants
*/
// CII namespaces
export const CII_NAMESPACES = {
RSM: 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
RAM: 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
UDT: 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'
};
// CII profiles
export enum CIIProfile {
BASIC = 'BASIC',
COMFORT = 'COMFORT',
EXTENDED = 'EXTENDED',
EN16931 = 'EN16931',
MINIMUM = 'MINIMUM'
}
// CII profile IDs for different formats
export const CII_PROFILE_IDS = {
FACTURX_MINIMUM: 'urn:factur-x.eu:1p0:minimum',
FACTURX_BASIC: 'urn:factur-x.eu:1p0:basicwl',
FACTURX_EN16931: 'urn:cen.eu:en16931:2017',
ZUGFERD_BASIC: 'urn:zugferd:basic',
ZUGFERD_COMFORT: 'urn:zugferd:comfort',
ZUGFERD_EXTENDED: 'urn:zugferd:extended'
};

View File

@ -0,0 +1,172 @@
import { BaseValidator } from '../base/base.validator.js';
import { ValidationLevel } from '../../interfaces/common.js';
import type { ValidationResult } from '../../interfaces/common.js';
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
import { DOMParser } from 'xmldom';
import * as xpath from 'xpath';
/**
* Base validator for CII-based invoice formats
*/
export abstract class CIIBaseValidator extends BaseValidator {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
protected profile: CIIProfile = CIIProfile.EN16931;
constructor(xml: string) {
super(xml);
try {
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT
};
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
// Detect profile
this.detectProfile();
} catch (error) {
this.addError('CII-PARSE', `Failed to parse XML: ${error}`, '/');
}
}
/**
* Validates CII XML against the specified level of validation
* @param level Validation level
* @returns Result of validation
*/
public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult {
// Reset errors
this.errors = [];
// Check if document was parsed successfully
if (!this.doc) {
return {
valid: false,
errors: this.errors,
level: level
};
}
// Perform validation based on level
let valid = true;
if (level === ValidationLevel.SYNTAX) {
valid = this.validateSchema();
} else if (level === ValidationLevel.SEMANTIC) {
valid = this.validateSchema() && this.validateStructure();
} else if (level === ValidationLevel.BUSINESS) {
valid = this.validateSchema() &&
this.validateStructure() &&
this.validateBusinessRules();
}
return {
valid,
errors: this.errors,
level
};
}
/**
* Validates CII XML against schema
* @returns True if schema validation passed
*/
protected validateSchema(): boolean {
// Basic schema validation (simplified for now)
if (!this.doc) return false;
// Check for root element
const root = this.doc.documentElement;
if (!root || root.nodeName !== 'rsm:CrossIndustryInvoice') {
this.addError('CII-SCHEMA-1', 'Root element must be rsm:CrossIndustryInvoice', '/');
return false;
}
// Check for required namespaces
if (!root.lookupNamespaceURI('rsm') || !root.lookupNamespaceURI('ram')) {
this.addError('CII-SCHEMA-2', 'Required namespaces rsm and ram must be declared', '/');
return false;
}
return true;
}
/**
* Validates structure of the CII XML document
* @returns True if structure validation passed
*/
protected abstract validateStructure(): boolean;
/**
* Detects the CII profile from the XML
*/
protected detectProfile(): void {
// Look for profile identifier
const profileNode = this.select(
'string(//rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID)',
this.doc
);
if (profileNode) {
const profileText = profileNode.toString();
if (profileText.includes('BASIC')) {
this.profile = CIIProfile.BASIC;
} else if (profileText.includes('EN16931')) {
this.profile = CIIProfile.EN16931;
} else if (profileText.includes('EXTENDED')) {
this.profile = CIIProfile.EXTENDED;
} else if (profileText.includes('MINIMUM')) {
this.profile = CIIProfile.MINIMUM;
} else if (profileText.includes('COMFORT')) {
this.profile = CIIProfile.COMFORT;
}
}
}
/**
* Gets a text value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
return node ? (node.textContent || '') : '';
}
/**
* Gets a number value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Number value or 0 if not found or not a number
*/
protected getNumber(xpathExpr: string, context?: Node): number {
const text = this.getText(xpathExpr, context);
const num = parseFloat(text);
return isNaN(num) ? 0 : num;
}
/**
* Checks if a node exists
* @param xpath XPath expression
* @param context Optional context node
* @returns True if node exists
*/
protected exists(xpathExpr: string, context?: Node): boolean {
const nodes = this.select(xpathExpr, context || this.doc);
if (Array.isArray(nodes)) {
return nodes.length > 0;
}
return false;
}
}

View File

@ -0,0 +1,220 @@
import { CIIBaseDecoder } from '../cii.decoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../../interfaces/common.js';
import { FACTURX_PROFILE_IDS } from './facturx.types.js';
import { business, finance, general } from '@tsclass/tsclass';
/**
* Decoder for Factur-X invoice format
*/
export class FacturXDecoder extends CIIBaseDecoder {
/**
* Decodes a Factur-X credit note
* @returns Promise resolving to a TCreditNote object
*/
protected async decodeCreditNote(): Promise<TCreditNote> {
// Get common invoice data
const commonData = await this.extractCommonData();
// Create a credit note with the common data
return {
...commonData,
invoiceType: 'creditnote'
} as TCreditNote;
}
/**
* Decodes a Factur-X debit note (invoice)
* @returns Promise resolving to a TDebitNote object
*/
protected async decodeDebitNote(): Promise<TDebitNote> {
// Get common invoice data
const commonData = await this.extractCommonData();
// Create a debit note with the common data
return {
...commonData,
invoiceType: 'debitnote'
} as TDebitNote;
}
/**
* Extracts common invoice data from Factur-X XML
* @returns Common invoice data
*/
private async extractCommonData(): Promise<Partial<TInvoice>> {
// Extract invoice ID
const invoiceId = this.getText('//rsm:ExchangedDocument/ram:ID');
// Extract issue date
const issueDateStr = this.getText('//ram:IssueDateTime/udt:DateTimeString');
const issueDate = issueDateStr ? new Date(issueDateStr).getTime() : Date.now();
// Extract seller information
const seller = this.extractParty('//ram:SellerTradeParty');
// Extract buyer information
const buyer = this.extractParty('//ram:BuyerTradeParty');
// Extract items
const items = this.extractItems();
// Extract due date
const dueDateStr = this.getText('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString');
const dueDate = dueDateStr ? new Date(dueDateStr).getTime() : Date.now();
const dueInDays = Math.round((dueDate - issueDate) / (1000 * 60 * 60 * 24));
// Extract currency
const currencyCode = this.getText('//ram:InvoiceCurrencyCode') || 'EUR';
// Extract total amount
const totalAmount = this.getNumber('//ram:GrandTotalAmount');
// Extract notes
const notes = this.extractNotes();
// Check for reverse charge
const reverseCharge = this.exists('//ram:SpecifiedTradeAllowanceCharge/ram:ReasonCode[text()="62"]');
// Create the common invoice data
return {
type: 'invoice',
id: 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: reverseCharge,
currency: currencyCode as finance.TCurrency,
notes: notes,
deliveryDate: issueDate,
objectActions: [],
invoiceType: 'debitnote' // Default to debit note, will be overridden in decode methods
};
}
/**
* Extracts party information from Factur-X XML
* @param partyXPath XPath to the party node
* @returns Party information as TContact
*/
private extractParty(partyXPath: string): business.TContact {
// Extract name
const name = this.getText(`${partyXPath}/ram:Name`);
// Extract address
const address: business.IAddress = {
streetName: this.getText(`${partyXPath}/ram:PostalTradeAddress/ram:LineOne`) || '',
houseNumber: this.getText(`${partyXPath}/ram:PostalTradeAddress/ram:LineTwo`) || '0',
postalCode: this.getText(`${partyXPath}/ram:PostalTradeAddress/ram:PostcodeCode`) || '',
city: this.getText(`${partyXPath}/ram:PostalTradeAddress/ram:CityName`) || '',
country: this.getText(`${partyXPath}/ram:PostalTradeAddress/ram:CountryID`) || '',
countryCode: this.getText(`${partyXPath}/ram:PostalTradeAddress/ram:CountryID`) || ''
};
// Extract VAT ID
const vatId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]`) || '';
// Extract registration ID
const registrationId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]`) || '';
// Create contact object
return {
type: 'company',
name: name,
description: '',
address: address,
status: 'active',
foundedDate: this.createDefaultDate(),
registrationDetails: {
vatId: vatId,
registrationId: registrationId,
registrationName: ''
}
} as business.TContact;
}
/**
* Extracts invoice items from Factur-X XML
* @returns Array of invoice items
*/
private extractItems(): finance.TInvoiceItem[] {
const items: finance.TInvoiceItem[] = [];
// Get all item nodes
const itemNodes = this.select('//ram:IncludedSupplyChainTradeLineItem', this.doc);
// Process each item
if (Array.isArray(itemNodes)) {
for (let i = 0; i < itemNodes.length; i++) {
const itemNode = itemNodes[i];
// Extract item data
const name = this.getText('ram:SpecifiedTradeProduct/ram:Name', itemNode);
const articleNumber = this.getText('ram:SpecifiedTradeProduct/ram:SellerAssignedID', itemNode);
const unitQuantity = this.getNumber('ram:SpecifiedLineTradeDelivery/ram:BilledQuantity', itemNode);
const unitType = this.getText('ram:SpecifiedLineTradeDelivery/ram:BilledQuantity/@unitCode', itemNode) || 'EA';
const unitNetPrice = this.getNumber('ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount', itemNode);
const vatPercentage = this.getNumber('ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent', itemNode);
// Create item object
items.push({
position: i + 1,
name: name,
articleNumber: articleNumber,
unitType: unitType,
unitQuantity: unitQuantity,
unitNetPrice: unitNetPrice,
vatPercentage: vatPercentage
});
}
}
return items;
}
/**
* Extracts notes from Factur-X XML
* @returns Array of notes
*/
private extractNotes(): string[] {
const notes: string[] = [];
// Get all note nodes
const noteNodes = this.select('//ram:IncludedNote', this.doc);
// Process each note
if (Array.isArray(noteNodes)) {
for (let i = 0; i < noteNodes.length; i++) {
const noteNode = noteNodes[i];
const noteText = this.getText('ram:Content', noteNode);
if (noteText) {
notes.push(noteText);
}
}
}
return notes;
}
/**
* Creates a default date object
* @returns Default date object
*/
private createDefaultDate(): general.IDate {
return {
year: 2000,
month: 1,
day: 1
};
}
}

View File

@ -0,0 +1,465 @@
import { CIIBaseEncoder } from '../cii.encoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../../interfaces/common.js';
import { FACTURX_PROFILE_IDS } from './facturx.types.js';
import { DOMParser, XMLSerializer } from 'xmldom';
/**
* Encoder for Factur-X invoice format
*/
export class FacturXEncoder extends CIIBaseEncoder {
/**
* Encodes a TCreditNote object into Factur-X XML
* @param creditNote TCreditNote object to encode
* @returns Factur-X XML string
*/
protected async encodeCreditNote(creditNote: TCreditNote): Promise<string> {
// Create base XML
const xmlDoc = this.createBaseXml();
// Set document type code to credit note (381)
this.setDocumentTypeCode(xmlDoc, '381');
// Add common invoice data
this.addCommonInvoiceData(xmlDoc, creditNote);
// Serialize to string
return new XMLSerializer().serializeToString(xmlDoc);
}
/**
* Encodes a TDebitNote object into Factur-X XML
* @param debitNote TDebitNote object to encode
* @returns Factur-X XML string
*/
protected async encodeDebitNote(debitNote: TDebitNote): Promise<string> {
// Create base XML
const xmlDoc = this.createBaseXml();
// Set document type code to invoice (380)
this.setDocumentTypeCode(xmlDoc, '380');
// Add common invoice data
this.addCommonInvoiceData(xmlDoc, debitNote);
// Serialize to string
return new XMLSerializer().serializeToString(xmlDoc);
}
/**
* Creates a base Factur-X XML document
* @returns XML document with basic structure
*/
private createBaseXml(): Document {
// Create XML document from template
const xmlString = this.createXmlRoot();
const doc = new DOMParser().parseFromString(xmlString, 'application/xml');
// Add Factur-X profile
this.addProfile(doc);
return doc;
}
/**
* Adds Factur-X profile information to the XML document
* @param doc XML document
*/
private addProfile(doc: Document): void {
// Get root element
const root = doc.documentElement;
// Create context element if it doesn't exist
let contextElement = root.getElementsByTagName('rsm:ExchangedDocumentContext')[0];
if (!contextElement) {
contextElement = doc.createElement('rsm:ExchangedDocumentContext');
root.appendChild(contextElement);
}
// Create guideline parameter element
const guidelineElement = doc.createElement('ram:GuidelineSpecifiedDocumentContextParameter');
contextElement.appendChild(guidelineElement);
// Add ID element with profile
const idElement = doc.createElement('ram:ID');
// Set profile based on the selected profile
let profileId = FACTURX_PROFILE_IDS.EN16931;
if (this.profile === 'BASIC') {
profileId = FACTURX_PROFILE_IDS.BASIC;
} else if (this.profile === 'MINIMUM') {
profileId = FACTURX_PROFILE_IDS.MINIMUM;
}
idElement.textContent = profileId;
guidelineElement.appendChild(idElement);
}
/**
* Sets the document type code in the XML document
* @param doc XML document
* @param typeCode Document type code (380 for invoice, 381 for credit note)
*/
private setDocumentTypeCode(doc: Document, typeCode: string): void {
// Get root element
const root = doc.documentElement;
// Create document element if it doesn't exist
let documentElement = root.getElementsByTagName('rsm:ExchangedDocument')[0];
if (!documentElement) {
documentElement = doc.createElement('rsm:ExchangedDocument');
root.appendChild(documentElement);
}
// Add type code element
const typeCodeElement = doc.createElement('ram:TypeCode');
typeCodeElement.textContent = typeCode;
documentElement.appendChild(typeCodeElement);
}
/**
* Adds common invoice data to the XML document
* @param doc XML document
* @param invoice Invoice data
*/
private addCommonInvoiceData(doc: Document, invoice: TInvoice): void {
// Get root element
const root = doc.documentElement;
// Get document element or create it
let documentElement = root.getElementsByTagName('rsm:ExchangedDocument')[0];
if (!documentElement) {
documentElement = doc.createElement('rsm:ExchangedDocument');
root.appendChild(documentElement);
}
// Add ID element
const idElement = doc.createElement('ram:ID');
idElement.textContent = invoice.id;
documentElement.appendChild(idElement);
// Add issue date element
const issueDateElement = doc.createElement('ram:IssueDateTime');
const dateStringElement = doc.createElement('udt:DateTimeString');
dateStringElement.setAttribute('format', '102'); // YYYYMMDD format
dateStringElement.textContent = this.formatDateYYYYMMDD(invoice.date);
issueDateElement.appendChild(dateStringElement);
documentElement.appendChild(issueDateElement);
// Create transaction element if it doesn't exist
let transactionElement = root.getElementsByTagName('rsm:SupplyChainTradeTransaction')[0];
if (!transactionElement) {
transactionElement = doc.createElement('rsm:SupplyChainTradeTransaction');
root.appendChild(transactionElement);
}
// Add agreement section with seller and buyer
this.addAgreementSection(doc, transactionElement, invoice);
// Add delivery section
this.addDeliverySection(doc, transactionElement, invoice);
// Add settlement section with payment terms and totals
this.addSettlementSection(doc, transactionElement, invoice);
// Add line items
this.addLineItems(doc, transactionElement, invoice);
}
/**
* Adds agreement section with seller and buyer information
* @param doc XML document
* @param transactionElement Transaction element
* @param invoice Invoice data
*/
private addAgreementSection(doc: Document, transactionElement: Element, invoice: TInvoice): void {
// Create agreement element
const agreementElement = doc.createElement('ram:ApplicableHeaderTradeAgreement');
transactionElement.appendChild(agreementElement);
// Add seller
const sellerElement = doc.createElement('ram:SellerTradeParty');
this.addPartyInfo(doc, sellerElement, invoice.from);
agreementElement.appendChild(sellerElement);
// Add buyer
const buyerElement = doc.createElement('ram:BuyerTradeParty');
this.addPartyInfo(doc, buyerElement, invoice.to);
agreementElement.appendChild(buyerElement);
}
/**
* Adds party information to an element
* @param doc XML document
* @param partyElement Party element
* @param party Party data
*/
private addPartyInfo(doc: Document, partyElement: Element, party: any): void {
// Add name
const nameElement = doc.createElement('ram:Name');
nameElement.textContent = party.name;
partyElement.appendChild(nameElement);
// Add postal address
const addressElement = doc.createElement('ram:PostalTradeAddress');
// Add address line 1 (street)
const line1Element = doc.createElement('ram:LineOne');
line1Element.textContent = party.address.streetName;
addressElement.appendChild(line1Element);
// Add address line 2 (house number)
const line2Element = doc.createElement('ram:LineTwo');
line2Element.textContent = party.address.houseNumber;
addressElement.appendChild(line2Element);
// Add postal code
const postalCodeElement = doc.createElement('ram:PostcodeCode');
postalCodeElement.textContent = party.address.postalCode;
addressElement.appendChild(postalCodeElement);
// Add city
const cityElement = doc.createElement('ram:CityName');
cityElement.textContent = party.address.city;
addressElement.appendChild(cityElement);
// Add country
const countryElement = doc.createElement('ram:CountryID');
countryElement.textContent = party.address.countryCode || party.address.country;
addressElement.appendChild(countryElement);
partyElement.appendChild(addressElement);
// Add VAT ID if available
if (party.registrationDetails && party.registrationDetails.vatId) {
const taxRegistrationElement = doc.createElement('ram:SpecifiedTaxRegistration');
const taxIdElement = doc.createElement('ram:ID');
taxIdElement.setAttribute('schemeID', 'VA');
taxIdElement.textContent = party.registrationDetails.vatId;
taxRegistrationElement.appendChild(taxIdElement);
partyElement.appendChild(taxRegistrationElement);
}
// Add registration ID if available
if (party.registrationDetails && party.registrationDetails.registrationId) {
const regRegistrationElement = doc.createElement('ram:SpecifiedTaxRegistration');
const regIdElement = doc.createElement('ram:ID');
regIdElement.setAttribute('schemeID', 'FC');
regIdElement.textContent = party.registrationDetails.registrationId;
regRegistrationElement.appendChild(regIdElement);
partyElement.appendChild(regRegistrationElement);
}
}
/**
* Adds delivery section with delivery information
* @param doc XML document
* @param transactionElement Transaction element
* @param invoice Invoice data
*/
private addDeliverySection(doc: Document, transactionElement: Element, invoice: TInvoice): void {
// Create delivery element
const deliveryElement = doc.createElement('ram:ApplicableHeaderTradeDelivery');
transactionElement.appendChild(deliveryElement);
// Add delivery date if available
if (invoice.deliveryDate) {
const deliveryDateElement = doc.createElement('ram:ActualDeliverySupplyChainEvent');
const occurrenceDateElement = doc.createElement('ram:OccurrenceDateTime');
const dateStringElement = doc.createElement('udt:DateTimeString');
dateStringElement.setAttribute('format', '102'); // YYYYMMDD format
dateStringElement.textContent = this.formatDateYYYYMMDD(invoice.deliveryDate);
occurrenceDateElement.appendChild(dateStringElement);
deliveryDateElement.appendChild(occurrenceDateElement);
deliveryElement.appendChild(deliveryDateElement);
}
}
/**
* Adds settlement section with payment terms and totals
* @param doc XML document
* @param transactionElement Transaction element
* @param invoice Invoice data
*/
private addSettlementSection(doc: Document, transactionElement: Element, invoice: TInvoice): void {
// Create settlement element
const settlementElement = doc.createElement('ram:ApplicableHeaderTradeSettlement');
transactionElement.appendChild(settlementElement);
// Add currency
const currencyElement = doc.createElement('ram:InvoiceCurrencyCode');
currencyElement.textContent = invoice.currency;
settlementElement.appendChild(currencyElement);
// Add payment terms
const paymentTermsElement = doc.createElement('ram:SpecifiedTradePaymentTerms');
// Add due date
const dueDateElement = doc.createElement('ram:DueDateDateTime');
const dateStringElement = doc.createElement('udt:DateTimeString');
dateStringElement.setAttribute('format', '102'); // YYYYMMDD format
// Calculate due date
const dueDate = new Date(invoice.date);
dueDate.setDate(dueDate.getDate() + invoice.dueInDays);
dateStringElement.textContent = this.formatDateYYYYMMDD(dueDate.getTime());
dueDateElement.appendChild(dateStringElement);
paymentTermsElement.appendChild(dueDateElement);
settlementElement.appendChild(paymentTermsElement);
// Add totals
const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
// Calculate totals
let totalNetAmount = 0;
let totalTaxAmount = 0;
// Calculate from items
if (invoice.items) {
for (const item of invoice.items) {
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
totalNetAmount += itemNetAmount;
totalTaxAmount += itemTaxAmount;
}
}
const totalGrossAmount = totalNetAmount + totalTaxAmount;
// Add line total amount
const lineTotalElement = doc.createElement('ram:LineTotalAmount');
lineTotalElement.textContent = totalNetAmount.toFixed(2);
monetarySummationElement.appendChild(lineTotalElement);
// Add tax total amount
const taxTotalElement = doc.createElement('ram:TaxTotalAmount');
taxTotalElement.textContent = totalTaxAmount.toFixed(2);
taxTotalElement.setAttribute('currencyID', invoice.currency);
monetarySummationElement.appendChild(taxTotalElement);
// Add grand total amount
const grandTotalElement = doc.createElement('ram:GrandTotalAmount');
grandTotalElement.textContent = totalGrossAmount.toFixed(2);
monetarySummationElement.appendChild(grandTotalElement);
// Add due payable amount
const duePayableElement = doc.createElement('ram:DuePayableAmount');
duePayableElement.textContent = totalGrossAmount.toFixed(2);
monetarySummationElement.appendChild(duePayableElement);
settlementElement.appendChild(monetarySummationElement);
}
/**
* Adds line items to the XML document
* @param doc XML document
* @param transactionElement Transaction element
* @param invoice Invoice data
*/
private addLineItems(doc: Document, transactionElement: Element, invoice: TInvoice): void {
// Add each line item
if (invoice.items) {
for (const item of invoice.items) {
// Create line item element
const lineItemElement = doc.createElement('ram:IncludedSupplyChainTradeLineItem');
// Add line ID
const lineIdElement = doc.createElement('ram:AssociatedDocumentLineDocument');
const lineIdValueElement = doc.createElement('ram:LineID');
lineIdValueElement.textContent = item.position.toString();
lineIdElement.appendChild(lineIdValueElement);
lineItemElement.appendChild(lineIdElement);
// Add product information
const productElement = doc.createElement('ram:SpecifiedTradeProduct');
// Add name
const nameElement = doc.createElement('ram:Name');
nameElement.textContent = item.name;
productElement.appendChild(nameElement);
// Add article number if available
if (item.articleNumber) {
const articleNumberElement = doc.createElement('ram:SellerAssignedID');
articleNumberElement.textContent = item.articleNumber;
productElement.appendChild(articleNumberElement);
}
lineItemElement.appendChild(productElement);
// Add agreement information (price)
const agreementElement = doc.createElement('ram:SpecifiedLineTradeAgreement');
const priceElement = doc.createElement('ram:NetPriceProductTradePrice');
const chargeAmountElement = doc.createElement('ram:ChargeAmount');
chargeAmountElement.textContent = item.unitNetPrice.toFixed(2);
priceElement.appendChild(chargeAmountElement);
agreementElement.appendChild(priceElement);
lineItemElement.appendChild(agreementElement);
// Add delivery information (quantity)
const deliveryElement = doc.createElement('ram:SpecifiedLineTradeDelivery');
const quantityElement = doc.createElement('ram:BilledQuantity');
quantityElement.textContent = item.unitQuantity.toString();
quantityElement.setAttribute('unitCode', item.unitType);
deliveryElement.appendChild(quantityElement);
lineItemElement.appendChild(deliveryElement);
// Add settlement information (tax)
const settlementElement = doc.createElement('ram:SpecifiedLineTradeSettlement');
// Add tax information
const taxElement = doc.createElement('ram:ApplicableTradeTax');
// Add tax type code
const taxTypeCodeElement = doc.createElement('ram:TypeCode');
taxTypeCodeElement.textContent = 'VAT';
taxElement.appendChild(taxTypeCodeElement);
// Add tax category code
const taxCategoryCodeElement = doc.createElement('ram:CategoryCode');
taxCategoryCodeElement.textContent = 'S';
taxElement.appendChild(taxCategoryCodeElement);
// Add tax rate
const taxRateElement = doc.createElement('ram:RateApplicablePercent');
taxRateElement.textContent = item.vatPercentage.toString();
taxElement.appendChild(taxRateElement);
settlementElement.appendChild(taxElement);
// Add monetary summation
const monetarySummationElement = doc.createElement('ram:SpecifiedLineTradeSettlementMonetarySummation');
// Calculate item total
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
// Add line total amount
const lineTotalElement = doc.createElement('ram:LineTotalAmount');
lineTotalElement.textContent = itemNetAmount.toFixed(2);
monetarySummationElement.appendChild(lineTotalElement);
settlementElement.appendChild(monetarySummationElement);
lineItemElement.appendChild(settlementElement);
// Add line item to transaction
transactionElement.appendChild(lineItemElement);
}
}
}
/**
* Formats a date as YYYYMMDD
* @param timestamp Timestamp to format
* @returns Formatted date string
*/
private formatDateYYYYMMDD(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}${month}${day}`;
}
}

View File

@ -0,0 +1,18 @@
import { CIIProfile, CII_PROFILE_IDS } from '../cii.types.js';
/**
* Factur-X specific constants and types
*/
// Factur-X profile IDs
export const FACTURX_PROFILE_IDS = {
MINIMUM: CII_PROFILE_IDS.FACTURX_MINIMUM,
BASIC: CII_PROFILE_IDS.FACTURX_BASIC,
EN16931: CII_PROFILE_IDS.FACTURX_EN16931
};
// Factur-X PDF attachment filename
export const FACTURX_ATTACHMENT_FILENAME = 'factur-x.xml';
// Factur-X PDF attachment description
export const FACTURX_ATTACHMENT_DESCRIPTION = 'Factur-X XML Invoice';

View File

@ -1,124 +1,35 @@
import { BaseValidator } from './base.validator.js';
import { ValidationLevel } from '../interfaces.js';
import type { ValidationResult, ValidationError } from '../interfaces.js';
import * as xpath from 'xpath';
import { DOMParser } from 'xmldom';
import { CIIBaseValidator } from '../cii.validator.js';
import { ValidationLevel } from '../../../interfaces/common.js';
import type { ValidationResult } from '../../../interfaces/common.js';
/**
* Validator for Factur-X/ZUGFeRD invoice format
* Validator for Factur-X invoice format
* Implements validation rules according to EN16931 and Factur-X specification
*/
export class FacturXValidator extends BaseValidator {
// XML namespaces for Factur-X/ZUGFeRD
private static NS_RSMT = 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100';
private static NS_RAM = 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100';
private static NS_UDT = 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100';
// XML document for processing
private xmlDoc: Document | null = null;
// Factur-X profile (BASIC, EN16931, EXTENDED, etc.)
private profile: string = '';
constructor(xml: string) {
super(xml);
try {
// Parse XML document
this.xmlDoc = new DOMParser().parseFromString(xml, 'application/xml');
// Determine Factur-X profile
this.detectProfile();
} catch (error) {
this.addError('FX-PARSE', `Failed to parse XML: ${error}`, '/');
}
}
export class FacturXValidator extends CIIBaseValidator {
/**
* Validates the Factur-X invoice against the specified level
* @param level Validation level
* @returns Validation result
*/
public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult {
// Reset errors
this.errors = [];
// Check if document was parsed successfully
if (!this.xmlDoc) {
return {
valid: false,
errors: this.errors,
level: level
};
}
// Perform validation based on level
let valid = true;
if (level === ValidationLevel.SYNTAX) {
valid = this.validateSchema();
} else if (level === ValidationLevel.SEMANTIC) {
valid = this.validateSchema() && this.validateStructure();
} else if (level === ValidationLevel.BUSINESS) {
valid = this.validateSchema() &&
this.validateStructure() &&
this.validateBusinessRules();
}
return {
valid,
errors: this.errors,
level
};
}
/**
* Validates XML against schema
* @returns True if schema validation passed
*/
protected validateSchema(): boolean {
// Basic schema validation (simplified for now)
if (!this.xmlDoc) return false;
// Check for root element
const root = this.xmlDoc.documentElement;
if (!root || root.nodeName !== 'rsm:CrossIndustryInvoice') {
this.addError('FX-SCHEMA-1', 'Root element must be rsm:CrossIndustryInvoice', '/');
return false;
}
// Check for required namespaces
if (!root.lookupNamespaceURI('rsm') || !root.lookupNamespaceURI('ram')) {
this.addError('FX-SCHEMA-2', 'Required namespaces rsm and ram must be declared', '/');
return false;
}
return true;
}
/**
* Validates structure of the XML document
* Validates structure of the Factur-X XML document
* @returns True if structure validation passed
*/
private validateStructure(): boolean {
if (!this.xmlDoc) return false;
protected validateStructure(): boolean {
if (!this.doc) return false;
let valid = true;
// Check for required main sections
const sections = [
'rsm:ExchangedDocumentContext',
'rsm:ExchangedDocument',
'rsm:SupplyChainTradeTransaction'
];
for (const section of sections) {
if (!this.exists(section)) {
this.addError('FX-STRUCT-1', `Required section ${section} is missing`, '/rsm:CrossIndustryInvoice');
valid = false;
}
}
// Check for SupplyChainTradeTransaction sections
if (this.exists('rsm:SupplyChainTradeTransaction')) {
const tradeSubsections = [
@ -126,197 +37,144 @@ export class FacturXValidator extends BaseValidator {
'ram:ApplicableHeaderTradeDelivery',
'ram:ApplicableHeaderTradeSettlement'
];
for (const subsection of tradeSubsections) {
if (!this.exists(`rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeTransaction/${subsection}`)) {
this.addError('FX-STRUCT-2', `Required subsection ${subsection} is missing`,
'/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeTransaction');
if (!this.exists(`rsm:SupplyChainTradeTransaction/${subsection}`)) {
this.addError('FX-STRUCT-2', `Required subsection ${subsection} is missing`,
'/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction');
valid = false;
}
}
}
return valid;
}
/**
* Validates business rules
* @returns True if business rule validation passed
*/
protected validateBusinessRules(): boolean {
if (!this.xmlDoc) return false;
if (!this.doc) return false;
let valid = true;
// BR-16: Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) - Paid amount (BT-113)
valid = this.validateAmounts() && valid;
// BR-CO-3: Value added tax point date (BT-7) and Value added tax point date code (BT-8) are mutually exclusive
valid = this.validateMutuallyExclusiveFields() && valid;
// BR-S-1: An Invoice that contains a line (BG-25) where the Invoiced item VAT category code (BT-151) is "Standard rated"
// shall contain the Seller VAT Identifier (BT-31), the Seller tax registration identifier (BT-32)
// BR-S-1: An Invoice that contains a line (BG-25) where the Invoiced item VAT category code (BT-151) is "Standard rated"
// shall contain the Seller VAT Identifier (BT-31), the Seller tax registration identifier (BT-32)
// and/or the Seller tax representative VAT identifier (BT-63).
valid = this.validateSellerVatIdentifier() && valid;
return valid;
}
/**
* Detects Factur-X profile from the XML
*/
private detectProfile(): void {
if (!this.xmlDoc) return;
// Look for profile identifier
const profileNode = xpath.select1(
'string(//rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID)',
this.xmlDoc
);
if (profileNode) {
const profileText = profileNode.toString();
if (profileText.includes('BASIC')) {
this.profile = 'BASIC';
} else if (profileText.includes('EN16931')) {
this.profile = 'EN16931';
} else if (profileText.includes('EXTENDED')) {
this.profile = 'EXTENDED';
} else if (profileText.includes('MINIMUM')) {
this.profile = 'MINIMUM';
}
}
}
/**
* Validates amount calculations in the invoice
* @returns True if amount validation passed
*/
private validateAmounts(): boolean {
if (!this.xmlDoc) return false;
if (!this.doc) return false;
try {
// Extract amounts
const totalAmount = this.getNumberValue(
const totalAmount = this.getNumber(
'//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount'
);
const paidAmount = this.getNumberValue(
const paidAmount = this.getNumber(
'//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TotalPrepaidAmount'
) || 0;
const dueAmount = this.getNumberValue(
const dueAmount = this.getNumber(
'//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:DuePayableAmount'
);
// Calculate expected due amount
const expectedDueAmount = totalAmount - paidAmount;
// Compare with a small tolerance for rounding errors
if (Math.abs(dueAmount - expectedDueAmount) > 0.01) {
this.addError(
'BR-16',
'BR-16',
`Amount due for payment (${dueAmount}) must equal Invoice total amount with VAT (${totalAmount}) - Paid amount (${paidAmount})`,
'//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation'
);
return false;
}
return true;
} catch (error) {
this.addError('FX-AMOUNT', `Error validating amounts: ${error}`, '/');
return false;
}
}
/**
* Validates mutually exclusive fields
* @returns True if validation passed
*/
private validateMutuallyExclusiveFields(): boolean {
if (!this.xmlDoc) return false;
if (!this.doc) return false;
try {
// Check for VAT point date and code (BR-CO-3)
const vatPointDate = this.exists('//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:TaxPointDate');
const vatPointDateCode = this.exists('//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:DueDateTypeCode');
if (vatPointDate && vatPointDateCode) {
this.addError(
'BR-CO-3',
'BR-CO-3',
'Value added tax point date and Value added tax point date code are mutually exclusive',
'//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax'
);
return false;
}
return true;
} catch (error) {
this.addError('FX-MUTUAL', `Error validating mutually exclusive fields: ${error}`, '/');
return false;
}
}
/**
* Validates seller VAT identifier requirements
* @returns True if validation passed
*/
private validateSellerVatIdentifier(): boolean {
if (!this.xmlDoc) return false;
if (!this.doc) return false;
try {
// Check if there are any standard rated line items
const standardRatedItems = this.exists(
'//ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode[text()="S"]'
);
if (standardRatedItems) {
// Check for seller VAT identifier
const sellerVatId = this.exists('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]');
const sellerTaxId = this.exists('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]');
const sellerTaxRepId = this.exists('//ram:ApplicableHeaderTradeAgreement/ram:SellerTaxRepresentativeTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]');
if (!sellerVatId && !sellerTaxId && !sellerTaxRepId) {
this.addError(
'BR-S-1',
'BR-S-1',
'An Invoice with standard rated items must contain the Seller VAT Identifier, Tax registration identifier or Tax representative VAT identifier',
'//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty'
);
return false;
}
}
return true;
} catch (error) {
this.addError('FX-VAT', `Error validating seller VAT identifier: ${error}`, '/');
return false;
}
}
/**
* Helper method to check if a node exists
* @param xpathExpression XPath to check
* @returns True if node exists
*/
private exists(xpathExpression: string): boolean {
if (!this.xmlDoc) return false;
const nodes = xpath.select(xpathExpression, this.xmlDoc);
// Handle different return types from xpath.select()
if (Array.isArray(nodes)) {
return nodes.length > 0;
}
return nodes ? true : false;
}
/**
* Helper method to get a number value from XPath
* @param xpathExpression XPath to get number from
* @returns Number value or NaN if not found
*/
private getNumberValue(xpathExpression: string): number {
if (!this.xmlDoc) return NaN;
const node = xpath.select1(`string(${xpathExpression})`, this.xmlDoc);
return node ? parseFloat(node.toString()) : NaN;
}
}
}

View File

@ -1,52 +0,0 @@
import { BaseDecoder } from './base.decoder.js';
import { FacturXDecoder } from './facturx.decoder.js';
import { XInvoiceDecoder } from './xrechnung.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,50 @@
import { BaseDecoder } from '../base/base.decoder.js';
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 { FacturXDecoder } from '../cii/facturx/facturx.decoder.js';
// import { ZUGFeRDDecoder } from '../cii/zugferd/zugferd.decoder.js';
/**
* Factory to create the appropriate decoder based on the XML format
*/
export class DecoderFactory {
/**
* Creates a decoder for the specified XML content
* @param xml XML content to decode
* @returns Appropriate decoder instance
*/
public static createDecoder(xml: string): BaseDecoder {
const format = FormatDetector.detectFormat(xml);
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');
case InvoiceFormat.CII:
// For now, use Factur-X decoder for generic CII
return new FacturXDecoder(xml);
case InvoiceFormat.ZUGFERD:
// For now, use Factur-X decoder for ZUGFeRD
return new FacturXDecoder(xml);
case InvoiceFormat.FACTURX:
return new FacturXDecoder(xml);
case InvoiceFormat.FATTURAPA:
// return new FatturaPADecoder(xml);
throw new Error('FatturaPA decoder not yet implemented');
default:
throw new Error(`Unsupported invoice format: ${format}`);
}
}
}

View File

@ -0,0 +1,48 @@
import { BaseEncoder } from '../base/base.encoder.js';
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 { FacturXEncoder } from '../cii/facturx/facturx.encoder.js';
// import { ZUGFeRDEncoder } from '../cii/zugferd/zugferd.encoder.js';
/**
* Factory to create the appropriate encoder based on the target format
*/
export class EncoderFactory {
/**
* Creates an encoder for the specified format
* @param format Target format for encoding
* @returns Appropriate encoder instance
*/
public static createEncoder(format: ExportFormat | InvoiceFormat): BaseEncoder {
switch (format.toLowerCase()) {
case InvoiceFormat.UBL:
case 'ubl':
// return new UBLEncoder();
throw new Error('UBL encoder not yet implemented');
case InvoiceFormat.XRECHNUNG:
case 'xrechnung':
// return new XRechnungEncoder();
throw new Error('XRechnung encoder not yet implemented');
case InvoiceFormat.CII:
// For now, use Factur-X encoder for generic CII
return new FacturXEncoder();
case InvoiceFormat.ZUGFERD:
case 'zugferd':
// For now, use Factur-X encoder for ZUGFeRD
return new FacturXEncoder();
case InvoiceFormat.FACTURX:
case 'facturx':
return new FacturXEncoder();
default:
throw new Error(`Unsupported invoice format for encoding: ${format}`);
}
}
}

View File

@ -0,0 +1,51 @@
import { BaseValidator } from '../base/base.validator.js';
import { InvoiceFormat } from '../../interfaces/common.js';
import { FormatDetector } from '../utils/format.detector.js';
// Import specific validators
// import { UBLValidator } from '../ubl/ubl.validator.js';
// import { XRechnungValidator } from '../ubl/xrechnung/xrechnung.validator.js';
import { FacturXValidator } from '../cii/facturx/facturx.validator.js';
// import { ZUGFeRDValidator } from '../cii/zugferd/zugferd.validator.js';
/**
* Factory to create the appropriate validator based on the XML format
*/
export class ValidatorFactory {
/**
* Creates a validator for the specified XML content
* @param xml XML content to validate
* @returns Appropriate validator instance
*/
public static createValidator(xml: string): BaseValidator {
const format = FormatDetector.detectFormat(xml);
switch (format) {
case InvoiceFormat.UBL:
// return new UBLValidator(xml);
throw new Error('UBL validator not yet implemented');
case InvoiceFormat.XRECHNUNG:
// return new XRechnungValidator(xml);
throw new Error('XRechnung validator not yet implemented');
case InvoiceFormat.CII:
// For now, use Factur-X validator for generic CII
return new FacturXValidator(xml);
case InvoiceFormat.ZUGFERD:
// For now, use Factur-X validator for ZUGFeRD
return new FacturXValidator(xml);
case InvoiceFormat.FACTURX:
return new FacturXValidator(xml);
case InvoiceFormat.FATTURAPA:
// return new FatturaPAValidator(xml);
throw new Error('FatturaPA validator not yet implemented');
default:
throw new Error(`Unsupported invoice format: ${format}`);
}
}
}

View File

@ -1,224 +0,0 @@
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.TContact = {
name: sellerName,
type: 'company',
description: sellerName,
address: {
streetName: this.getElementText('ram:LineOne') || 'Unknown',
houseNumber: '0',
city: this.getElementText('ram:CityName') || 'Unknown',
country: this.getElementText('ram:CountryID') || 'Unknown',
postalCode: this.getElementText('ram:PostcodeCode') || 'Unknown',
},
registrationDetails: {
vatId: this.getElementText('ram:ID') || 'Unknown',
registrationId: this.getElementText('ram:ID') || 'Unknown',
registrationName: sellerName
},
foundedDate: {
year: 2000,
month: 1,
day: 1
},
closedDate: {
year: 9999,
month: 12,
day: 31
},
status: 'active'
};
// Create buyer
const buyer: plugins.tsclass.business.TContact = {
name: buyerName,
type: 'company',
description: buyerName,
address: {
streetName: 'Unknown',
houseNumber: '0',
city: 'Unknown',
country: 'Unknown',
postalCode: 'Unknown',
},
registrationDetails: {
vatId: 'Unknown',
registrationId: 'Unknown',
registrationName: buyerName
},
foundedDate: {
year: 2000,
month: 1,
day: 1
},
closedDate: {
year: 9999,
month: 12,
day: 31
},
status: 'active'
};
// Extract invoice type
let invoiceType = 'debitnote';
const typeCode = this.getElementText('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,345 +0,0 @@
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.TContact = invoice.billedBy;
const billedTo: plugins.tsclass.business.TContact = 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;
}
}

View File

@ -0,0 +1,77 @@
import { PDFDocument } from 'pdf-lib';
import type { IPdf } from '../../interfaces/common.js';
/**
* Class for embedding XML into PDF files
*/
export class PDFEmbedder {
/**
* Embeds XML into a PDF
* @param pdfBuffer PDF buffer
* @param xmlContent XML content to embed
* @param filename Filename for the embedded XML
* @param description Description for the embedded XML
* @returns Modified PDF buffer
*/
public async embedXml(
pdfBuffer: Uint8Array | Buffer,
xmlContent: string,
filename: string = 'invoice.xml',
description: string = 'XML Invoice'
): Promise<Uint8Array> {
try {
// Load the PDF
const pdfDoc = await PDFDocument.load(pdfBuffer);
// Convert the XML string to a Uint8Array
const xmlBuffer = new TextEncoder().encode(xmlContent);
// Make sure filename is lowercase (as required by documentation)
filename = filename.toLowerCase();
// Use pdf-lib's .attach() to embed the XML
pdfDoc.attach(xmlBuffer, filename, {
mimeType: 'application/xml',
description: description,
});
// Save the modified PDF
const modifiedPdfBytes = await pdfDoc.save();
return modifiedPdfBytes;
} catch (error) {
console.error('Error embedding XML into PDF:', error);
throw error;
}
}
/**
* Creates an IPdf object with embedded XML
* @param pdfBuffer PDF buffer
* @param xmlContent XML content to embed
* @param filename Filename for the embedded XML
* @param description Description for the embedded XML
* @param pdfName Name for the PDF
* @param pdfId ID for the PDF
* @returns IPdf object with embedded XML
*/
public async createPdfWithXml(
pdfBuffer: Uint8Array | Buffer,
xmlContent: string,
filename: string = 'invoice.xml',
description: string = 'XML Invoice',
pdfName: string = 'invoice.pdf',
pdfId: string = `invoice-${Date.now()}`
): Promise<IPdf> {
const modifiedPdfBytes = await this.embedXml(pdfBuffer, xmlContent, filename, description);
return {
name: pdfName,
id: pdfId,
metadata: {
textExtraction: ''
},
buffer: modifiedPdfBytes
};
}
}

View File

@ -0,0 +1,94 @@
import { PDFDocument, PDFDict, PDFName, PDFRawStream, PDFArray, PDFString } from 'pdf-lib';
import * as pako from 'pako';
/**
* Class for extracting XML from PDF files
*/
export class PDFExtractor {
/**
* Extracts XML from a PDF buffer
* @param pdfBuffer PDF buffer
* @returns XML content or null if not found
*/
public async extractXml(pdfBuffer: Uint8Array | Buffer): Promise<string | null> {
try {
const pdfDoc = await PDFDocument.load(pdfBuffer);
// Get the document's metadata dictionary
const namesDictObj = pdfDoc.catalog.lookup(PDFName.of('Names'));
if (!(namesDictObj instanceof PDFDict)) {
console.warn('No Names dictionary found in PDF! This PDF does not contain embedded files.');
return null;
}
const embeddedFilesDictObj = namesDictObj.lookup(PDFName.of('EmbeddedFiles'));
if (!(embeddedFilesDictObj instanceof PDFDict)) {
console.warn('No EmbeddedFiles dictionary found! This PDF does not contain embedded files.');
return null;
}
const filesSpecObj = embeddedFilesDictObj.lookup(PDFName.of('Names'));
if (!(filesSpecObj instanceof PDFArray)) {
console.warn('No files specified in EmbeddedFiles dictionary!');
return null;
}
// Try to find an XML file in the embedded files
let xmlFile: PDFRawStream | undefined;
let xmlFileName: string | undefined;
for (let i = 0; i < filesSpecObj.size(); i += 2) {
const fileNameObj = filesSpecObj.lookup(i);
const fileSpecObj = filesSpecObj.lookup(i + 1);
if (!(fileNameObj instanceof PDFString)) {
continue;
}
if (!(fileSpecObj instanceof PDFDict)) {
continue;
}
// Get the filename as string
const fileName = fileNameObj.toString();
// Check if it's an XML file (checking both extension and known standard filenames)
if (fileName.toLowerCase().includes('.xml') ||
fileName.toLowerCase().includes('factur-x') ||
fileName.toLowerCase().includes('zugferd') ||
fileName.toLowerCase().includes('xrechnung')) {
const efDictObj = fileSpecObj.lookup(PDFName.of('EF'));
if (!(efDictObj instanceof PDFDict)) {
continue;
}
const maybeStream = efDictObj.lookup(PDFName.of('F'));
if (maybeStream instanceof PDFRawStream) {
// Found an XML file - save it
xmlFile = maybeStream;
xmlFileName = fileName;
break;
}
}
}
// If no XML file was found, return null
if (!xmlFile) {
console.warn('No embedded XML file found in the PDF!');
return null;
}
// Decompress and decode the XML content
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;
} catch (error) {
console.error('Error extracting or parsing embedded XML from PDF:', error);
throw error;
}
}
}

View File

@ -1,382 +0,0 @@
import { BaseValidator } from './base.validator.js';
import { ValidationLevel } from '../interfaces.js';
import type { ValidationResult, ValidationError } from '../interfaces.js';
import * as xpath from 'xpath';
import { DOMParser } from 'xmldom';
/**
* Validator for UBL (Universal Business Language) invoice format
* Implements validation rules according to EN16931 and UBL 2.1 specification
*/
export class UBLValidator extends BaseValidator {
// XML namespaces for UBL
private static NS_INVOICE = 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2';
private static NS_CAC = 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2';
private static NS_CBC = 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2';
// XML document for processing
private xmlDoc: Document | null = null;
// UBL profile or customization ID
private customizationId: string = '';
constructor(xml: string) {
super(xml);
try {
// Parse XML document
this.xmlDoc = new DOMParser().parseFromString(xml, 'application/xml');
// Determine UBL customization ID (e.g. EN16931, XRechnung)
this.detectCustomizationId();
} catch (error) {
this.addError('UBL-PARSE', `Failed to parse XML: ${error}`, '/');
}
}
/**
* Validates the UBL invoice against the specified level
* @param level Validation level
* @returns Validation result
*/
public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult {
// Reset errors
this.errors = [];
// Check if document was parsed successfully
if (!this.xmlDoc) {
return {
valid: false,
errors: this.errors,
level: level
};
}
// Perform validation based on level
let valid = true;
if (level === ValidationLevel.SYNTAX) {
valid = this.validateSchema();
} else if (level === ValidationLevel.SEMANTIC) {
valid = this.validateSchema() && this.validateStructure();
} else if (level === ValidationLevel.BUSINESS) {
valid = this.validateSchema() &&
this.validateStructure() &&
this.validateBusinessRules();
}
return {
valid,
errors: this.errors,
level
};
}
/**
* Validates XML against schema
* @returns True if schema validation passed
*/
protected validateSchema(): boolean {
// Basic schema validation (simplified for now)
if (!this.xmlDoc) return false;
// Check for root element
const root = this.xmlDoc.documentElement;
if (!root || (root.nodeName !== 'Invoice' && root.nodeName !== 'CreditNote')) {
this.addError('UBL-SCHEMA-1', 'Root element must be Invoice or CreditNote', '/');
return false;
}
// Check for required namespaces
if (!root.lookupNamespaceURI('cac') || !root.lookupNamespaceURI('cbc')) {
this.addError('UBL-SCHEMA-2', 'Required namespaces cac and cbc must be declared', '/');
return false;
}
return true;
}
/**
* Validates structure of the XML document
* @returns True if structure validation passed
*/
private validateStructure(): boolean {
if (!this.xmlDoc) return false;
let valid = true;
// Check for required main sections
const sections = [
'cbc:ID',
'cbc:IssueDate',
'cac:AccountingSupplierParty',
'cac:AccountingCustomerParty',
'cac:LegalMonetaryTotal'
];
for (const section of sections) {
if (!this.exists(`/${this.getRootNodeName()}/${section}`)) {
this.addError('UBL-STRUCT-1', `Required section ${section} is missing`, `/${this.getRootNodeName()}`);
valid = false;
}
}
// Check for TaxTotal section
if (this.exists(`/${this.getRootNodeName()}/cac:TaxTotal`)) {
const taxSubsections = [
'cbc:TaxAmount',
'cac:TaxSubtotal'
];
for (const subsection of taxSubsections) {
if (!this.exists(`/${this.getRootNodeName()}/cac:TaxTotal/${subsection}`)) {
this.addError('UBL-STRUCT-2', `Required subsection ${subsection} is missing`,
`/${this.getRootNodeName()}/cac:TaxTotal`);
valid = false;
}
}
}
return valid;
}
/**
* Validates business rules
* @returns True if business rule validation passed
*/
protected validateBusinessRules(): boolean {
if (!this.xmlDoc) return false;
let valid = true;
// BR-16: Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) - Paid amount (BT-113)
valid = this.validateAmounts() && valid;
// BR-CO-3: Value added tax point date (BT-7) and Value added tax point date code (BT-8) are mutually exclusive
valid = this.validateMutuallyExclusiveFields() && valid;
// BR-S-1: An Invoice that contains a line where the VAT category code is "Standard rated"
// shall contain the Seller VAT Identifier or the Seller tax representative VAT identifier
valid = this.validateSellerVatIdentifier() && valid;
// XRechnung specific rules when customization ID matches
if (this.isXRechnung()) {
valid = this.validateXRechnungRules() && valid;
}
return valid;
}
/**
* Gets the root node name (Invoice or CreditNote)
* @returns Root node name
*/
private getRootNodeName(): string {
if (!this.xmlDoc || !this.xmlDoc.documentElement) return 'Invoice';
return this.xmlDoc.documentElement.nodeName;
}
/**
* Detects UBL customization ID from the XML
*/
private detectCustomizationId(): void {
if (!this.xmlDoc) return;
// Look for customization ID
const customizationNode = xpath.select1(
`string(/${this.getRootNodeName()}/cbc:CustomizationID)`,
this.xmlDoc
);
if (customizationNode) {
this.customizationId = customizationNode.toString();
}
}
/**
* Checks if invoice is an XRechnung
* @returns True if XRechnung customization ID is present
*/
private isXRechnung(): boolean {
return this.customizationId.includes('xrechnung') ||
this.customizationId.includes('XRechnung');
}
/**
* Validates amount calculations in the invoice
* @returns True if amount validation passed
*/
private validateAmounts(): boolean {
if (!this.xmlDoc) return false;
try {
// Extract amounts
const totalAmount = this.getNumberValue(
`/${this.getRootNodeName()}/cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount`
);
const paidAmount = this.getNumberValue(
`/${this.getRootNodeName()}/cac:LegalMonetaryTotal/cbc:PrepaidAmount`
) || 0;
const dueAmount = this.getNumberValue(
`/${this.getRootNodeName()}/cac:LegalMonetaryTotal/cbc:PayableAmount`
);
// Calculate expected due amount
const expectedDueAmount = totalAmount - paidAmount;
// Compare with a small tolerance for rounding errors
if (Math.abs(dueAmount - expectedDueAmount) > 0.01) {
this.addError(
'BR-16',
`Amount due for payment (${dueAmount}) must equal Invoice total amount with VAT (${totalAmount}) - Paid amount (${paidAmount})`,
`/${this.getRootNodeName()}/cac:LegalMonetaryTotal`
);
return false;
}
return true;
} catch (error) {
this.addError('UBL-AMOUNT', `Error validating amounts: ${error}`, '/');
return false;
}
}
/**
* Validates mutually exclusive fields
* @returns True if validation passed
*/
private validateMutuallyExclusiveFields(): boolean {
if (!this.xmlDoc) return false;
try {
// Check for VAT point date and code (BR-CO-3)
const vatPointDate = this.exists(`/${this.getRootNodeName()}/cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory/cbc:TaxPointDate`);
const vatPointDateCode = this.exists(`/${this.getRootNodeName()}/cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory/cbc:TaxExemptionReasonCode`);
if (vatPointDate && vatPointDateCode) {
this.addError(
'BR-CO-3',
'Value added tax point date and Value added tax point date code are mutually exclusive',
`/${this.getRootNodeName()}/cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory`
);
return false;
}
return true;
} catch (error) {
this.addError('UBL-MUTUAL', `Error validating mutually exclusive fields: ${error}`, '/');
return false;
}
}
/**
* Validates seller VAT identifier requirements
* @returns True if validation passed
*/
private validateSellerVatIdentifier(): boolean {
if (!this.xmlDoc) return false;
try {
// Check if there are any standard rated line items
const standardRatedItems = this.exists(
`/${this.getRootNodeName()}/cac:InvoiceLine/cac:Item/cac:ClassifiedTaxCategory/cbc:ID[text()="S"]`
);
if (standardRatedItems) {
// Check for seller VAT identifier
const sellerVatId = this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID`);
const sellerTaxRepId = this.exists(`/${this.getRootNodeName()}/cac:TaxRepresentativeParty/cac:PartyTaxScheme/cbc:CompanyID`);
if (!sellerVatId && !sellerTaxRepId) {
this.addError(
'BR-S-1',
'An Invoice with standard rated items must contain the Seller VAT Identifier or Tax representative VAT identifier',
`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party`
);
return false;
}
}
return true;
} catch (error) {
this.addError('UBL-VAT', `Error validating seller VAT identifier: ${error}`, '/');
return false;
}
}
/**
* Validates XRechnung specific rules
* @returns True if validation passed
*/
private validateXRechnungRules(): boolean {
if (!this.xmlDoc) return false;
let valid = true;
try {
// BR-DE-1: Buyer reference must be present for German VAT compliance
if (!this.exists(`/${this.getRootNodeName()}/cbc:BuyerReference`)) {
this.addError(
'BR-DE-1',
'BuyerReference is mandatory for XRechnung',
`/${this.getRootNodeName()}`
);
valid = false;
}
// BR-DE-15: Contact information must be present
if (!this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cac:Contact`)) {
this.addError(
'BR-DE-15',
'Supplier contact information is mandatory for XRechnung',
`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party`
);
valid = false;
}
// BR-DE-16: Electronic address identifier scheme (e.g. PEPPOL) must be present
if (!this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID`) ||
!this.exists(`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID`)) {
this.addError(
'BR-DE-16',
'Supplier electronic address with scheme identifier is mandatory for XRechnung',
`/${this.getRootNodeName()}/cac:AccountingSupplierParty/cac:Party`
);
valid = false;
}
return valid;
} catch (error) {
this.addError('UBL-XRECHNUNG', `Error validating XRechnung rules: ${error}`, '/');
return false;
}
}
/**
* Helper method to check if a node exists
* @param xpathExpression XPath to check
* @returns True if node exists
*/
private exists(xpathExpression: string): boolean {
if (!this.xmlDoc) return false;
const nodes = xpath.select(xpathExpression, this.xmlDoc);
// Handle different return types from xpath.select()
if (Array.isArray(nodes)) {
return nodes.length > 0;
}
return nodes ? true : false;
}
/**
* Helper method to get a number value from XPath
* @param xpathExpression XPath to get number from
* @returns Number value or NaN if not found
*/
private getNumberValue(xpathExpression: string): number {
if (!this.xmlDoc) return NaN;
const node = xpath.select1(`string(${xpathExpression})`, this.xmlDoc);
return node ? parseFloat(node.toString()) : NaN;
}
}

View File

@ -0,0 +1,122 @@
import { BaseDecoder } from '../base/base.decoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.js';
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
import { DOMParser } from 'xmldom';
import * as xpath from 'xpath';
/**
* Base decoder for UBL-based invoice formats
*/
export abstract class UBLBaseDecoder extends BaseDecoder {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
constructor(xml: string) {
super(xml);
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
cbc: UBL_NAMESPACES.CBC,
cac: UBL_NAMESPACES.CAC
};
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
}
/**
* Decodes UBL XML into a TInvoice object
* @returns Promise resolving to a TInvoice object
*/
public async decode(): Promise<TInvoice> {
// Determine document type
const documentType = this.getDocumentType();
if (documentType === UBLDocumentType.CREDIT_NOTE) {
return this.decodeCreditNote();
} else {
return this.decodeDebitNote();
}
}
/**
* Gets the UBL document type
* @returns UBL document type
*/
protected getDocumentType(): UBLDocumentType {
const rootName = this.doc.documentElement.nodeName;
if (rootName === UBLDocumentType.CREDIT_NOTE) {
return UBLDocumentType.CREDIT_NOTE;
} else {
return UBLDocumentType.INVOICE;
}
}
/**
* Decodes a UBL credit note
* @returns Promise resolving to a TCreditNote object
*/
protected abstract decodeCreditNote(): Promise<TCreditNote>;
/**
* Decodes a UBL debit note (invoice)
* @returns Promise resolving to a TDebitNote object
*/
protected abstract decodeDebitNote(): Promise<TDebitNote>;
/**
* Gets a text value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
return node ? (node.textContent || '') : '';
}
/**
* Gets a number value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Number value or 0 if not found or not a number
*/
protected getNumber(xpathExpr: string, context?: Node): number {
const text = this.getText(xpathExpr, context);
const num = parseFloat(text);
return isNaN(num) ? 0 : num;
}
/**
* Gets a date value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Date timestamp or current time if not found or invalid
*/
protected getDate(xpathExpr: string, context?: Node): number {
const text = this.getText(xpathExpr, context);
if (!text) return Date.now();
const date = new Date(text);
return isNaN(date.getTime()) ? Date.now() : date.getTime();
}
/**
* Checks if a node exists
* @param xpath XPath expression
* @param context Optional context node
* @returns True if node exists
*/
protected exists(xpathExpr: string, context?: Node): boolean {
const nodes = this.select(xpathExpr, context || this.doc);
if (Array.isArray(nodes)) {
return nodes.length > 0;
}
return false;
}
}

View File

@ -0,0 +1,59 @@
import { BaseEncoder } from '../base/base.encoder.js';
import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.js';
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
/**
* Base encoder for UBL-based invoice formats
*/
export abstract class UBLBaseEncoder extends BaseEncoder {
/**
* Encodes a TInvoice object into UBL XML
* @param invoice TInvoice object to encode
* @returns UBL XML string
*/
public async encode(invoice: TInvoice): Promise<string> {
// Determine if it's a credit note or debit note
if (invoice.invoiceType === 'creditnote') {
return this.encodeCreditNote(invoice as TCreditNote);
} else {
return this.encodeDebitNote(invoice as TDebitNote);
}
}
/**
* Encodes a TCreditNote object into UBL XML
* @param creditNote TCreditNote object to encode
* @returns UBL XML string
*/
protected abstract encodeCreditNote(creditNote: TCreditNote): Promise<string>;
/**
* Encodes a TDebitNote object into UBL XML
* @param debitNote TDebitNote object to encode
* @returns UBL XML string
*/
protected abstract encodeDebitNote(debitNote: TDebitNote): Promise<string>;
/**
* Creates the XML declaration and root element
* @param documentType UBL document type
* @returns XML string with declaration and root element
*/
protected createXmlRoot(documentType: UBLDocumentType): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<${documentType} xmlns="urn:oasis:names:specification:ubl:schema:xsd:${documentType}-2"
xmlns:cac="${UBL_NAMESPACES.CAC}"
xmlns:cbc="${UBL_NAMESPACES.CBC}">
</${documentType}>`;
}
/**
* Formats a date as an ISO string (YYYY-MM-DD)
* @param timestamp Timestamp to format
* @returns Formatted date string
*/
protected formatDate(timestamp: number): string {
const date = new Date(timestamp);
return date.toISOString().split('T')[0];
}
}

View File

@ -0,0 +1,22 @@
/**
* UBL-specific types and constants
*/
// UBL namespaces
export const UBL_NAMESPACES = {
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'
};
// UBL document types
export enum UBLDocumentType {
INVOICE = 'Invoice',
CREDIT_NOTE = 'CreditNote'
}
// UBL customization IDs for different formats
export const UBL_CUSTOMIZATION_IDS = {
XRECHNUNG: 'urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0',
PEPPOL_BIS: 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0'
};

View File

@ -0,0 +1,134 @@
import { BaseValidator } from '../base/base.validator.js';
import { ValidationLevel } from '../../interfaces/common.js';
import type { ValidationResult } from '../../interfaces/common.js';
import { UBLDocumentType } from './ubl.types.js';
import { DOMParser } from 'xmldom';
import * as xpath from 'xpath';
/**
* Base validator for UBL-based invoice formats
*/
export abstract class UBLBaseValidator extends BaseValidator {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
constructor(xml: string) {
super(xml);
try {
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'
};
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
} catch (error) {
this.addError('UBL-PARSE', `Failed to parse XML: ${error}`, '/');
}
}
/**
* Validates UBL XML against the specified level of validation
* @param level Validation level
* @returns Result of validation
*/
public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult {
// Reset errors
this.errors = [];
// Check if document was parsed successfully
if (!this.doc) {
return {
valid: false,
errors: this.errors,
level: level
};
}
// Perform validation based on level
let valid = true;
if (level === ValidationLevel.SYNTAX) {
valid = this.validateSchema();
} else if (level === ValidationLevel.SEMANTIC) {
valid = this.validateSchema() && this.validateStructure();
} else if (level === ValidationLevel.BUSINESS) {
valid = this.validateSchema() &&
this.validateStructure() &&
this.validateBusinessRules();
}
return {
valid,
errors: this.errors,
level
};
}
/**
* Validates UBL XML against schema
* @returns True if schema validation passed
*/
protected validateSchema(): boolean {
// Basic schema validation (simplified for now)
if (!this.doc) return false;
// Check for root element
const root = this.doc.documentElement;
if (!root || (root.nodeName !== UBLDocumentType.INVOICE && root.nodeName !== UBLDocumentType.CREDIT_NOTE)) {
this.addError('UBL-SCHEMA-1', `Root element must be ${UBLDocumentType.INVOICE} or ${UBLDocumentType.CREDIT_NOTE}`, '/');
return false;
}
return true;
}
/**
* Validates structure of the UBL XML document
* @returns True if structure validation passed
*/
protected abstract validateStructure(): boolean;
/**
* Gets a text value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
return node ? (node.textContent || '') : '';
}
/**
* Gets a number value from an XPath expression
* @param xpath XPath expression
* @param context Optional context node
* @returns Number value or 0 if not found or not a number
*/
protected getNumber(xpathExpr: string, context?: Node): number {
const text = this.getText(xpathExpr, context);
const num = parseFloat(text);
return isNaN(num) ? 0 : num;
}
/**
* Checks if a node exists
* @param xpath XPath expression
* @param context Optional context node
* @returns True if node exists
*/
protected exists(xpathExpr: string, context?: Node): boolean {
const nodes = this.select(xpathExpr, context || this.doc);
if (Array.isArray(nodes)) {
return nodes.length > 0;
}
return false;
}
}

View File

@ -1,45 +1,16 @@
import { InvoiceFormat } from '../interfaces.js';
import type { IValidator } from '../interfaces.js';
import { BaseValidator } from './base.validator.js';
import { FacturXValidator } from './facturx.validator.js';
import { UBLValidator } from './ubl.validator.js';
import { InvoiceFormat } from '../../interfaces/common.js';
import { DOMParser } from 'xmldom';
/**
* Factory to create the appropriate validator based on the XML format
* Utility class for detecting invoice formats
*/
export class ValidatorFactory {
export class FormatDetector {
/**
* Creates a validator for the specified XML content
* @param xml XML content to validate
* @returns Appropriate validator instance
*/
public static createValidator(xml: string): BaseValidator {
const format = ValidatorFactory.detectFormat(xml);
switch (format) {
case InvoiceFormat.UBL:
case InvoiceFormat.XRECHNUNG:
return new UBLValidator(xml);
case InvoiceFormat.CII:
case InvoiceFormat.ZUGFERD:
case InvoiceFormat.FACTURX:
return new FacturXValidator(xml);
// FatturaPA and other formats would be implemented here
default:
throw new Error(`Unsupported invoice format: ${format}`);
}
}
/**
* Detects the invoice format from XML content
* Detects the format of an XML document
* @param xml XML content to analyze
* @returns Detected invoice format
*/
private static detectFormat(xml: string): InvoiceFormat {
public static detectFormat(xml: string): InvoiceFormat {
try {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const root = doc.documentElement;
@ -83,10 +54,15 @@ export class ValidatorFactory {
}
// FatturaPA detection would be implemented here
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);
return InvoiceFormat.UNKNOWN;
}
}
}
}

View File

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

View File

@ -1,335 +0,0 @@
import * as plugins from '../plugins.js';
/**
* A class to convert a given ILetter with invoice data
* into an 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 XRechnungEncoder {
constructor() {}
/**
* Creates an XRechnung compliant XML based on the provided letter data.
*/
public createXRechnungXml(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.TContact = invoice.billedBy;
const billedTo: plugins.tsclass.business.TContact = 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.type === 'company' && billedBy.registrationDetails?.vatId) {
const partyTaxScheme = supplierPartyDetails.ele('cac:PartyTaxScheme');
partyTaxScheme.ele('cbc:CompanyID').txt(billedBy.registrationDetails.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.type === 'company' && billedTo.registrationDetails?.vatId) {
const partyTaxScheme = customerPartyDetails.ele('cac:PartyTaxScheme');
partyTaxScheme.ele('cbc:CompanyID').txt(billedTo.registrationDetails.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
}
}
}