- 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.
654 lines
20 KiB
TypeScript
654 lines
20 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
} |