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:
494
ts/formats/validation/xrechnung.validator.ts
Normal file
494
ts/formats/validation/xrechnung.validator.ts
Normal 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();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user