/** * 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 { const model = this.adapter.toSemanticModel(invoice); const mapping = new Map(); // 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; } }