Files
einvoice/ts/formats/validation/facturx.validator.ts
Juergen Kunz cbb297b0b1 feat: Implement PEPPOL and XRechnung validators for compliance with e-invoice specifications
- Added PeppolValidator class to validate PEPPOL BIS 3.0 invoices, including checks for endpoint IDs, document type IDs, process IDs, party identification, and business rules.
- Implemented validation for GLN check digits, document types, and transport protocols specific to PEPPOL.
- Added XRechnungValidator class to validate XRechnung 3.0 invoices, focusing on German-specific requirements such as Leitweg-ID, payment details, seller contact, and tax registration.
- Included validation for IBAN and BIC formats, ensuring compliance with SEPA regulations.
- Established methods for checking B2G invoice indicators and validating mandatory fields for both validators.
2025-08-11 18:07:01 +00:00

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];
}
}