update
This commit is contained in:
140
ts/formats/cii/cii.decoder.ts
Normal file
140
ts/formats/cii/cii.decoder.ts
Normal 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;
|
||||
}
|
||||
}
|
68
ts/formats/cii/cii.encoder.ts
Normal file
68
ts/formats/cii/cii.encoder.ts
Normal 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];
|
||||
}
|
||||
}
|
29
ts/formats/cii/cii.types.ts
Normal file
29
ts/formats/cii/cii.types.ts
Normal 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'
|
||||
};
|
172
ts/formats/cii/cii.validator.ts
Normal file
172
ts/formats/cii/cii.validator.ts
Normal 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;
|
||||
}
|
||||
}
|
220
ts/formats/cii/facturx/facturx.decoder.ts
Normal file
220
ts/formats/cii/facturx/facturx.decoder.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
465
ts/formats/cii/facturx/facturx.encoder.ts
Normal file
465
ts/formats/cii/facturx/facturx.encoder.ts
Normal 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}`;
|
||||
}
|
||||
}
|
18
ts/formats/cii/facturx/facturx.types.ts
Normal file
18
ts/formats/cii/facturx/facturx.types.ts
Normal 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';
|
180
ts/formats/cii/facturx/facturx.validator.ts
Normal file
180
ts/formats/cii/facturx/facturx.validator.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { CIIBaseValidator } from '../cii.validator.js';
|
||||
import { ValidationLevel } from '../../../interfaces/common.js';
|
||||
import type { ValidationResult } from '../../../interfaces/common.js';
|
||||
|
||||
/**
|
||||
* Validator for Factur-X invoice format
|
||||
* Implements validation rules according to EN16931 and Factur-X specification
|
||||
*/
|
||||
export class FacturXValidator extends CIIBaseValidator {
|
||||
/**
|
||||
* Validates structure of the Factur-X XML document
|
||||
* @returns True if structure validation passed
|
||||
*/
|
||||
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 = [
|
||||
'ram:ApplicableHeaderTradeAgreement',
|
||||
'ram:ApplicableHeaderTradeDelivery',
|
||||
'ram:ApplicableHeaderTradeSettlement'
|
||||
];
|
||||
|
||||
for (const subsection of tradeSubsections) {
|
||||
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.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)
|
||||
// and/or the Seller tax representative VAT identifier (BT-63).
|
||||
valid = this.validateSellerVatIdentifier() && valid;
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates amount calculations in the invoice
|
||||
* @returns True if amount validation passed
|
||||
*/
|
||||
private validateAmounts(): boolean {
|
||||
if (!this.doc) return false;
|
||||
|
||||
try {
|
||||
// Extract amounts
|
||||
const totalAmount = this.getNumber(
|
||||
'//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount'
|
||||
);
|
||||
|
||||
const paidAmount = this.getNumber(
|
||||
'//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TotalPrepaidAmount'
|
||||
) || 0;
|
||||
|
||||
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',
|
||||
`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.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',
|
||||
'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.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',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user