494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
|
/**
|
||
|
* 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<string, { length: number; pattern: RegExp }> = {
|
||
|
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();
|
||
|
}
|
||
|
}
|