216 lines
9.3 KiB
TypeScript
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;
|
||
|
}
|
||
|
}
|