/** * Factur-X validator for profile-specific compliance * Implements validation for MINIMUM, BASIC, EN16931, and EXTENDED profiles */ import type { ValidationResult } from './validation.types.js'; import type { EInvoice } from '../../einvoice.js'; /** * Factur-X Profile definitions */ export enum FacturXProfile { MINIMUM = 'MINIMUM', BASIC = 'BASIC', BASIC_WL = 'BASIC_WL', // Basic without lines EN16931 = 'EN16931', EXTENDED = 'EXTENDED' } /** * Field cardinality requirements per profile */ interface ProfileRequirements { mandatory: string[]; optional: string[]; forbidden?: string[]; } /** * Factur-X Validator * Validates invoices according to Factur-X profile specifications */ export class FacturXValidator { private static instance: FacturXValidator; /** * Profile requirements mapping */ private profileRequirements: Record = { [FacturXProfile.MINIMUM]: { mandatory: [ 'accountingDocId', // BT-1: Invoice number 'issueDate', // BT-2: Invoice issue date 'accountingDocType', // BT-3: Invoice type code 'currency', // BT-5: Invoice currency code 'from.name', // BT-27: Seller name 'from.vatNumber', // BT-31: Seller VAT identifier 'to.name', // BT-44: Buyer name 'totalInvoiceAmount', // BT-112: Invoice total amount with VAT 'totalNetAmount', // BT-109: Invoice total amount without VAT 'totalVatAmount', // BT-110: Invoice total VAT amount ], optional: [] }, [FacturXProfile.BASIC]: { mandatory: [ // All MINIMUM fields plus: 'accountingDocId', 'issueDate', 'accountingDocType', 'currency', 'from.name', 'from.vatNumber', 'from.address', // BT-35: Seller postal address 'from.country', // BT-40: Seller country code 'to.name', 'to.address', // BT-50: Buyer postal address 'to.country', // BT-55: Buyer country code 'items', // BG-25: Invoice line items 'items[].name', // BT-153: Item name 'items[].unitQuantity', // BT-129: Invoiced quantity 'items[].unitNetPrice', // BT-146: Item net price 'items[].vatPercentage', // BT-152: Invoiced item VAT rate 'totalInvoiceAmount', 'totalNetAmount', 'totalVatAmount', 'dueDate', // BT-9: Payment due date ], optional: [ 'metadata.buyerReference', // BT-10: Buyer reference 'metadata.purchaseOrderReference', // BT-13: Purchase order reference 'metadata.salesOrderReference', // BT-14: Sales order reference 'metadata.contractReference', // BT-12: Contract reference 'projectReference', // BT-11: Project reference ] }, [FacturXProfile.BASIC_WL]: { // Basic without lines - for summary invoices mandatory: [ 'accountingDocId', 'issueDate', 'accountingDocType', 'currency', 'from.name', 'from.vatNumber', 'from.address', 'from.country', 'to.name', 'to.address', 'to.country', 'totalInvoiceAmount', 'totalNetAmount', 'totalVatAmount', 'dueDate', // No items required ], optional: [ 'metadata.buyerReference', 'metadata.purchaseOrderReference', 'metadata.contractReference', ] }, [FacturXProfile.EN16931]: { // Full EN16931 compliance - all mandatory fields from the standard mandatory: [ // Document level 'accountingDocId', 'issueDate', 'accountingDocType', 'currency', 'metadata.buyerReference', // Seller information 'from.name', 'from.address', 'from.city', 'from.postalCode', 'from.country', 'from.vatNumber', // Buyer information 'to.name', 'to.address', 'to.city', 'to.postalCode', 'to.country', // Line items 'items', 'items[].name', 'items[].unitQuantity', 'items[].unitType', 'items[].unitNetPrice', 'items[].vatPercentage', // Totals 'totalInvoiceAmount', 'totalNetAmount', 'totalVatAmount', 'dueDate', ], optional: [ // All other EN16931 fields 'metadata.purchaseOrderReference', 'metadata.salesOrderReference', 'metadata.contractReference', 'metadata.deliveryDate', 'metadata.paymentTerms', 'metadata.paymentMeans', 'to.vatNumber', 'to.legalRegistration', 'items[].articleNumber', 'items[].description', 'paymentAccount', ] }, [FacturXProfile.EXTENDED]: { // Extended profile allows all fields mandatory: [ // Same as EN16931 core 'accountingDocId', 'issueDate', 'accountingDocType', 'currency', 'from.name', 'from.vatNumber', 'to.name', 'totalInvoiceAmount', ], optional: [ // All fields are allowed in EXTENDED profile ] } }; /** * Singleton pattern for validator instance */ public static create(): FacturXValidator { if (!FacturXValidator.instance) { FacturXValidator.instance = new FacturXValidator(); } return FacturXValidator.instance; } /** * Main validation entry point for Factur-X */ public validateFacturX(invoice: EInvoice, profile?: FacturXProfile): ValidationResult[] { const results: ValidationResult[] = []; // Detect profile if not provided const detectedProfile = profile || this.detectProfile(invoice); // Skip if not a Factur-X invoice if (!detectedProfile) { return results; } // Validate according to profile results.push(...this.validateProfileRequirements(invoice, detectedProfile)); results.push(...this.validateProfileSpecificRules(invoice, detectedProfile)); // Add profile-specific business rules if (detectedProfile === FacturXProfile.MINIMUM) { results.push(...this.validateMinimumProfile(invoice)); } else if (detectedProfile === FacturXProfile.BASIC || detectedProfile === FacturXProfile.BASIC_WL) { results.push(...this.validateBasicProfile(invoice, detectedProfile)); } else if (detectedProfile === FacturXProfile.EN16931) { results.push(...this.validateEN16931Profile(invoice)); } else if (detectedProfile === FacturXProfile.EXTENDED) { results.push(...this.validateExtendedProfile(invoice)); } return results; } /** * Detect Factur-X profile from invoice metadata */ public detectProfile(invoice: EInvoice): FacturXProfile | null { const profileId = invoice.metadata?.profileId || ''; const customizationId = invoice.metadata?.customizationId || ''; const format = invoice.metadata?.format; // Check if it's a Factur-X invoice if (!format?.includes('facturx') && !profileId.includes('facturx') && !customizationId.includes('facturx') && !profileId.includes('zugferd')) { return null; } // Detect specific profile const profileLower = profileId.toLowerCase(); const customLower = customizationId.toLowerCase(); if (profileLower.includes('minimum') || customLower.includes('minimum')) { return FacturXProfile.MINIMUM; } else if (profileLower.includes('basic_wl') || customLower.includes('basicwl')) { return FacturXProfile.BASIC_WL; } else if (profileLower.includes('basic') || customLower.includes('basic')) { return FacturXProfile.BASIC; } else if (profileLower.includes('en16931') || customLower.includes('en16931') || profileLower.includes('comfort') || customLower.includes('comfort')) { return FacturXProfile.EN16931; } else if (profileLower.includes('extended') || customLower.includes('extended')) { return FacturXProfile.EXTENDED; } // Default to BASIC if format is Factur-X but profile unclear return FacturXProfile.BASIC; } /** * Validate field requirements for a specific profile */ private validateProfileRequirements(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] { const results: ValidationResult[] = []; const requirements = this.profileRequirements[profile]; // Check mandatory fields for (const field of requirements.mandatory) { const value = this.getFieldValue(invoice, field); if (value === undefined || value === null || value === '') { results.push({ ruleId: `FX-${profile}-M01`, severity: 'error', message: `Field '${field}' is mandatory for Factur-X ${profile} profile`, field: field, source: 'FACTURX' }); } } // Check forbidden fields (if any) if (requirements.forbidden) { for (const field of requirements.forbidden) { const value = this.getFieldValue(invoice, field); if (value !== undefined && value !== null) { results.push({ ruleId: `FX-${profile}-F01`, severity: 'error', message: `Field '${field}' is not allowed in Factur-X ${profile} profile`, field: field, value: value, source: 'FACTURX' }); } } } return results; } /** * Get field value from invoice using dot notation */ private getFieldValue(invoice: any, fieldPath: string): any { // Handle special calculated fields if (fieldPath === 'totalInvoiceAmount') { return invoice.totalGross || invoice.totalInvoiceAmount; } if (fieldPath === 'totalNetAmount') { return invoice.totalNet || invoice.totalNetAmount; } if (fieldPath === 'totalVatAmount') { return invoice.totalVat || invoice.totalVatAmount; } if (fieldPath === 'dueDate') { // Check for dueInDays which is used in EInvoice if (invoice.dueInDays !== undefined && invoice.dueInDays !== null) { return true; // Has payment terms } return invoice.dueDate; } const parts = fieldPath.split('.'); let value = invoice; for (const part of parts) { if (part.includes('[')) { // Array field like items[] const fieldName = part.substring(0, part.indexOf('[')); const arrayField = part.substring(part.indexOf('[') + 1, part.indexOf(']')); if (!value[fieldName] || !Array.isArray(value[fieldName])) { return undefined; } if (arrayField === '') { // Check if array exists and has items return value[fieldName].length > 0 ? value[fieldName] : undefined; } else { // Check specific field in array items return value[fieldName].every((item: any) => item[arrayField] !== undefined); } } else { value = value?.[part]; } } return value; } /** * Profile-specific validation rules */ private validateProfileSpecificRules(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] { const results: ValidationResult[] = []; // Validate according to profile level switch (profile) { case FacturXProfile.MINIMUM: // MINIMUM requires at least gross amounts // Check both calculated totals and direct properties (for test compatibility) const totalGross = invoice.totalGross || (invoice as any).totalInvoiceAmount; if (!totalGross || totalGross <= 0) { results.push({ ruleId: 'FX-MIN-01', severity: 'error', message: 'MINIMUM profile requires positive total invoice amount', field: 'totalInvoiceAmount', value: totalGross, source: 'FACTURX' }); } break; case FacturXProfile.BASIC: case FacturXProfile.BASIC_WL: // BASIC requires VAT breakdown const totalVat = invoice.totalVat; if (!invoice.metadata?.extensions?.taxDetails && totalVat > 0) { results.push({ ruleId: 'FX-BAS-01', severity: 'warning', message: 'BASIC profile should include VAT breakdown when VAT is present', field: 'metadata.extensions.taxDetails', source: 'FACTURX' }); } break; case FacturXProfile.EN16931: // EN16931 requires full compliance - additional checks handled by EN16931 validator if (!invoice.metadata?.buyerReference && !invoice.metadata?.extensions?.purchaseOrderReference) { results.push({ ruleId: 'FX-EN-01', severity: 'error', message: 'EN16931 profile requires either buyer reference or purchase order reference', field: 'metadata.buyerReference', source: 'FACTURX' }); } break; } return results; } /** * Validate MINIMUM profile specific rules */ private validateMinimumProfile(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; // MINIMUM profile allows only essential fields // Check that complex structures are not present if (invoice.items && invoice.items.length > 0) { // Lines are optional but if present must be minimal invoice.items.forEach((item, index) => { if ((item as any).allowances || (item as any).charges) { results.push({ ruleId: 'FX-MIN-02', severity: 'warning', message: `Line ${index + 1}: MINIMUM profile should not include line allowances/charges`, field: `items[${index}]`, source: 'FACTURX' }); } }); } return results; } /** * Validate BASIC profile specific rules */ private validateBasicProfile(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] { const results: ValidationResult[] = []; // BASIC requires line items (except BASIC_WL) // Only check for line items in BASIC profile, not BASIC_WL if (profile === FacturXProfile.BASIC) { if (!invoice.items || invoice.items.length === 0) { results.push({ ruleId: 'FX-BAS-02', severity: 'error', message: 'BASIC profile requires at least one invoice line item', field: 'items', source: 'FACTURX' }); } } // Payment information should be present if (!invoice.dueInDays && invoice.dueInDays !== 0) { results.push({ ruleId: 'FX-BAS-03', severity: 'warning', message: 'BASIC profile should include payment terms (due in days)', field: 'dueInDays', source: 'FACTURX' }); } return results; } /** * Validate EN16931 profile specific rules */ private validateEN16931Profile(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; // EN16931 requires complete address information const fromAny = invoice.from as any; const toAny = invoice.to as any; if (!fromAny?.city || !fromAny?.postalCode) { results.push({ ruleId: 'FX-EN-02', severity: 'error', message: 'EN16931 profile requires complete seller address including city and postal code', field: 'from.address', source: 'FACTURX' }); } if (!toAny?.city || !toAny?.postalCode) { results.push({ ruleId: 'FX-EN-03', severity: 'error', message: 'EN16931 profile requires complete buyer address including city and postal code', field: 'to.address', source: 'FACTURX' }); } // Line items must have unit type if (invoice.items) { invoice.items.forEach((item, index) => { if (!item.unitType) { results.push({ ruleId: 'FX-EN-04', severity: 'error', message: `Line ${index + 1}: EN16931 profile requires unit of measure`, field: `items[${index}].unitType`, source: 'FACTURX' }); } }); } return results; } /** * Validate EXTENDED profile specific rules */ private validateExtendedProfile(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; // EXTENDED profile is most permissive - mainly check for data consistency if (invoice.metadata?.extensions) { // Extended profile can include additional structured data // Validate that extended data is well-formed const extensions = invoice.metadata.extensions; if (extensions.attachments && Array.isArray(extensions.attachments)) { extensions.attachments.forEach((attachment: any, index: number) => { if (!attachment.filename || !attachment.mimeType) { results.push({ ruleId: 'FX-EXT-01', severity: 'warning', message: `Attachment ${index + 1}: Should include filename and MIME type`, field: `metadata.extensions.attachments[${index}]`, source: 'FACTURX' }); } }); } } return results; } /** * Get profile display name */ public getProfileDisplayName(profile: FacturXProfile): string { const names: Record = { [FacturXProfile.MINIMUM]: 'Factur-X MINIMUM', [FacturXProfile.BASIC]: 'Factur-X BASIC', [FacturXProfile.BASIC_WL]: 'Factur-X BASIC WL', [FacturXProfile.EN16931]: 'Factur-X EN16931', [FacturXProfile.EXTENDED]: 'Factur-X EXTENDED' }; return names[profile]; } /** * Get profile compliance level (for reporting) */ public getProfileComplianceLevel(profile: FacturXProfile): number { const levels: Record = { [FacturXProfile.MINIMUM]: 1, [FacturXProfile.BASIC_WL]: 2, [FacturXProfile.BASIC]: 3, [FacturXProfile.EN16931]: 4, [FacturXProfile.EXTENDED]: 5 }; return levels[profile]; } }