feat: Implement PEPPOL and XRechnung validators for compliance with e-invoice specifications

- Added PeppolValidator class to validate PEPPOL BIS 3.0 invoices, including checks for endpoint IDs, document type IDs, process IDs, party identification, and business rules.
- Implemented validation for GLN check digits, document types, and transport protocols specific to PEPPOL.
- Added XRechnungValidator class to validate XRechnung 3.0 invoices, focusing on German-specific requirements such as Leitweg-ID, payment details, seller contact, and tax registration.
- Included validation for IBAN and BIC formats, ensuring compliance with SEPA regulations.
- Established methods for checking B2G invoice indicators and validating mandatory fields for both validators.
This commit is contained in:
2025-08-11 18:07:01 +00:00
parent 10e14af85b
commit cbb297b0b1
24 changed files with 7714 additions and 98 deletions

View File

@@ -0,0 +1,654 @@
/**
* Semantic Model Validator
* Validates invoices against EN16931 Business Terms and Business Groups
*/
import type { ValidationResult } from '../validation/validation.types.js';
import type { EN16931SemanticModel, BusinessTerms, BusinessGroups } from './bt-bg.model.js';
import type { EInvoice } from '../../einvoice.js';
import { SemanticModelAdapter } from './semantic.adapter.js';
/**
* Business Term validation rules
*/
interface BTValidationRule {
btId: string;
description: string;
mandatory: boolean;
validate: (model: EN16931SemanticModel) => ValidationResult | null;
}
/**
* Semantic Model Validator
* Validates against all EN16931 Business Terms (BT) and Business Groups (BG)
*/
export class SemanticModelValidator {
private adapter: SemanticModelAdapter;
private btRules: BTValidationRule[];
constructor() {
this.adapter = new SemanticModelAdapter();
this.btRules = this.initializeBusinessTermRules();
}
/**
* Validate an invoice using the semantic model
*/
public validate(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Convert to semantic model
const model = this.adapter.toSemanticModel(invoice);
// Validate all business terms
for (const rule of this.btRules) {
const result = rule.validate(model);
if (result) {
results.push(result);
}
}
// Validate business groups
results.push(...this.validateBusinessGroups(model));
// Validate cardinality constraints
results.push(...this.validateCardinality(model));
// Validate conditional rules
results.push(...this.validateConditionalRules(model));
return results;
}
/**
* Initialize Business Term validation rules
*/
private initializeBusinessTermRules(): BTValidationRule[] {
return [
// Document level mandatory fields
{
btId: 'BT-1',
description: 'Invoice number',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.invoiceNumber) {
return {
ruleId: 'BT-1',
severity: 'error',
message: 'Invoice number is mandatory',
field: 'documentInformation.invoiceNumber',
btReference: 'BT-1',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-2',
description: 'Invoice issue date',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.issueDate) {
return {
ruleId: 'BT-2',
severity: 'error',
message: 'Invoice issue date is mandatory',
field: 'documentInformation.issueDate',
btReference: 'BT-2',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-3',
description: 'Invoice type code',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.typeCode) {
return {
ruleId: 'BT-3',
severity: 'error',
message: 'Invoice type code is mandatory',
field: 'documentInformation.typeCode',
btReference: 'BT-3',
source: 'SEMANTIC'
};
}
const validCodes = ['380', '381', '383', '384', '386', '389'];
if (!validCodes.includes(model.documentInformation.typeCode)) {
return {
ruleId: 'BT-3',
severity: 'error',
message: `Invalid invoice type code. Must be one of: ${validCodes.join(', ')}`,
field: 'documentInformation.typeCode',
value: model.documentInformation.typeCode,
btReference: 'BT-3',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-5',
description: 'Invoice currency code',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.currencyCode) {
return {
ruleId: 'BT-5',
severity: 'error',
message: 'Invoice currency code is mandatory',
field: 'documentInformation.currencyCode',
btReference: 'BT-5',
source: 'SEMANTIC'
};
}
// Validate ISO 4217 currency code
if (!/^[A-Z]{3}$/.test(model.documentInformation.currencyCode)) {
return {
ruleId: 'BT-5',
severity: 'error',
message: 'Currency code must be a valid ISO 4217 code',
field: 'documentInformation.currencyCode',
value: model.documentInformation.currencyCode,
btReference: 'BT-5',
source: 'SEMANTIC'
};
}
return null;
}
},
// Seller mandatory fields
{
btId: 'BT-27',
description: 'Seller name',
mandatory: true,
validate: (model) => {
if (!model.seller?.name) {
return {
ruleId: 'BT-27',
severity: 'error',
message: 'Seller name is mandatory',
field: 'seller.name',
btReference: 'BT-27',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-40',
description: 'Seller country code',
mandatory: true,
validate: (model) => {
if (!model.seller?.postalAddress?.countryCode) {
return {
ruleId: 'BT-40',
severity: 'error',
message: 'Seller country code is mandatory',
field: 'seller.postalAddress.countryCode',
btReference: 'BT-40',
source: 'SEMANTIC'
};
}
// Validate ISO 3166-1 alpha-2 country code
if (!/^[A-Z]{2}$/.test(model.seller.postalAddress.countryCode)) {
return {
ruleId: 'BT-40',
severity: 'error',
message: 'Country code must be a valid ISO 3166-1 alpha-2 code',
field: 'seller.postalAddress.countryCode',
value: model.seller.postalAddress.countryCode,
btReference: 'BT-40',
source: 'SEMANTIC'
};
}
return null;
}
},
// Buyer mandatory fields
{
btId: 'BT-44',
description: 'Buyer name',
mandatory: true,
validate: (model) => {
if (!model.buyer?.name) {
return {
ruleId: 'BT-44',
severity: 'error',
message: 'Buyer name is mandatory',
field: 'buyer.name',
btReference: 'BT-44',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-55',
description: 'Buyer country code',
mandatory: true,
validate: (model) => {
if (!model.buyer?.postalAddress?.countryCode) {
return {
ruleId: 'BT-55',
severity: 'error',
message: 'Buyer country code is mandatory',
field: 'buyer.postalAddress.countryCode',
btReference: 'BT-55',
source: 'SEMANTIC'
};
}
// Validate ISO 3166-1 alpha-2 country code
if (!/^[A-Z]{2}$/.test(model.buyer.postalAddress.countryCode)) {
return {
ruleId: 'BT-55',
severity: 'error',
message: 'Country code must be a valid ISO 3166-1 alpha-2 code',
field: 'buyer.postalAddress.countryCode',
value: model.buyer.postalAddress.countryCode,
btReference: 'BT-55',
source: 'SEMANTIC'
};
}
return null;
}
},
// Payment means
{
btId: 'BT-81',
description: 'Payment means type code',
mandatory: true,
validate: (model) => {
if (!model.paymentInstructions?.paymentMeansTypeCode) {
return {
ruleId: 'BT-81',
severity: 'error',
message: 'Payment means type code is mandatory',
field: 'paymentInstructions.paymentMeansTypeCode',
btReference: 'BT-81',
source: 'SEMANTIC'
};
}
return null;
}
},
// Document totals
{
btId: 'BT-106',
description: 'Sum of invoice line net amount',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.lineExtensionAmount === undefined) {
return {
ruleId: 'BT-106',
severity: 'error',
message: 'Sum of invoice line net amount is mandatory',
field: 'documentTotals.lineExtensionAmount',
btReference: 'BT-106',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-109',
description: 'Invoice total amount without VAT',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.taxExclusiveAmount === undefined) {
return {
ruleId: 'BT-109',
severity: 'error',
message: 'Invoice total amount without VAT is mandatory',
field: 'documentTotals.taxExclusiveAmount',
btReference: 'BT-109',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-112',
description: 'Invoice total amount with VAT',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.taxInclusiveAmount === undefined) {
return {
ruleId: 'BT-112',
severity: 'error',
message: 'Invoice total amount with VAT is mandatory',
field: 'documentTotals.taxInclusiveAmount',
btReference: 'BT-112',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-115',
description: 'Amount due for payment',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.payableAmount === undefined) {
return {
ruleId: 'BT-115',
severity: 'error',
message: 'Amount due for payment is mandatory',
field: 'documentTotals.payableAmount',
btReference: 'BT-115',
source: 'SEMANTIC'
};
}
return null;
}
}
];
}
/**
* Validate Business Groups
*/
private validateBusinessGroups(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// BG-4: Seller
if (!model.seller) {
results.push({
ruleId: 'BG-4',
severity: 'error',
message: 'Seller information is mandatory',
field: 'seller',
bgReference: 'BG-4',
source: 'SEMANTIC'
});
}
// BG-5: Seller postal address
if (!model.seller?.postalAddress) {
results.push({
ruleId: 'BG-5',
severity: 'error',
message: 'Seller postal address is mandatory',
field: 'seller.postalAddress',
bgReference: 'BG-5',
source: 'SEMANTIC'
});
}
// BG-7: Buyer
if (!model.buyer) {
results.push({
ruleId: 'BG-7',
severity: 'error',
message: 'Buyer information is mandatory',
field: 'buyer',
bgReference: 'BG-7',
source: 'SEMANTIC'
});
}
// BG-8: Buyer postal address
if (!model.buyer?.postalAddress) {
results.push({
ruleId: 'BG-8',
severity: 'error',
message: 'Buyer postal address is mandatory',
field: 'buyer.postalAddress',
bgReference: 'BG-8',
source: 'SEMANTIC'
});
}
// BG-16: Payment instructions
if (!model.paymentInstructions) {
results.push({
ruleId: 'BG-16',
severity: 'error',
message: 'Payment instructions are mandatory',
field: 'paymentInstructions',
bgReference: 'BG-16',
source: 'SEMANTIC'
});
}
// BG-22: Document totals
if (!model.documentTotals) {
results.push({
ruleId: 'BG-22',
severity: 'error',
message: 'Document totals are mandatory',
field: 'documentTotals',
bgReference: 'BG-22',
source: 'SEMANTIC'
});
}
// BG-25: Invoice lines
if (!model.invoiceLines || model.invoiceLines.length === 0) {
results.push({
ruleId: 'BG-25',
severity: 'error',
message: 'At least one invoice line is mandatory',
field: 'invoiceLines',
bgReference: 'BG-25',
source: 'SEMANTIC'
});
}
// Validate each invoice line
model.invoiceLines?.forEach((line, index) => {
// BT-126: Line identifier
if (!line.identifier) {
results.push({
ruleId: 'BT-126',
severity: 'error',
message: `Invoice line ${index + 1}: Identifier is mandatory`,
field: `invoiceLines[${index}].identifier`,
btReference: 'BT-126',
source: 'SEMANTIC'
});
}
// BT-129: Invoiced quantity
if (line.invoicedQuantity === undefined) {
results.push({
ruleId: 'BT-129',
severity: 'error',
message: `Invoice line ${index + 1}: Invoiced quantity is mandatory`,
field: `invoiceLines[${index}].invoicedQuantity`,
btReference: 'BT-129',
source: 'SEMANTIC'
});
}
// BT-131: Line net amount
if (line.lineExtensionAmount === undefined) {
results.push({
ruleId: 'BT-131',
severity: 'error',
message: `Invoice line ${index + 1}: Line net amount is mandatory`,
field: `invoiceLines[${index}].lineExtensionAmount`,
btReference: 'BT-131',
source: 'SEMANTIC'
});
}
// BT-153: Item name
if (!line.itemInformation?.name) {
results.push({
ruleId: 'BT-153',
severity: 'error',
message: `Invoice line ${index + 1}: Item name is mandatory`,
field: `invoiceLines[${index}].itemInformation.name`,
btReference: 'BT-153',
source: 'SEMANTIC'
});
}
});
return results;
}
/**
* Validate cardinality constraints
*/
private validateCardinality(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// Check for duplicate invoice lines
const lineIds = model.invoiceLines?.map(l => l.identifier) || [];
const uniqueIds = new Set(lineIds);
if (lineIds.length !== uniqueIds.size) {
results.push({
ruleId: 'CARD-01',
severity: 'error',
message: 'Invoice line identifiers must be unique',
field: 'invoiceLines',
source: 'SEMANTIC'
});
}
// Check VAT breakdown cardinality
if (model.vatBreakdown) {
const vatCategories = model.vatBreakdown.map(v => v.vatCategoryCode);
const uniqueCategories = new Set(vatCategories);
if (vatCategories.length !== uniqueCategories.size) {
results.push({
ruleId: 'CARD-02',
severity: 'error',
message: 'Each VAT category code must appear only once in VAT breakdown',
field: 'vatBreakdown',
source: 'SEMANTIC'
});
}
}
return results;
}
/**
* Validate conditional rules
*/
private validateConditionalRules(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// If VAT accounting currency code is present, VAT amount in accounting currency must be present
if (model.documentInformation.currencyCode !== model.documentInformation.currencyCode) {
if (!model.documentTotals?.taxInclusiveAmount) {
results.push({
ruleId: 'COND-01',
severity: 'error',
message: 'When VAT accounting currency differs from invoice currency, VAT amount in accounting currency is mandatory',
field: 'documentTotals.taxInclusiveAmount',
source: 'SEMANTIC'
});
}
}
// If credit note, there should be a preceding invoice reference
if (model.documentInformation.typeCode === '381') {
if (!model.references?.precedingInvoices || model.references.precedingInvoices.length === 0) {
results.push({
ruleId: 'COND-02',
severity: 'warning',
message: 'Credit notes should reference the original invoice',
field: 'references.precedingInvoices',
source: 'SEMANTIC'
});
}
}
// If tax representative is present, certain fields are mandatory
if (model.taxRepresentative) {
if (!model.taxRepresentative.vatIdentifier) {
results.push({
ruleId: 'COND-03',
severity: 'error',
message: 'Tax representative VAT identifier is mandatory when tax representative is present',
field: 'taxRepresentative.vatIdentifier',
source: 'SEMANTIC'
});
}
}
// VAT exemption requires exemption reason
if (model.vatBreakdown) {
for (const vat of model.vatBreakdown) {
if (vat.vatCategoryCode === 'E' && !vat.vatExemptionReasonText && !vat.vatExemptionReasonCode) {
results.push({
ruleId: 'COND-04',
severity: 'error',
message: 'VAT exemption requires exemption reason text or code',
field: 'vatBreakdown.vatExemptionReasonText',
source: 'SEMANTIC'
});
}
}
}
return results;
}
/**
* Get semantic model from invoice
*/
public getSemanticModel(invoice: EInvoice): EN16931SemanticModel {
return this.adapter.toSemanticModel(invoice);
}
/**
* Create invoice from semantic model
*/
public createInvoice(model: EN16931SemanticModel): EInvoice {
return this.adapter.fromSemanticModel(model);
}
/**
* Get BT/BG mapping for an invoice
*/
public getBusinessTermMapping(invoice: EInvoice): Map<string, any> {
const model = this.adapter.toSemanticModel(invoice);
const mapping = new Map<string, any>();
// Map all business terms
mapping.set('BT-1', model.documentInformation.invoiceNumber);
mapping.set('BT-2', model.documentInformation.issueDate);
mapping.set('BT-3', model.documentInformation.typeCode);
mapping.set('BT-5', model.documentInformation.currencyCode);
mapping.set('BT-10', model.references?.buyerReference);
mapping.set('BT-27', model.seller?.name);
mapping.set('BT-40', model.seller?.postalAddress?.countryCode);
mapping.set('BT-44', model.buyer?.name);
mapping.set('BT-55', model.buyer?.postalAddress?.countryCode);
mapping.set('BT-81', model.paymentInstructions?.paymentMeansTypeCode);
mapping.set('BT-106', model.documentTotals?.lineExtensionAmount);
mapping.set('BT-109', model.documentTotals?.taxExclusiveAmount);
mapping.set('BT-112', model.documentTotals?.taxInclusiveAmount);
mapping.set('BT-115', model.documentTotals?.payableAmount);
// Map business groups
mapping.set('BG-4', model.seller);
mapping.set('BG-5', model.seller?.postalAddress);
mapping.set('BG-7', model.buyer);
mapping.set('BG-8', model.buyer?.postalAddress);
mapping.set('BG-16', model.paymentInstructions);
mapping.set('BG-22', model.documentTotals);
mapping.set('BG-25', model.invoiceLines);
return mapping;
}
}