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.
This commit is contained in:
2025-08-11 18:07:01 +00:00
parent 10e14af85b
commit cbb297b0b1
24 changed files with 7714 additions and 98 deletions

View File

@@ -0,0 +1,494 @@
/**
* 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();
}
}