579 lines
18 KiB
TypeScript
579 lines
18 KiB
TypeScript
|
/**
|
||
|
* 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, ProfileRequirements> = {
|
||
|
[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, string> = {
|
||
|
[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, number> = {
|
||
|
[FacturXProfile.MINIMUM]: 1,
|
||
|
[FacturXProfile.BASIC_WL]: 2,
|
||
|
[FacturXProfile.BASIC]: 3,
|
||
|
[FacturXProfile.EN16931]: 4,
|
||
|
[FacturXProfile.EXTENDED]: 5
|
||
|
};
|
||
|
return levels[profile];
|
||
|
}
|
||
|
}
|