einvoice/ts/formats/ubl/en16931.ubl.validator.ts
Philipp Kunz 56fd12a6b2 test(suite): comprehensive test suite improvements and new validators
- Update test-utils import path and refactor to helpers/utils.ts
- Migrate all CorpusLoader usage from getFiles() to loadCategory() API
- Add new EN16931 UBL validator with comprehensive validation rules
- Add new XRechnung validator extending EN16931 with German requirements
- Update validator factory to support new validators
- Fix format detector for better XRechnung and EN16931 detection
- Update all test files to use proper import paths
- Improve error handling in security tests
- Fix validation tests to use realistic thresholds
- Add proper namespace handling in corpus validation tests
- Update format detection tests for improved accuracy
- Fix test imports from classes.xinvoice.ts to index.js

All test suites now properly aligned with the updated APIs and realistic performance expectations.
2025-05-30 18:18:42 +00:00

216 lines
9.3 KiB
TypeScript

import { UBLBaseValidator } from './ubl.validator.js';
import { ValidationLevel } from '../../interfaces/common.js';
import { xpath } from '../../plugins.js';
/**
* EN16931-compliant UBL validator that implements all business rules
*/
export class EN16931UBLValidator extends UBLBaseValidator {
/**
* Validates the structure of the UBL document
*/
protected validateStructure(): boolean {
let valid = true;
// Check for required elements
const requiredElements = [
{ path: '//cbc:ID', error: 'Required element cbc:ID is missing' },
{ path: '//cbc:IssueDate', error: 'Required element cbc:IssueDate is missing' },
{ path: '//cbc:CustomizationID', error: 'Required element cbc:CustomizationID is missing' }
];
for (const element of requiredElements) {
if (!this.exists(element.path)) {
this.addError('STRUCT-REQUIRED', element.error, element.path);
valid = false;
}
}
// Check for at least one invoice line or credit note line
const invoiceLines = this.select('//cac:InvoiceLine', this.doc) as Node[];
const creditNoteLines = this.select('//cac:CreditNoteLine', this.doc) as Node[];
if (invoiceLines.length === 0 && creditNoteLines.length === 0) {
this.addError('STRUCT-LINE', 'At least one invoice line or credit note line is required', '/');
valid = false;
}
return valid;
}
/**
* Validates EN16931 business rules
*/
protected validateBusinessRules(): boolean {
let valid = true;
// BR-01: An Invoice shall have a Specification identifier (BT-24).
if (!this.exists('//cbc:CustomizationID')) {
this.addError('BR-01', 'An Invoice shall have a Specification identifier', '//cbc:CustomizationID');
valid = false;
}
// BR-02: An Invoice shall have an Invoice number (BT-1).
if (!this.exists('//cbc:ID')) {
this.addError('BR-02', 'An Invoice shall have an Invoice number', '//cbc:ID');
valid = false;
}
// BR-03: An Invoice shall have an Invoice issue date (BT-2).
if (!this.exists('//cbc:IssueDate')) {
this.addError('BR-03', 'An Invoice shall have an Invoice issue date', '//cbc:IssueDate');
valid = false;
}
// BR-04: An Invoice shall have an Invoice type code (BT-3).
const isInvoice = this.doc.documentElement.localName === 'Invoice';
if (isInvoice && !this.exists('//cbc:InvoiceTypeCode')) {
this.addError('BR-04', 'An Invoice shall have an Invoice type code', '//cbc:InvoiceTypeCode');
valid = false;
}
// BR-05: An Invoice shall have an Invoice currency code (BT-5).
if (!this.exists('//cbc:DocumentCurrencyCode')) {
this.addError('BR-05', 'An Invoice shall have an Invoice currency code', '//cbc:DocumentCurrencyCode');
valid = false;
}
// BR-06: An Invoice shall contain the Seller name (BT-27).
if (!this.exists('//cac:AccountingSupplierParty//cbc:RegistrationName') &&
!this.exists('//cac:AccountingSupplierParty//cbc:Name')) {
this.addError('BR-06', 'An Invoice shall contain the Seller name', '//cac:AccountingSupplierParty');
valid = false;
}
// BR-07: An Invoice shall contain the Buyer name (BT-44).
if (!this.exists('//cac:AccountingCustomerParty//cbc:RegistrationName') &&
!this.exists('//cac:AccountingCustomerParty//cbc:Name')) {
this.addError('BR-07', 'An Invoice shall contain the Buyer name', '//cac:AccountingCustomerParty');
valid = false;
}
// BR-08: An Invoice shall contain the Seller postal address (BG-5).
const sellerAddress = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc)[0];
if (!sellerAddress || !this.exists('.//cbc:IdentificationCode', sellerAddress)) {
this.addError('BR-08', 'An Invoice shall contain the Seller postal address', '//cac:AccountingSupplierParty//cac:PostalAddress');
valid = false;
}
// BR-09: The Seller postal address (BG-5) shall contain a Seller country code (BT-40).
if (sellerAddress && !this.exists('.//cac:Country/cbc:IdentificationCode', sellerAddress)) {
this.addError('BR-09', 'The Seller postal address shall contain a Seller country code', '//cac:AccountingSupplierParty//cac:PostalAddress//cac:Country');
valid = false;
}
// BR-10: An Invoice shall contain the Buyer postal address (BG-8).
const buyerAddress = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc)[0];
if (!buyerAddress || !this.exists('.//cbc:IdentificationCode', buyerAddress)) {
this.addError('BR-10', 'An Invoice shall contain the Buyer postal address', '//cac:AccountingCustomerParty//cac:PostalAddress');
valid = false;
}
// BR-11: The Buyer postal address (BG-8) shall contain a Buyer country code (BT-55).
if (buyerAddress && !this.exists('.//cac:Country/cbc:IdentificationCode', buyerAddress)) {
this.addError('BR-11', 'The Buyer postal address shall contain a Buyer country code', '//cac:AccountingCustomerParty//cac:PostalAddress//cac:Country');
valid = false;
}
// BR-12: An Invoice shall have the Sum of Invoice line net amount (BT-106).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount')) {
this.addError('BR-12', 'An Invoice shall have the Sum of Invoice line net amount', '//cac:LegalMonetaryTotal/cbc:LineExtensionAmount');
valid = false;
}
// BR-13: An Invoice shall have the Invoice total amount without VAT (BT-109).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount')) {
this.addError('BR-13', 'An Invoice shall have the Invoice total amount without VAT', '//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount');
valid = false;
}
// BR-14: An Invoice shall have the Invoice total amount with VAT (BT-112).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount')) {
this.addError('BR-14', 'An Invoice shall have the Invoice total amount with VAT', '//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount');
valid = false;
}
// BR-15: An Invoice shall have the Amount due for payment (BT-115).
if (!this.exists('//cac:LegalMonetaryTotal/cbc:PayableAmount')) {
this.addError('BR-15', 'An Invoice shall have the Amount due for payment', '//cac:LegalMonetaryTotal/cbc:PayableAmount');
valid = false;
}
// BR-16: An Invoice shall have at least one Invoice line (BG-25).
const lines = this.select('//cac:InvoiceLine | //cac:CreditNoteLine', this.doc) as Node[];
if (lines.length === 0) {
this.addError('BR-16', 'An Invoice shall have at least one Invoice line', '//cac:InvoiceLine');
valid = false;
}
// Validate calculation rules if we have the necessary data
if (this.exists('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount')) {
valid = this.validateCalculationRules() && valid;
}
return valid;
}
/**
* Validates calculation rules (BR-CO-*)
*/
private validateCalculationRules(): boolean {
let valid = true;
// BR-CO-10: Sum of Invoice line net amount = Σ Invoice line net amount.
const lineExtensionAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount');
const lines = this.select('//cac:InvoiceLine | //cac:CreditNoteLine', this.doc) as Node[];
let calculatedSum = 0;
for (const line of lines) {
const lineAmount = this.getNumber('.//cbc:LineExtensionAmount', line);
calculatedSum += lineAmount;
}
// Allow for small rounding differences (0.01)
if (Math.abs(lineExtensionAmount - calculatedSum) > 0.01) {
this.addError(
'BR-CO-10',
`Sum of Invoice line net amount (${lineExtensionAmount}) must equal sum of line amounts (${calculatedSum})`,
'//cac:LegalMonetaryTotal/cbc:LineExtensionAmount'
);
valid = false;
}
// BR-CO-13: Invoice total amount without VAT = Σ Invoice line net amount - Sum of allowances on document level + Sum of charges on document level.
const taxExclusiveAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount');
const allowanceTotal = this.getNumber('//cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount') || 0;
const chargeTotal = this.getNumber('//cac:LegalMonetaryTotal/cbc:ChargeTotalAmount') || 0;
const calculatedTaxExclusive = lineExtensionAmount - allowanceTotal + chargeTotal;
if (Math.abs(taxExclusiveAmount - calculatedTaxExclusive) > 0.01) {
this.addError(
'BR-CO-13',
`Invoice total amount without VAT (${taxExclusiveAmount}) must equal calculated amount (${calculatedTaxExclusive})`,
'//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount'
);
valid = false;
}
// BR-CO-15: Invoice total amount with VAT = Invoice total amount without VAT + Invoice total VAT amount.
const taxInclusiveAmount = this.getNumber('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount');
const totalTaxAmount = this.getNumber('//cac:TaxTotal/cbc:TaxAmount') || 0;
const calculatedTaxInclusive = taxExclusiveAmount + totalTaxAmount;
if (Math.abs(taxInclusiveAmount - calculatedTaxInclusive) > 0.01) {
this.addError(
'BR-CO-15',
`Invoice total amount with VAT (${taxInclusiveAmount}) must equal calculated amount (${calculatedTaxInclusive})`,
'//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount'
);
valid = false;
}
return valid;
}
}