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

@@ -2,6 +2,8 @@ import * as plugins from '../../plugins.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import type { EInvoice } from '../../einvoice.js';
import { CurrencyCalculator, areMonetaryValuesEqual } from '../utils/currency.utils.js';
import { DecimalCurrencyCalculator } from '../utils/currency.calculator.decimal.js';
import { Decimal } from '../utils/decimal.js';
import type { ValidationResult, ValidationOptions } from './validation.types.js';
/**
@@ -11,6 +13,7 @@ import type { ValidationResult, ValidationOptions } from './validation.types.js'
export class EN16931BusinessRulesValidator {
private results: ValidationResult[] = [];
private currencyCalculator?: CurrencyCalculator;
private decimalCalculator?: DecimalCurrencyCalculator;
/**
* Validate an invoice against EN16931 business rules
@@ -18,9 +21,10 @@ export class EN16931BusinessRulesValidator {
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
this.results = [];
// Initialize currency calculator if currency is available
// Initialize currency calculators if currency is available
if (invoice.currency) {
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
this.decimalCalculator = new DecimalCurrencyCalculator(invoice.currency);
}
// Document level rules (BR-01 to BR-65)
@@ -118,100 +122,139 @@ export class EN16931BusinessRulesValidator {
private validateCalculationRules(invoice: EInvoice): void {
if (!invoice.items || invoice.items.length === 0) return;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = this.calculateLineTotal(invoice.items);
const declaredLineTotal = invoice.totalNet || 0;
// Use decimal calculator for precise calculations
const useDecimal = this.decimalCalculator !== undefined;
const isEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal, declaredLineTotal)
: Math.abs(calculatedLineTotal - declaredLineTotal) < 0.01;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = useDecimal
? this.calculateLineTotalDecimal(invoice.items)
: this.calculateLineTotal(invoice.items);
const declaredLineTotal = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedLineTotal, declaredLineTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal as number, declaredLineTotal as number)
: Math.abs((calculatedLineTotal as number) - (declaredLineTotal as number)) < 0.01;
if (!isEqual) {
this.addError(
'BR-CO-10',
`Sum of line net amounts (${calculatedLineTotal.toFixed(2)}) does not match declared total (${declaredLineTotal.toFixed(2)})`,
`Sum of line net amounts (${useDecimal ? (calculatedLineTotal as Decimal).toFixed(2) : (calculatedLineTotal as number).toFixed(2)}) does not match declared total (${useDecimal ? (declaredLineTotal as Decimal).toFixed(2) : (declaredLineTotal as number).toFixed(2)})`,
'totalNet',
declaredLineTotal,
calculatedLineTotal
useDecimal ? (declaredLineTotal as Decimal).toNumber() : declaredLineTotal as number,
useDecimal ? (calculatedLineTotal as Decimal).toNumber() : calculatedLineTotal as number
);
}
// BR-CO-11: Sum of allowances on document level
const documentAllowances = this.calculateDocumentAllowances(invoice);
const documentAllowances = useDecimal
? this.calculateDocumentAllowancesDecimal(invoice)
: this.calculateDocumentAllowances(invoice);
// BR-CO-12: Sum of charges on document level
const documentCharges = this.calculateDocumentCharges(invoice);
const documentCharges = useDecimal
? this.calculateDocumentChargesDecimal(invoice)
: this.calculateDocumentCharges(invoice);
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
const expectedTaxExclusive = calculatedLineTotal - documentAllowances + documentCharges;
const declaredTaxExclusive = invoice.totalNet || 0;
const expectedTaxExclusive = useDecimal
? (calculatedLineTotal as Decimal).subtract(documentAllowances).add(documentCharges)
: (calculatedLineTotal as number) - (documentAllowances as number) + (documentCharges as number);
const declaredTaxExclusive = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isTaxExclusiveEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: Math.abs(expectedTaxExclusive - declaredTaxExclusive) < 0.01;
const isTaxExclusiveEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive as number, declaredTaxExclusive as number)
: Math.abs((expectedTaxExclusive as number) - (declaredTaxExclusive as number)) < 0.01;
if (!isTaxExclusiveEqual) {
this.addError(
'BR-CO-13',
`Tax exclusive amount (${declaredTaxExclusive.toFixed(2)}) does not match calculation (${expectedTaxExclusive.toFixed(2)})`,
`Tax exclusive amount (${useDecimal ? (declaredTaxExclusive as Decimal).toFixed(2) : (declaredTaxExclusive as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedTaxExclusive as Decimal).toFixed(2) : (expectedTaxExclusive as number).toFixed(2)})`,
'totalNet',
declaredTaxExclusive,
expectedTaxExclusive
useDecimal ? (declaredTaxExclusive as Decimal).toNumber() : declaredTaxExclusive as number,
useDecimal ? (expectedTaxExclusive as Decimal).toNumber() : expectedTaxExclusive as number
);
}
// BR-CO-14: Invoice total VAT amount = Σ(VAT category tax amount)
const calculatedVAT = this.calculateTotalVAT(invoice);
const declaredVAT = invoice.totalVat || 0;
const calculatedVAT = useDecimal
? this.calculateTotalVATDecimal(invoice)
: this.calculateTotalVAT(invoice);
const declaredVAT = useDecimal
? new Decimal(invoice.totalVat || 0)
: invoice.totalVat || 0;
const isVATEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT, declaredVAT)
: Math.abs(calculatedVAT - declaredVAT) < 0.01;
const isVATEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedVAT, declaredVAT)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT as number, declaredVAT as number)
: Math.abs((calculatedVAT as number) - (declaredVAT as number)) < 0.01;
if (!isVATEqual) {
this.addError(
'BR-CO-14',
`Total VAT (${declaredVAT.toFixed(2)}) does not match calculation (${calculatedVAT.toFixed(2)})`,
`Total VAT (${useDecimal ? (declaredVAT as Decimal).toFixed(2) : (declaredVAT as number).toFixed(2)}) does not match calculation (${useDecimal ? (calculatedVAT as Decimal).toFixed(2) : (calculatedVAT as number).toFixed(2)})`,
'totalVat',
declaredVAT,
calculatedVAT
useDecimal ? (declaredVAT as Decimal).toNumber() : declaredVAT as number,
useDecimal ? (calculatedVAT as Decimal).toNumber() : calculatedVAT as number
);
}
// BR-CO-15: Invoice total with VAT = Invoice total without VAT + Invoice total VAT
const expectedGrossTotal = expectedTaxExclusive + calculatedVAT;
const declaredGrossTotal = invoice.totalGross || 0;
const expectedGrossTotal = useDecimal
? (expectedTaxExclusive as Decimal).add(calculatedVAT)
: (expectedTaxExclusive as number) + (calculatedVAT as number);
const declaredGrossTotal = useDecimal
? new Decimal(invoice.totalGross || 0)
: invoice.totalGross || 0;
const isGrossEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal, declaredGrossTotal)
: Math.abs(expectedGrossTotal - declaredGrossTotal) < 0.01;
const isGrossEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedGrossTotal, declaredGrossTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal as number, declaredGrossTotal as number)
: Math.abs((expectedGrossTotal as number) - (declaredGrossTotal as number)) < 0.01;
if (!isGrossEqual) {
this.addError(
'BR-CO-15',
`Gross total (${declaredGrossTotal.toFixed(2)}) does not match calculation (${expectedGrossTotal.toFixed(2)})`,
`Gross total (${useDecimal ? (declaredGrossTotal as Decimal).toFixed(2) : (declaredGrossTotal as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedGrossTotal as Decimal).toFixed(2) : (expectedGrossTotal as number).toFixed(2)})`,
'totalGross',
declaredGrossTotal,
expectedGrossTotal
useDecimal ? (declaredGrossTotal as Decimal).toNumber() : declaredGrossTotal as number,
useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal as number
);
}
// BR-CO-16: Amount due for payment = Invoice total with VAT - Paid amount
const paidAmount = invoice.metadata?.paidAmount || 0;
const expectedDueAmount = expectedGrossTotal - paidAmount;
const declaredDueAmount = invoice.metadata?.amountDue || expectedGrossTotal;
const paidAmount = useDecimal
? new Decimal(invoice.metadata?.paidAmount || 0)
: invoice.metadata?.paidAmount || 0;
const expectedDueAmount = useDecimal
? (expectedGrossTotal as Decimal).subtract(paidAmount)
: (expectedGrossTotal as number) - (paidAmount as number);
const declaredDueAmount = useDecimal
? new Decimal(invoice.metadata?.amountDue || (useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal))
: invoice.metadata?.amountDue || expectedGrossTotal;
const isDueEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount, declaredDueAmount)
: Math.abs(expectedDueAmount - declaredDueAmount) < 0.01;
const isDueEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedDueAmount, declaredDueAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount as number, declaredDueAmount as number)
: Math.abs((expectedDueAmount as number) - (declaredDueAmount as number)) < 0.01;
if (!isDueEqual) {
this.addError(
'BR-CO-16',
`Amount due (${declaredDueAmount.toFixed(2)}) does not match calculation (${expectedDueAmount.toFixed(2)})`,
`Amount due (${useDecimal ? (declaredDueAmount as Decimal).toFixed(2) : (declaredDueAmount as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedDueAmount as Decimal).toFixed(2) : (expectedDueAmount as number).toFixed(2)})`,
'amountDue',
declaredDueAmount,
expectedDueAmount
useDecimal ? (declaredDueAmount as Decimal).toNumber() : declaredDueAmount as number,
useDecimal ? (expectedDueAmount as Decimal).toNumber() : expectedDueAmount as number
);
}
}
@@ -220,6 +263,8 @@ export class EN16931BusinessRulesValidator {
* Validate VAT rules
*/
private validateVATRules(invoice: EInvoice): void {
const useDecimal = this.decimalCalculator !== undefined;
// Group items by VAT rate
const vatGroups = this.groupItemsByVAT(invoice.items || []);
@@ -247,11 +292,19 @@ export class EN16931BusinessRulesValidator {
// BR-S-03: VAT category tax amount for standard rated
vatGroups.forEach((group, rate) => {
if (rate > 0) { // Standard rated
const expectedTaxableAmount = group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxableAmount = useDecimal
? group.reduce((sum, item) => {
const unitPrice = new Decimal(item.unitNetPrice);
const quantity = new Decimal(item.unitQuantity);
return sum.add(unitPrice.multiply(quantity));
}, Decimal.ZERO)
: group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxAmount = expectedTaxableAmount * (rate / 100);
const expectedTaxAmount = useDecimal
? this.decimalCalculator!.calculateVAT(expectedTaxableAmount, new Decimal(rate))
: (expectedTaxableAmount as number) * (rate / 100);
// Find corresponding breakdown
const breakdown = invoice.taxBreakdown?.find(b =>
@@ -259,9 +312,11 @@ export class EN16931BusinessRulesValidator {
);
if (breakdown) {
const isTaxableEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount)
: Math.abs(breakdown.netAmount - expectedTaxableAmount) < 0.01;
const isTaxableEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxableAmount, breakdown.netAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount as number)
: Math.abs(breakdown.netAmount - (expectedTaxableAmount as number)) < 0.01;
if (!isTaxableEqual) {
this.addError(
@@ -269,13 +324,15 @@ export class EN16931BusinessRulesValidator {
`VAT taxable amount for ${rate}% incorrect`,
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxableAmount
useDecimal ? (expectedTaxableAmount as Decimal).toNumber() : expectedTaxableAmount as number
);
}
const isTaxEqual = this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount)
: Math.abs(breakdown.taxAmount - expectedTaxAmount) < 0.01;
const isTaxEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxAmount, breakdown.taxAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount as number)
: Math.abs(breakdown.taxAmount - (expectedTaxAmount as number)) < 0.01;
if (!isTaxEqual) {
this.addError(
@@ -283,7 +340,7 @@ export class EN16931BusinessRulesValidator {
`VAT tax amount for ${rate}% incorrect`,
'taxBreakdown.vatAmount',
breakdown.taxAmount,
expectedTaxAmount
useDecimal ? (expectedTaxAmount as Decimal).toNumber() : expectedTaxAmount as number
);
}
}
@@ -467,6 +524,90 @@ export class EN16931BusinessRulesValidator {
return sum + rounded;
}, 0);
}
/**
* Calculate line total using decimal arithmetic for precision
*/
private calculateLineTotalDecimal(items: TAccountingDocItem[]): Decimal {
let total = Decimal.ZERO;
for (const item of items) {
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineTotal = unitPrice.multiply(quantity);
total = total.add(this.decimalCalculator!.round(lineTotal));
}
return total;
}
/**
* Calculate document allowances using decimal arithmetic
*/
private calculateDocumentAllowancesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.allowances) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const allowance of invoice.metadata.allowances) {
const amount = new Decimal(allowance.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate document charges using decimal arithmetic
*/
private calculateDocumentChargesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.charges) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const charge of invoice.metadata.charges) {
const amount = new Decimal(charge.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate total VAT using decimal arithmetic
*/
private calculateTotalVATDecimal(invoice: EInvoice): Decimal {
let totalVAT = Decimal.ZERO;
// Group items by VAT rate
const vatGroups = new Map<string, Decimal>();
for (const item of invoice.items || []) {
const vatRate = item.vatPercentage || 0;
const rateKey = vatRate.toString();
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineNet = unitPrice.multiply(quantity);
if (vatGroups.has(rateKey)) {
vatGroups.set(rateKey, vatGroups.get(rateKey)!.add(lineNet));
} else {
vatGroups.set(rateKey, lineNet);
}
}
// Calculate VAT for each group
for (const [rateKey, baseAmount] of vatGroups) {
const rate = new Decimal(rateKey);
const vat = this.decimalCalculator!.calculateVAT(baseAmount, rate);
totalVAT = totalVAT.add(vat);
}
return totalVAT;
}
private calculateDocumentAllowances(invoice: EInvoice): number {
return invoice.metadata?.allowances?.reduce((sum: number, allowance: any) =>

View File

@@ -0,0 +1,579 @@
/**
* 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];
}
}

View File

@@ -0,0 +1,405 @@
/**
* Main integrated validator combining all validation capabilities
* Orchestrates TypeScript validators, Schematron, and profile-specific rules
*/
import { IntegratedValidator } from './schematron.integration.js';
import { XRechnungValidator } from './xrechnung.validator.js';
import { PeppolValidator } from './peppol.validator.js';
import { FacturXValidator } from './facturx.validator.js';
import { EN16931BusinessRulesValidator } from './en16931.business-rules.validator.js';
import { CodeListValidator } from './codelist.validator.js';
import type { ValidationResult, ValidationOptions, ValidationReport } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* Main validator that combines all validation capabilities
*/
export class MainValidator {
private integratedValidator: IntegratedValidator;
private xrechnungValidator: XRechnungValidator;
private peppolValidator: PeppolValidator;
private facturxValidator: FacturXValidator;
private businessRulesValidator: EN16931BusinessRulesValidator;
private codeListValidator: CodeListValidator;
private schematronEnabled: boolean = false;
constructor() {
this.integratedValidator = new IntegratedValidator();
this.xrechnungValidator = XRechnungValidator.create();
this.peppolValidator = PeppolValidator.create();
this.facturxValidator = FacturXValidator.create();
this.businessRulesValidator = new EN16931BusinessRulesValidator();
this.codeListValidator = new CodeListValidator();
}
/**
* Initialize Schematron validation for better coverage
*/
public async initializeSchematron(
profile?: 'EN16931' | 'PEPPOL' | 'XRECHNUNG'
): Promise<void> {
try {
// Check available Schematron files
const available = await this.integratedValidator.getAvailableSchematron();
if (available.length === 0) {
console.warn('No Schematron files available. Run: npm run download-schematron');
return;
}
// Load appropriate Schematron based on profile
const standard = profile || 'EN16931';
const format = 'UBL'; // Default to UBL, can be made configurable
await this.integratedValidator.loadSchematron(
standard === 'XRECHNUNG' ? 'EN16931' : standard, // XRechnung uses EN16931 as base
format
);
this.schematronEnabled = true;
console.log(`Schematron validation enabled for ${standard} ${format}`);
} catch (error) {
console.warn(`Failed to initialize Schematron: ${error.message}`);
}
}
/**
* Validate an invoice with all available validators
*/
public async validate(
invoice: EInvoice,
xmlContent?: string,
options: ValidationOptions = {}
): Promise<ValidationReport> {
const startTime = Date.now();
const results: ValidationResult[] = [];
// Detect profile from invoice
const profile = this.detectProfile(invoice);
const mergedOptions: ValidationOptions = {
...options,
profile: profile as ValidationOptions['profile']
};
// Run base validators
if (options.checkCodeLists !== false) {
results.push(...this.codeListValidator.validate(invoice));
}
results.push(...this.businessRulesValidator.validate(invoice, mergedOptions));
// Run XRechnung-specific validation if applicable
if (this.isXRechnungInvoice(invoice)) {
const xrResults = this.xrechnungValidator.validateXRechnung(invoice);
results.push(...xrResults);
}
// Run PEPPOL-specific validation if applicable
if (this.isPeppolInvoice(invoice)) {
const peppolResults = this.peppolValidator.validatePeppol(invoice);
results.push(...peppolResults);
}
// Run Factur-X specific validation if applicable
if (this.isFacturXInvoice(invoice)) {
const facturxResults = this.facturxValidator.validateFacturX(invoice);
results.push(...facturxResults);
}
// Run Schematron validation if available and XML is provided
if (this.schematronEnabled && xmlContent) {
try {
const schematronReport = await this.integratedValidator.validate(
invoice,
xmlContent,
mergedOptions
);
// Extract only Schematron-specific results to avoid duplication
const schematronResults = schematronReport.results.filter(
r => r.source === 'SCHEMATRON'
);
results.push(...schematronResults);
} catch (error) {
console.warn(`Schematron validation error: ${error.message}`);
}
}
// Remove duplicates (same rule + same field)
const uniqueResults = this.deduplicateResults(results);
// Calculate statistics
const errorCount = uniqueResults.filter(r => r.severity === 'error').length;
const warningCount = uniqueResults.filter(r => r.severity === 'warning').length;
const infoCount = uniqueResults.filter(r => r.severity === 'info').length;
// Estimate coverage
const totalRules = this.estimateTotalRules(profile);
const rulesChecked = new Set(uniqueResults.map(r => r.ruleId)).size;
const coverage = totalRules > 0 ? (rulesChecked / totalRules) * 100 : 0;
return {
valid: errorCount === 0,
profile: profile || 'EN16931',
timestamp: new Date().toISOString(),
validatorVersion: '2.0.0',
rulesetVersion: '1.3.14',
results: uniqueResults,
errorCount,
warningCount,
infoCount,
rulesChecked,
rulesTotal: totalRules,
coverage,
validationTime: Date.now() - startTime,
documentId: invoice.accountingDocId,
documentType: invoice.accountingDocType,
format: this.detectFormat(xmlContent)
} as ValidationReport & { schematronEnabled: boolean };
}
/**
* Detect profile from invoice metadata
*/
private detectProfile(invoice: EInvoice): string {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
if (profileId.includes('xrechnung') || customizationId.includes('xrechnung')) {
return 'XRECHNUNG_3.0';
}
if (profileId.includes('peppol') || customizationId.includes('peppol') ||
profileId.includes('urn:fdc:peppol.eu')) {
return 'PEPPOL_BIS_3.0';
}
if (profileId.includes('facturx') || customizationId.includes('facturx') ||
profileId.includes('zugferd')) {
// Try to detect specific Factur-X profile
const facturxProfile = this.facturxValidator.detectProfile(invoice);
if (facturxProfile) {
return `FACTURX_${facturxProfile}`;
}
return 'FACTURX_EN16931';
}
return 'EN16931';
}
/**
* Check if invoice is XRechnung
*/
private isXRechnungInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const xrechnungProfiles = [
'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung',
'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung',
'xrechnung'
];
return xrechnungProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Check if invoice is PEPPOL
*/
private isPeppolInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const peppolProfiles = [
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'peppol-bis-3',
'peppol'
];
return peppolProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Check if invoice is Factur-X
*/
private isFacturXInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const format = invoice.metadata?.format;
return format?.includes('facturx') ||
profileId.toLowerCase().includes('facturx') ||
customizationId.toLowerCase().includes('facturx') ||
profileId.toLowerCase().includes('zugferd') ||
customizationId.toLowerCase().includes('zugferd');
}
/**
* Detect format from XML content
*/
private detectFormat(xmlContent?: string): 'UBL' | 'CII' | undefined {
if (!xmlContent) return undefined;
if (xmlContent.includes('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2')) {
return 'UBL';
} else if (xmlContent.includes('urn:un:unece:uncefact:data:standard:CrossIndustryInvoice')) {
return 'CII';
}
return undefined;
}
/**
* Remove duplicate validation results
*/
private deduplicateResults(results: ValidationResult[]): ValidationResult[] {
const seen = new Set<string>();
const unique: ValidationResult[] = [];
for (const result of results) {
const key = `${result.ruleId}|${result.field || ''}|${result.message}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(result);
}
}
return unique;
}
/**
* Estimate total rules for coverage calculation
*/
private estimateTotalRules(profile?: string): number {
const ruleCounts: Record<string, number> = {
EN16931: 150,
'PEPPOL_BIS_3.0': 250,
'XRECHNUNG_3.0': 280,
FACTURX_BASIC: 100,
FACTURX_EN16931: 150
};
return ruleCounts[profile || 'EN16931'] || 150;
}
/**
* Validate with automatic format and profile detection
*/
public async validateAuto(
invoice: EInvoice,
xmlContent?: string
): Promise<ValidationReport> {
// Auto-detect profile
const profile = this.detectProfile(invoice);
// Initialize Schematron if not already done
if (!this.schematronEnabled && xmlContent) {
await this.initializeSchematron(
profile.startsWith('XRECHNUNG') ? 'XRECHNUNG' :
profile.startsWith('PEPPOL') ? 'PEPPOL' : 'EN16931'
);
}
return this.validate(invoice, xmlContent, {
checkCalculations: true,
checkVAT: true,
checkCodeLists: true,
strictMode: profile.includes('XRECHNUNG') // Strict for XRechnung
});
}
/**
* Get validation capabilities
*/
public getCapabilities(): {
schematron: boolean;
xrechnung: boolean;
peppol: boolean;
facturx: boolean;
calculations: boolean;
codeLists: boolean;
} {
return {
schematron: this.schematronEnabled,
xrechnung: true,
peppol: true,
facturx: true,
calculations: true,
codeLists: true
};
}
/**
* Format validation report as text
*/
public formatReport(report: ValidationReport): string {
const lines: string[] = [];
lines.push('=== Validation Report ===');
lines.push(`Profile: ${report.profile}`);
lines.push(`Valid: ${report.valid ? '✅' : '❌'}`);
lines.push(`Timestamp: ${report.timestamp}`);
lines.push('');
if (report.errorCount > 0) {
lines.push(`Errors: ${report.errorCount}`);
report.results
.filter(r => r.severity === 'error')
.forEach(r => {
lines.push(` ❌ [${r.ruleId}] ${r.message}`);
if (r.field) lines.push(` Field: ${r.field}`);
});
lines.push('');
}
if (report.warningCount > 0) {
lines.push(`Warnings: ${report.warningCount}`);
report.results
.filter(r => r.severity === 'warning')
.forEach(r => {
lines.push(` ⚠️ [${r.ruleId}] ${r.message}`);
if (r.field) lines.push(` Field: ${r.field}`);
});
lines.push('');
}
lines.push('Statistics:');
lines.push(` Rules checked: ${report.rulesChecked}/${report.rulesTotal}`);
lines.push(` Coverage: ${report.coverage.toFixed(1)}%`);
lines.push(` Validation time: ${report.validationTime}ms`);
if ((report as any).schematronEnabled) {
lines.push(' Schematron: ✅ Enabled');
}
return lines.join('\n');
}
}
/**
* Create a pre-configured validator instance
*/
export async function createValidator(
options: {
profile?: 'EN16931' | 'PEPPOL' | 'XRECHNUNG';
enableSchematron?: boolean;
} = {}
): Promise<MainValidator> {
const validator = new MainValidator();
if (options.enableSchematron !== false) {
await validator.initializeSchematron(options.profile);
}
return validator;
}
// Export for convenience
export type { ValidationReport, ValidationResult, ValidationOptions } from './validation.types.js';

View File

@@ -0,0 +1,589 @@
/**
* PEPPOL BIS 3.0 validator for compliance with PEPPOL e-invoice specifications
* Implements PEPPOL-specific validation rules on top of EN16931
*/
import type { ValidationResult } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* PEPPOL BIS 3.0 Validator
* Implements PEPPOL-specific validation rules and constraints
*/
export class PeppolValidator {
private static instance: PeppolValidator;
/**
* Singleton pattern for validator instance
*/
public static create(): PeppolValidator {
if (!PeppolValidator.instance) {
PeppolValidator.instance = new PeppolValidator();
}
return PeppolValidator.instance;
}
/**
* Main validation entry point for PEPPOL
*/
public validatePeppol(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if this is a PEPPOL invoice
if (!this.isPeppolInvoice(invoice)) {
return results; // Not a PEPPOL invoice, skip validation
}
// Run all PEPPOL validations
results.push(...this.validateEndpointId(invoice));
results.push(...this.validateDocumentTypeId(invoice));
results.push(...this.validateProcessId(invoice));
results.push(...this.validatePartyIdentification(invoice));
results.push(...this.validatePeppolBusinessRules(invoice));
results.push(...this.validateSchemeIds(invoice));
results.push(...this.validateTransportProtocol(invoice));
return results;
}
/**
* Check if invoice is PEPPOL
*/
private isPeppolInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const peppolProfiles = [
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'peppol-bis-3',
'peppol'
];
return peppolProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Validate Endpoint ID format (0088:xxxxxxxxx or other schemes)
* PEPPOL-T001, PEPPOL-T002
*/
private validateEndpointId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check seller endpoint ID
const sellerEndpointId = invoice.metadata?.extensions?.sellerEndpointId ||
invoice.metadata?.extensions?.peppolSellerEndpoint;
if (sellerEndpointId) {
if (!this.isValidEndpointId(sellerEndpointId)) {
results.push({
ruleId: 'PEPPOL-T001',
severity: 'error',
message: 'Invalid seller endpoint ID format. Expected format: scheme:identifier (e.g., 0088:1234567890128)',
field: 'metadata.extensions.sellerEndpointId',
value: sellerEndpointId,
source: 'PEPPOL'
});
}
} else if (this.isPeppolB2G(invoice)) {
// Endpoint ID is mandatory for B2G
results.push({
ruleId: 'PEPPOL-T001',
severity: 'error',
message: 'Seller endpoint ID is mandatory for PEPPOL B2G invoices',
field: 'metadata.extensions.sellerEndpointId',
source: 'PEPPOL'
});
}
// Check buyer endpoint ID
const buyerEndpointId = invoice.metadata?.extensions?.buyerEndpointId ||
invoice.metadata?.extensions?.peppolBuyerEndpoint;
if (buyerEndpointId) {
if (!this.isValidEndpointId(buyerEndpointId)) {
results.push({
ruleId: 'PEPPOL-T002',
severity: 'error',
message: 'Invalid buyer endpoint ID format. Expected format: scheme:identifier (e.g., 0088:1234567890128)',
field: 'metadata.extensions.buyerEndpointId',
value: buyerEndpointId,
source: 'PEPPOL'
});
}
} else if (this.isPeppolB2G(invoice)) {
// Endpoint ID is mandatory for B2G
results.push({
ruleId: 'PEPPOL-T002',
severity: 'error',
message: 'Buyer endpoint ID is mandatory for PEPPOL B2G invoices',
field: 'metadata.extensions.buyerEndpointId',
source: 'PEPPOL'
});
}
return results;
}
/**
* Validate endpoint ID format
*/
private isValidEndpointId(endpointId: string): boolean {
// PEPPOL endpoint ID format: scheme:identifier
// Common schemes: 0088 (GLN), 0192 (Norwegian org), 9906 (IT VAT), etc.
const endpointPattern = /^[0-9]{4}:[A-Za-z0-9\-._]+$/;
// Special validation for GLN (0088)
if (endpointId.startsWith('0088:')) {
const gln = endpointId.substring(5);
// GLN should be 13 digits
if (!/^\d{13}$/.test(gln)) {
return false;
}
// Validate GLN check digit
return this.validateGLNCheckDigit(gln);
}
return endpointPattern.test(endpointId);
}
/**
* Validate GLN check digit using modulo 10
*/
private validateGLNCheckDigit(gln: string): boolean {
if (gln.length !== 13) return false;
let sum = 0;
for (let i = 0; i < 12; i++) {
const digit = parseInt(gln[i], 10);
sum += digit * (i % 2 === 0 ? 1 : 3);
}
const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === parseInt(gln[12], 10);
}
/**
* Validate Document Type ID
* PEPPOL-T003
*/
private validateDocumentTypeId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const documentTypeId = invoice.metadata?.extensions?.documentTypeId ||
invoice.metadata?.extensions?.peppolDocumentType;
if (!documentTypeId && this.isPeppolB2G(invoice)) {
results.push({
ruleId: 'PEPPOL-T003',
severity: 'error',
message: 'Document type ID is mandatory for PEPPOL invoices',
field: 'metadata.extensions.documentTypeId',
source: 'PEPPOL'
});
} else if (documentTypeId) {
// Validate against known PEPPOL document types
const validDocumentTypes = [
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1',
'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1',
// Add more valid document types as needed
];
if (!validDocumentTypes.some(type => documentTypeId.includes(type))) {
results.push({
ruleId: 'PEPPOL-T003',
severity: 'warning',
message: 'Document type ID may not be a valid PEPPOL document type',
field: 'metadata.extensions.documentTypeId',
value: documentTypeId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate Process ID
* PEPPOL-T004
*/
private validateProcessId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const processId = invoice.metadata?.extensions?.processId ||
invoice.metadata?.extensions?.peppolProcessId;
if (!processId && this.isPeppolB2G(invoice)) {
results.push({
ruleId: 'PEPPOL-T004',
severity: 'error',
message: 'Process ID is mandatory for PEPPOL invoices',
field: 'metadata.extensions.processId',
source: 'PEPPOL'
});
} else if (processId) {
// Validate against known PEPPOL processes
const validProcessIds = [
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
// Legacy process IDs
'urn:www.cenbii.eu:profile:bii05:ver2.0',
'urn:www.cenbii.eu:profile:bii04:ver2.0'
];
if (!validProcessIds.includes(processId)) {
results.push({
ruleId: 'PEPPOL-T004',
severity: 'warning',
message: 'Process ID may not be a valid PEPPOL process',
field: 'metadata.extensions.processId',
value: processId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate Party Identification Schemes
* PEPPOL-T005, PEPPOL-T006
*/
private validatePartyIdentification(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Validate seller party identification
if (invoice.from?.type === 'company') {
const company = invoice.from as any;
const partyId = company.registrationDetails?.peppolPartyId ||
company.registrationDetails?.partyIdentification;
if (partyId && partyId.schemeId) {
if (!this.isValidSchemeId(partyId.schemeId)) {
results.push({
ruleId: 'PEPPOL-T005',
severity: 'warning',
message: 'Seller party identification scheme may not be valid',
field: 'from.registrationDetails.partyIdentification.schemeId',
value: partyId.schemeId,
source: 'PEPPOL'
});
}
}
}
// Validate buyer party identification
const buyerPartyId = invoice.metadata?.extensions?.buyerPartyId;
if (buyerPartyId && buyerPartyId.schemeId) {
if (!this.isValidSchemeId(buyerPartyId.schemeId)) {
results.push({
ruleId: 'PEPPOL-T006',
severity: 'warning',
message: 'Buyer party identification scheme may not be valid',
field: 'metadata.extensions.buyerPartyId.schemeId',
value: buyerPartyId.schemeId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate scheme IDs against PEPPOL code list
*/
private isValidSchemeId(schemeId: string): boolean {
// PEPPOL Party Identifier Scheme (subset of ISO 6523 ICD list)
const validSchemes = [
'0002', // System Information et Repertoire des Entreprise et des Etablissements (SIRENE)
'0007', // Organisationsnummer (Swedish legal entities)
'0009', // SIRET
'0037', // LY-tunnus (Finnish business ID)
'0060', // DUNS number
'0088', // EAN Location Code (GLN)
'0096', // VIOC (Danish CVR)
'0097', // Danish Ministry of the Interior and Health
'0106', // Netherlands Chamber of Commerce
'0130', // Direktoratet for forvaltning og IKT (DIFI)
'0135', // IT:SIA
'0142', // IT:SECETI
'0184', // Danish CVR
'0190', // Dutch Originator's Identification Number
'0191', // Centre of Registers and Information Systems of the Ministry of Justice (Estonia)
'0192', // Norwegian Legal Entity
'0193', // UBL.BE party identifier
'0195', // Singapore UEN
'0196', // Kennitala (Iceland)
'0198', // ERSTORG
'0199', // Legal Entity Identifier (LEI)
'0200', // Legal entity code (Lithuania)
'0201', // CODICE UNIVOCO UNITÀ ORGANIZZATIVA
'0204', // German Leitweg-ID
'0208', // Belgian enterprise number
'0209', // GS1 identification keys
'0210', // CODICE FISCALE
'0211', // PARTITA IVA
'0212', // Finnish Organization Number
'0213', // Finnish VAT number
'9901', // Danish CVR
'9902', // Danish SE
'9904', // German VAT number
'9905', // German Leitweg ID
'9906', // IT:VAT
'9907', // IT:CF
'9910', // HU:VAT
'9914', // AT:VAT
'9915', // AT:GOV
'9917', // Netherlands OIN
'9918', // IS:KT
'9919', // IS company code
'9920', // ES:VAT
'9922', // AD:VAT
'9923', // AL:VAT
'9924', // BA:VAT
'9925', // BE:VAT
'9926', // BG:VAT
'9927', // CH:VAT
'9928', // CY:VAT
'9929', // CZ:VAT
'9930', // DE:VAT
'9931', // EE:VAT
'9932', // GB:VAT
'9933', // GR:VAT
'9934', // HR:VAT
'9935', // IE:VAT
'9936', // LI:VAT
'9937', // LT:VAT
'9938', // LU:VAT
'9939', // LV:VAT
'9940', // MC:VAT
'9941', // ME:VAT
'9942', // MK:VAT
'9943', // MT:VAT
'9944', // NL:VAT
'9945', // PL:VAT
'9946', // PT:VAT
'9947', // RO:VAT
'9948', // RS:VAT
'9949', // SI:VAT
'9950', // SK:VAT
'9951', // SM:VAT
'9952', // TR:VAT
'9953', // VA:VAT
'9955', // SE:VAT
'9956', // BE:CBE
'9957', // FR:VAT
'9958', // German Leitweg ID
];
return validSchemes.includes(schemeId);
}
/**
* Validate PEPPOL-specific business rules
*/
private validatePeppolBusinessRules(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// PEPPOL-B-01: Invoice must have a buyer reference or purchase order reference
const purchaseOrderRef = invoice.metadata?.extensions?.purchaseOrderReference;
if (!invoice.metadata?.buyerReference && !purchaseOrderRef) {
results.push({
ruleId: 'PEPPOL-B-01',
severity: 'error',
message: 'Invoice must have either a buyer reference (BT-10) or purchase order reference (BT-13)',
field: 'metadata.buyerReference',
source: 'PEPPOL'
});
}
// PEPPOL-B-02: Seller electronic address is mandatory
const sellerEmail = invoice.from?.type === 'company' ?
(invoice.from as any).contact?.email :
(invoice.from as any)?.email;
if (!sellerEmail) {
results.push({
ruleId: 'PEPPOL-B-02',
severity: 'warning',
message: 'Seller electronic address (email) is recommended for PEPPOL invoices',
field: 'from.contact.email',
source: 'PEPPOL'
});
}
// PEPPOL-B-03: Item standard identifier
if (invoice.items && invoice.items.length > 0) {
invoice.items.forEach((item, index) => {
const itemId = (item as any).standardItemIdentification;
if (!itemId) {
results.push({
ruleId: 'PEPPOL-B-03',
severity: 'info',
message: `Item ${index + 1} should have a standard item identification (GTIN, EAN, etc.)`,
field: `items[${index}].standardItemIdentification`,
source: 'PEPPOL'
});
} else if (itemId.schemeId === '0160' && !this.isValidGTIN(itemId.id)) {
// Validate GTIN if scheme is 0160
results.push({
ruleId: 'PEPPOL-B-03',
severity: 'error',
message: `Item ${index + 1} has invalid GTIN`,
field: `items[${index}].standardItemIdentification.id`,
value: itemId.id,
source: 'PEPPOL'
});
}
});
}
// PEPPOL-B-04: Payment means code must be from UNCL4461
const paymentMeansCode = invoice.metadata?.extensions?.paymentMeans?.paymentMeansCode;
if (paymentMeansCode) {
const validPaymentMeans = [
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10',
'11', '12', '13', '14', '15', '16', '17', '18', '19', '20',
'21', '22', '23', '24', '25', '26', '27', '28', '29', '30',
'31', '32', '33', '34', '35', '36', '37', '38', '39', '40',
'41', '42', '43', '44', '45', '46', '47', '48', '49', '50',
'51', '52', '53', '54', '55', '56', '57', '58', '59', '60',
'61', '62', '63', '64', '65', '66', '67', '68', '70', '74',
'75', '76', '77', '78', '91', '92', '93', '94', '95', '96', '97', 'ZZZ'
];
if (!validPaymentMeans.includes(paymentMeansCode)) {
results.push({
ruleId: 'PEPPOL-B-04',
severity: 'error',
message: 'Payment means code must be from UNCL4461 code list',
field: 'metadata.extensions.paymentMeans.paymentMeansCode',
value: paymentMeansCode,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate GTIN (Global Trade Item Number)
*/
private isValidGTIN(gtin: string): boolean {
// GTIN can be 8, 12, 13, or 14 digits
if (!/^(\d{8}|\d{12}|\d{13}|\d{14})$/.test(gtin)) {
return false;
}
// Validate check digit
const digits = gtin.split('').map(d => parseInt(d, 10));
const checkDigit = digits[digits.length - 1];
let sum = 0;
for (let i = digits.length - 2; i >= 0; i--) {
const multiplier = ((digits.length - 2 - i) % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
const calculatedCheck = (10 - (sum % 10)) % 10;
return calculatedCheck === checkDigit;
}
/**
* Validate scheme IDs used in the invoice
*/
private validateSchemeIds(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check tax scheme ID
const taxSchemeId = invoice.metadata?.extensions?.taxDetails?.[0]?.taxScheme?.id;
if (taxSchemeId && taxSchemeId !== 'VAT') {
results.push({
ruleId: 'PEPPOL-S-01',
severity: 'warning',
message: 'Tax scheme ID should be "VAT" for PEPPOL invoices',
field: 'metadata.extensions.taxDetails[0].taxScheme.id',
value: taxSchemeId,
source: 'PEPPOL'
});
}
// Check currency code is from ISO 4217
if (invoice.currency) {
// This is already validated by CodeListValidator, but we can add PEPPOL-specific check
if (!['EUR', 'USD', 'GBP', 'SEK', 'NOK', 'DKK', 'CHF', 'PLN', 'CZK', 'HUF'].includes(invoice.currency)) {
results.push({
ruleId: 'PEPPOL-S-02',
severity: 'info',
message: `Currency ${invoice.currency} is uncommon in PEPPOL network`,
field: 'currency',
value: invoice.currency,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate transport protocol requirements
*/
private validateTransportProtocol(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if transport protocol is specified
const transportProtocol = invoice.metadata?.extensions?.transportProtocol;
if (transportProtocol) {
const validProtocols = ['AS2', 'AS4'];
if (!validProtocols.includes(transportProtocol)) {
results.push({
ruleId: 'PEPPOL-P-01',
severity: 'warning',
message: 'Transport protocol should be AS2 or AS4 for PEPPOL',
field: 'metadata.extensions.transportProtocol',
value: transportProtocol,
source: 'PEPPOL'
});
}
}
// Check if SMP lookup is possible
const sellerEndpointId = invoice.metadata?.extensions?.sellerEndpointId;
if (sellerEndpointId && !invoice.metadata?.extensions?.smpRegistered) {
results.push({
ruleId: 'PEPPOL-P-02',
severity: 'info',
message: 'Seller endpoint should be registered in PEPPOL SMP for discovery',
field: 'metadata.extensions.smpRegistered',
source: 'PEPPOL'
});
}
return results;
}
/**
* Check if invoice is B2G (Business to Government)
*/
private isPeppolB2G(invoice: EInvoice): boolean {
// Check if buyer has government indicators
const buyerSchemeId = invoice.metadata?.extensions?.buyerPartyId?.schemeId;
const buyerCategory = invoice.metadata?.extensions?.buyerCategory;
// Government scheme IDs often include specific codes
const governmentSchemes = ['0204', '9905', '0197', '0215'];
// Check various indicators for government entity
return buyerCategory === 'government' ||
(buyerSchemeId && governmentSchemes.includes(buyerSchemeId)) ||
invoice.metadata?.extensions?.isB2G === true;
}
}

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();
}
}