/** * XRechnung CIUS Validator * Implements German-specific validation rules for XRechnung 3.0 * * XRechnung is the German Core Invoice Usage Specification (CIUS) of EN16931 * Required for B2G invoicing in Germany since November 2020 */ import type { EInvoice } from '../../einvoice.js'; import type { ValidationResult } from './validation.types.js'; /** * XRechnung-specific validator implementing German CIUS rules */ export class XRechnungValidator { private static readonly LEITWEG_ID_PATTERN = /^[0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}$/; private static readonly IBAN_PATTERNS: Record = { DE: { length: 22, pattern: /^DE[0-9]{2}[0-9]{8}[0-9]{10}$/ }, AT: { length: 20, pattern: /^AT[0-9]{2}[0-9]{5}[0-9]{11}$/ }, CH: { length: 21, pattern: /^CH[0-9]{2}[0-9]{5}[0-9A-Z]{12}$/ }, FR: { length: 27, pattern: /^FR[0-9]{2}[0-9]{5}[0-9]{5}[0-9A-Z]{11}[0-9]{2}$/ }, NL: { length: 18, pattern: /^NL[0-9]{2}[A-Z]{4}[0-9]{10}$/ }, BE: { length: 16, pattern: /^BE[0-9]{2}[0-9]{3}[0-9]{7}[0-9]{2}$/ }, IT: { length: 27, pattern: /^IT[0-9]{2}[A-Z][0-9]{5}[0-9]{5}[0-9A-Z]{12}$/ }, ES: { length: 24, pattern: /^ES[0-9]{2}[0-9]{4}[0-9]{4}[0-9]{2}[0-9]{10}$/ } }; private static readonly BIC_PATTERN = /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/; // SEPA countries private static readonly SEPA_COUNTRIES = new Set([ 'AD', 'AT', 'BE', 'BG', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GI', 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'LI', 'LT', 'LU', 'LV', 'MC', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'SM', 'VA' ]); /** * Validate XRechnung-specific requirements */ validateXRechnung(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; // Check if this is an XRechnung invoice if (!this.isXRechnungInvoice(invoice)) { return results; // Not XRechnung, skip validation } // Validate mandatory fields results.push(...this.validateLeitwegId(invoice)); results.push(...this.validateBuyerReference(invoice)); results.push(...this.validatePaymentDetails(invoice)); results.push(...this.validateSellerContact(invoice)); results.push(...this.validateTaxRegistration(invoice)); return results; } /** * Check if invoice is XRechnung based on profile/customization ID */ private isXRechnungInvoice(invoice: EInvoice): boolean { const profileId = invoice.metadata?.profileId || ''; const customizationId = invoice.metadata?.customizationId || ''; // XRechnung profile identifiers const xrechnungProfiles = [ 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0', 'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung_3.0', 'urn:cen.eu:en16931:2017:xrechnung', 'xrechnung' ]; return xrechnungProfiles.some(profile => profileId.toLowerCase().includes(profile.toLowerCase()) || customizationId.toLowerCase().includes(profile.toLowerCase()) ); } /** * Validate Leitweg-ID (routing ID for German public administration) * Pattern: [0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30} * Rule: XR-DE-01 */ private validateLeitwegId(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; // Leitweg-ID is typically in buyer reference (BT-10) for B2G const buyerReference = invoice.metadata?.buyerReference || invoice.buyerReference || ''; // Check if it looks like a Leitweg-ID if (buyerReference && this.looksLikeLeitwegId(buyerReference)) { if (!XRechnungValidator.LEITWEG_ID_PATTERN.test(buyerReference.trim())) { results.push({ ruleId: 'XR-DE-01', severity: 'error', source: 'XRECHNUNG', message: `Invalid Leitweg-ID format: ${buyerReference}. Expected pattern: [0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}`, btReference: 'BT-10', field: 'buyerReference', value: buyerReference }); } } // For B2G invoices, Leitweg-ID might be mandatory if (this.isB2GInvoice(invoice) && !buyerReference) { results.push({ ruleId: 'XR-DE-15', severity: 'error', source: 'XRECHNUNG', message: 'Buyer reference (Leitweg-ID) is mandatory for B2G invoices in Germany', btReference: 'BT-10', field: 'buyerReference' }); } return results; } /** * Check if string looks like a Leitweg-ID */ private looksLikeLeitwegId(value: string): boolean { // Contains dashes and numbers in the right proportion return value.includes('-') && /^\d+-\d+-\d+$/.test(value.trim()); } /** * Check if this is a B2G invoice */ private isB2GInvoice(invoice: EInvoice): boolean { // Check if buyer is a public entity (simplified check) const buyerName = invoice.to?.name?.toLowerCase() || ''; const buyerType = invoice.metadata?.extensions?.buyerType?.toLowerCase() || ''; const publicIndicators = [ 'bundesamt', 'landesamt', 'stadtverwaltung', 'gemeinde', 'ministerium', 'behörde', 'öffentlich', 'public', 'government' ]; return publicIndicators.some(indicator => buyerName.includes(indicator) || buyerType.includes(indicator) ); } /** * Validate mandatory buyer reference (BT-10) * Rule: XR-DE-15 */ private validateBuyerReference(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; const buyerReference = invoice.metadata?.buyerReference || invoice.buyerReference || ''; // Skip if B2G invoice - already handled in validateLeitwegId if (this.isB2GInvoice(invoice)) { return results; } if (!buyerReference || buyerReference.trim().length === 0) { results.push({ ruleId: 'XR-DE-15', severity: 'error', source: 'XRECHNUNG', message: 'Buyer reference (BT-10) is mandatory in XRechnung', btReference: 'BT-10', field: 'buyerReference' }); } return results; } /** * Validate payment details (IBAN/BIC for SEPA) * Rules: XR-DE-19, XR-DE-20 */ private validatePaymentDetails(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; // Check payment means const paymentMeans = invoice.metadata?.extensions?.paymentMeans as Array<{ type?: string; iban?: string; bic?: string; accountName?: string; }> | undefined; if (!paymentMeans || paymentMeans.length === 0) { return results; // No payment details to validate } for (const payment of paymentMeans) { // Validate IBAN if present if (payment.iban) { const ibanResult = this.validateIBAN(payment.iban); if (!ibanResult.valid) { results.push({ ruleId: 'XR-DE-19', severity: 'error', source: 'XRECHNUNG', message: `Invalid IBAN: ${ibanResult.message}`, btReference: 'BT-84', field: 'iban', value: payment.iban }); } // Check if IBAN country is in SEPA zone const countryCode = payment.iban.substring(0, 2); if (!XRechnungValidator.SEPA_COUNTRIES.has(countryCode)) { results.push({ ruleId: 'XR-DE-19', severity: 'warning', source: 'XRECHNUNG', message: `IBAN country ${countryCode} is not in SEPA zone`, btReference: 'BT-84', field: 'iban', value: payment.iban }); } } // Validate BIC if present if (payment.bic) { const bicResult = this.validateBIC(payment.bic); if (!bicResult.valid) { results.push({ ruleId: 'XR-DE-20', severity: 'error', source: 'XRECHNUNG', message: `Invalid BIC: ${bicResult.message}`, btReference: 'BT-86', field: 'bic', value: payment.bic }); } } // For German domestic payments, BIC is optional if IBAN starts with DE if (payment.iban?.startsWith('DE') && !payment.bic) { // This is fine, BIC is optional for domestic German payments } else if (payment.iban && !payment.iban.startsWith('DE') && !payment.bic) { results.push({ ruleId: 'XR-DE-20', severity: 'warning', source: 'XRECHNUNG', message: 'BIC is recommended for international SEPA transfers', btReference: 'BT-86', field: 'bic' }); } } return results; } /** * Validate IBAN format and checksum */ private validateIBAN(iban: string): { valid: boolean; message?: string } { // Remove spaces and convert to uppercase const cleanIBAN = iban.replace(/\s/g, '').toUpperCase(); // Check basic format if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleanIBAN)) { return { valid: false, message: 'Invalid IBAN format' }; } // Get country code const countryCode = cleanIBAN.substring(0, 2); // Check country-specific format const countryFormat = XRechnungValidator.IBAN_PATTERNS[countryCode]; if (countryFormat) { if (cleanIBAN.length !== countryFormat.length) { return { valid: false, message: `Invalid IBAN length for ${countryCode}: expected ${countryFormat.length}, got ${cleanIBAN.length}` }; } if (!countryFormat.pattern.test(cleanIBAN)) { return { valid: false, message: `Invalid IBAN format for ${countryCode}` }; } } // Validate checksum using mod-97 algorithm const rearranged = cleanIBAN.substring(4) + cleanIBAN.substring(0, 4); const numeric = rearranged.replace(/[A-Z]/g, char => (char.charCodeAt(0) - 55).toString()); // Calculate mod 97 for large numbers let remainder = 0; for (let i = 0; i < numeric.length; i++) { remainder = (remainder * 10 + parseInt(numeric[i])) % 97; } if (remainder !== 1) { return { valid: false, message: 'Invalid IBAN checksum' }; } return { valid: true }; } /** * Validate BIC format */ private validateBIC(bic: string): { valid: boolean; message?: string } { const cleanBIC = bic.replace(/\s/g, '').toUpperCase(); if (!XRechnungValidator.BIC_PATTERN.test(cleanBIC)) { return { valid: false, message: 'Invalid BIC format. Expected 8 or 11 alphanumeric characters' }; } // Additional validation could check if BIC exists in SWIFT directory // but that requires external data return { valid: true }; } /** * Validate seller contact details * Rule: XR-DE-02 */ private validateSellerContact(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; // Seller contact is mandatory in XRechnung const sellerContact = invoice.metadata?.extensions?.sellerContact as { name?: string; email?: string; phone?: string; } | undefined; if (!sellerContact || (!sellerContact.name && !sellerContact.email && !sellerContact.phone)) { results.push({ ruleId: 'XR-DE-02', severity: 'error', source: 'XRECHNUNG', message: 'Seller contact information (name, email, or phone) is mandatory in XRechnung', bgReference: 'BG-6', field: 'sellerContact' }); } // Validate email format if present if (sellerContact?.email && !this.isValidEmail(sellerContact.email)) { results.push({ ruleId: 'XR-DE-02', severity: 'warning', source: 'XRECHNUNG', message: `Invalid email format: ${sellerContact.email}`, btReference: 'BT-43', field: 'email', value: sellerContact.email }); } // Validate phone format if present (basic validation) if (sellerContact?.phone && !this.isValidPhone(sellerContact.phone)) { results.push({ ruleId: 'XR-DE-02', severity: 'warning', source: 'XRECHNUNG', message: `Invalid phone format: ${sellerContact.phone}`, btReference: 'BT-42', field: 'phone', value: sellerContact.phone }); } return results; } /** * Validate email format */ private isValidEmail(email: string): boolean { const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailPattern.test(email); } /** * Validate phone format (basic) */ private isValidPhone(phone: string): boolean { // Remove common formatting characters const cleanPhone = phone.replace(/[\s\-\(\)\.]/g, ''); // Check if it contains only numbers and optional + at start return /^\+?[0-9]{6,15}$/.test(cleanPhone); } /** * Validate tax registration details * Rules: XR-DE-03, XR-DE-04 */ private validateTaxRegistration(invoice: EInvoice): ValidationResult[] { const results: ValidationResult[] = []; const sellerVatId = invoice.metadata?.sellerTaxId || (invoice.from?.type === 'company' ? (invoice.from as any).registrationDetails?.vatId : undefined) || invoice.metadata?.extensions?.sellerVatId; const sellerTaxId = invoice.metadata?.extensions?.sellerTaxId; // Either VAT ID or Tax ID must be present if (!sellerVatId && !sellerTaxId) { results.push({ ruleId: 'XR-DE-03', severity: 'error', source: 'XRECHNUNG', message: 'Either seller VAT ID (BT-31) or Tax ID (BT-32) must be provided', btReference: 'BT-31', field: 'sellerTaxRegistration' }); } // Validate German VAT ID format if present if (sellerVatId && sellerVatId.startsWith('DE')) { if (!this.isValidGermanVatId(sellerVatId)) { results.push({ ruleId: 'XR-DE-04', severity: 'error', source: 'XRECHNUNG', message: `Invalid German VAT ID format: ${sellerVatId}`, btReference: 'BT-31', field: 'vatId', value: sellerVatId }); } } // Validate German Tax ID format if present if (sellerTaxId && this.looksLikeGermanTaxId(sellerTaxId)) { if (!this.isValidGermanTaxId(sellerTaxId)) { results.push({ ruleId: 'XR-DE-04', severity: 'warning', source: 'XRECHNUNG', message: `Invalid German Tax ID format: ${sellerTaxId}`, btReference: 'BT-32', field: 'taxId', value: sellerTaxId }); } } return results; } /** * Validate German VAT ID format */ private isValidGermanVatId(vatId: string): boolean { // German VAT ID: DE followed by 9 digits const germanVatPattern = /^DE[0-9]{9}$/; return germanVatPattern.test(vatId.replace(/\s/g, '')); } /** * Check if value looks like a German Tax ID */ private looksLikeGermanTaxId(value: string): boolean { const clean = value.replace(/[\s\/\-]/g, ''); return /^[0-9]{10,11}$/.test(clean); } /** * Validate German Tax ID format */ private isValidGermanTaxId(taxId: string): boolean { // German Tax ID: 11 digits with specific checksum algorithm const clean = taxId.replace(/[\s\/\-]/g, ''); if (!/^[0-9]{11}$/.test(clean)) { return false; } // Simplified validation - full algorithm would require checksum calculation // At least check that not all digits are the same const firstDigit = clean[0]; return !clean.split('').every(digit => digit === firstDigit); } /** * Create XRechnung profile validator instance */ static create(): XRechnungValidator { return new XRechnungValidator(); } }