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:
@@ -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) =>
|
||||
|
579
ts/formats/validation/facturx.validator.ts
Normal file
579
ts/formats/validation/facturx.validator.ts
Normal 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];
|
||||
}
|
||||
}
|
405
ts/formats/validation/integrated.validator.ts
Normal file
405
ts/formats/validation/integrated.validator.ts
Normal 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';
|
589
ts/formats/validation/peppol.validator.ts
Normal file
589
ts/formats/validation/peppol.validator.ts
Normal 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;
|
||||
}
|
||||
}
|
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