- Added validation types for EN16931 compliance in `validation.types.ts`, including interfaces for `ValidationResult`, `ValidationOptions`, and `ValidationReport`. - Introduced `VATCategoriesValidator` in `vat-categories.validator.ts` to validate VAT categories according to EN16931 rules, including detailed checks for standard, zero-rated, exempt, reverse charge, intra-community, export, and out-of-scope services. - Enhanced `IEInvoiceMetadata` interface in `en16931-metadata.ts` to include additional fields required for full standards compliance, such as delivery information, payment information, allowances, and charges. - Implemented helper methods for VAT calculations and validation logic to ensure accurate compliance with EN16931 standards.
553 lines
18 KiB
TypeScript
553 lines
18 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
|
|
import type { EInvoice } from '../../einvoice.js';
|
|
import { CurrencyCalculator, areMonetaryValuesEqual } from '../utils/currency.utils.js';
|
|
import type { ValidationResult, ValidationOptions } from './validation.types.js';
|
|
|
|
/**
|
|
* EN16931 Business Rules Validator
|
|
* Implements the full set of EN16931 business rules for invoice validation
|
|
*/
|
|
export class EN16931BusinessRulesValidator {
|
|
private results: ValidationResult[] = [];
|
|
private currencyCalculator?: CurrencyCalculator;
|
|
|
|
/**
|
|
* Validate an invoice against EN16931 business rules
|
|
*/
|
|
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
|
|
this.results = [];
|
|
|
|
// Initialize currency calculator if currency is available
|
|
if (invoice.currency) {
|
|
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
|
|
}
|
|
|
|
// Document level rules (BR-01 to BR-65)
|
|
this.validateDocumentRules(invoice);
|
|
|
|
// Calculation rules (BR-CO-*)
|
|
if (options.checkCalculations !== false) {
|
|
this.validateCalculationRules(invoice);
|
|
}
|
|
|
|
// VAT rules (BR-S-*, BR-Z-*, BR-E-*, BR-AE-*, BR-IC-*, BR-G-*, BR-O-*)
|
|
if (options.checkVAT !== false) {
|
|
this.validateVATRules(invoice);
|
|
}
|
|
|
|
// Line level rules (BR-21 to BR-30)
|
|
this.validateLineRules(invoice);
|
|
|
|
// Allowances and charges rules
|
|
if (options.checkAllowances !== false) {
|
|
this.validateAllowancesCharges(invoice);
|
|
}
|
|
|
|
return this.results;
|
|
}
|
|
|
|
/**
|
|
* Validate document level rules (BR-01 to BR-65)
|
|
*/
|
|
private validateDocumentRules(invoice: EInvoice): void {
|
|
// BR-01: An Invoice shall have a Specification identifier (BT-24)
|
|
if (!invoice.metadata?.customizationId) {
|
|
this.addError('BR-01', 'Invoice must have a Specification identifier (CustomizationID)', 'customizationId');
|
|
}
|
|
|
|
// BR-02: An Invoice shall have an Invoice number (BT-1)
|
|
if (!invoice.accountingDocId) {
|
|
this.addError('BR-02', 'Invoice must have an Invoice number', 'accountingDocId');
|
|
}
|
|
|
|
// BR-03: An Invoice shall have an Invoice issue date (BT-2)
|
|
if (!invoice.date) {
|
|
this.addError('BR-03', 'Invoice must have an issue date', 'date');
|
|
}
|
|
|
|
// BR-04: An Invoice shall have an Invoice type code (BT-3)
|
|
if (!invoice.accountingDocType) {
|
|
this.addError('BR-04', 'Invoice must have a type code', 'accountingDocType');
|
|
}
|
|
|
|
// BR-05: An Invoice shall have an Invoice currency code (BT-5)
|
|
if (!invoice.currency) {
|
|
this.addError('BR-05', 'Invoice must have a currency code', 'currency');
|
|
}
|
|
|
|
// BR-06: An Invoice shall contain the Seller name (BT-27)
|
|
if (!invoice.from?.name) {
|
|
this.addError('BR-06', 'Invoice must contain the Seller name', 'from.name');
|
|
}
|
|
|
|
// BR-07: An Invoice shall contain the Buyer name (BT-44)
|
|
if (!invoice.to?.name) {
|
|
this.addError('BR-07', 'Invoice must contain the Buyer name', 'to.name');
|
|
}
|
|
|
|
// BR-08: An Invoice shall contain the Seller postal address (BG-5)
|
|
if (!invoice.from?.address) {
|
|
this.addError('BR-08', 'Invoice must contain the Seller postal address', 'from.address');
|
|
}
|
|
|
|
// BR-09: The Seller postal address shall contain a Seller country code (BT-40)
|
|
if (!invoice.from?.address?.countryCode) {
|
|
this.addError('BR-09', 'Seller postal address must contain a country code', 'from.address.countryCode');
|
|
}
|
|
|
|
// BR-10: An Invoice shall contain the Buyer postal address (BG-8)
|
|
if (!invoice.to?.address) {
|
|
this.addError('BR-10', 'Invoice must contain the Buyer postal address', 'to.address');
|
|
}
|
|
|
|
// BR-11: The Buyer postal address shall contain a Buyer country code (BT-55)
|
|
if (!invoice.to?.address?.countryCode) {
|
|
this.addError('BR-11', 'Buyer postal address must contain a country code', 'to.address.countryCode');
|
|
}
|
|
|
|
// BR-16: An Invoice shall have at least one Invoice line (BG-25)
|
|
if (!invoice.items || invoice.items.length === 0) {
|
|
this.addError('BR-16', 'Invoice must have at least one invoice line', 'items');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate calculation rules (BR-CO-*)
|
|
*/
|
|
private validateCalculationRules(invoice: EInvoice): void {
|
|
if (!invoice.items || invoice.items.length === 0) return;
|
|
|
|
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
|
|
const calculatedLineTotal = this.calculateLineTotal(invoice.items);
|
|
const declaredLineTotal = invoice.totalNet || 0;
|
|
|
|
const isEqual = this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(calculatedLineTotal, declaredLineTotal)
|
|
: Math.abs(calculatedLineTotal - declaredLineTotal) < 0.01;
|
|
|
|
if (!isEqual) {
|
|
this.addError(
|
|
'BR-CO-10',
|
|
`Sum of line net amounts (${calculatedLineTotal.toFixed(2)}) does not match declared total (${declaredLineTotal.toFixed(2)})`,
|
|
'totalNet',
|
|
declaredLineTotal,
|
|
calculatedLineTotal
|
|
);
|
|
}
|
|
|
|
// BR-CO-11: Sum of allowances on document level
|
|
const documentAllowances = this.calculateDocumentAllowances(invoice);
|
|
|
|
// BR-CO-12: Sum of charges on document level
|
|
const documentCharges = this.calculateDocumentCharges(invoice);
|
|
|
|
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
|
|
const expectedTaxExclusive = calculatedLineTotal - documentAllowances + documentCharges;
|
|
const declaredTaxExclusive = invoice.totalNet || 0;
|
|
|
|
const isTaxExclusiveEqual = this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(expectedTaxExclusive, declaredTaxExclusive)
|
|
: Math.abs(expectedTaxExclusive - declaredTaxExclusive) < 0.01;
|
|
|
|
if (!isTaxExclusiveEqual) {
|
|
this.addError(
|
|
'BR-CO-13',
|
|
`Tax exclusive amount (${declaredTaxExclusive.toFixed(2)}) does not match calculation (${expectedTaxExclusive.toFixed(2)})`,
|
|
'totalNet',
|
|
declaredTaxExclusive,
|
|
expectedTaxExclusive
|
|
);
|
|
}
|
|
|
|
// BR-CO-14: Invoice total VAT amount = Σ(VAT category tax amount)
|
|
const calculatedVAT = this.calculateTotalVAT(invoice);
|
|
const declaredVAT = invoice.totalVat || 0;
|
|
|
|
const isVATEqual = this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(calculatedVAT, declaredVAT)
|
|
: Math.abs(calculatedVAT - declaredVAT) < 0.01;
|
|
|
|
if (!isVATEqual) {
|
|
this.addError(
|
|
'BR-CO-14',
|
|
`Total VAT (${declaredVAT.toFixed(2)}) does not match calculation (${calculatedVAT.toFixed(2)})`,
|
|
'totalVat',
|
|
declaredVAT,
|
|
calculatedVAT
|
|
);
|
|
}
|
|
|
|
// BR-CO-15: Invoice total with VAT = Invoice total without VAT + Invoice total VAT
|
|
const expectedGrossTotal = expectedTaxExclusive + calculatedVAT;
|
|
const declaredGrossTotal = invoice.totalGross || 0;
|
|
|
|
const isGrossEqual = this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(expectedGrossTotal, declaredGrossTotal)
|
|
: Math.abs(expectedGrossTotal - declaredGrossTotal) < 0.01;
|
|
|
|
if (!isGrossEqual) {
|
|
this.addError(
|
|
'BR-CO-15',
|
|
`Gross total (${declaredGrossTotal.toFixed(2)}) does not match calculation (${expectedGrossTotal.toFixed(2)})`,
|
|
'totalGross',
|
|
declaredGrossTotal,
|
|
expectedGrossTotal
|
|
);
|
|
}
|
|
|
|
// BR-CO-16: Amount due for payment = Invoice total with VAT - Paid amount
|
|
const paidAmount = invoice.metadata?.paidAmount || 0;
|
|
const expectedDueAmount = expectedGrossTotal - paidAmount;
|
|
const declaredDueAmount = invoice.metadata?.amountDue || expectedGrossTotal;
|
|
|
|
const isDueEqual = this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(expectedDueAmount, declaredDueAmount)
|
|
: Math.abs(expectedDueAmount - declaredDueAmount) < 0.01;
|
|
|
|
if (!isDueEqual) {
|
|
this.addError(
|
|
'BR-CO-16',
|
|
`Amount due (${declaredDueAmount.toFixed(2)}) does not match calculation (${expectedDueAmount.toFixed(2)})`,
|
|
'amountDue',
|
|
declaredDueAmount,
|
|
expectedDueAmount
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate VAT rules
|
|
*/
|
|
private validateVATRules(invoice: EInvoice): void {
|
|
// Group items by VAT rate
|
|
const vatGroups = this.groupItemsByVAT(invoice.items || []);
|
|
|
|
// BR-S-01: An Invoice that contains an Invoice line where VAT category code is "Standard rated"
|
|
// shall contain in the VAT breakdown at least one VAT category code equal to "Standard rated"
|
|
const hasStandardRatedLine = invoice.items?.some(item =>
|
|
item.vatPercentage && item.vatPercentage > 0
|
|
);
|
|
|
|
if (hasStandardRatedLine) {
|
|
const hasStandardRatedBreakdown = invoice.taxBreakdown?.some(breakdown =>
|
|
breakdown.taxPercent && breakdown.taxPercent > 0
|
|
);
|
|
|
|
if (!hasStandardRatedBreakdown) {
|
|
this.addError(
|
|
'BR-S-01',
|
|
'Invoice with standard rated lines must have standard rated VAT breakdown',
|
|
'taxBreakdown'
|
|
);
|
|
}
|
|
}
|
|
|
|
// BR-S-02: VAT category taxable amount for standard rated
|
|
// BR-S-03: VAT category tax amount for standard rated
|
|
vatGroups.forEach((group, rate) => {
|
|
if (rate > 0) { // Standard rated
|
|
const expectedTaxableAmount = group.reduce((sum, item) =>
|
|
sum + (item.unitNetPrice * item.unitQuantity), 0
|
|
);
|
|
|
|
const expectedTaxAmount = expectedTaxableAmount * (rate / 100);
|
|
|
|
// Find corresponding breakdown
|
|
const breakdown = invoice.taxBreakdown?.find(b =>
|
|
Math.abs((b.taxPercent || 0) - rate) < 0.01
|
|
);
|
|
|
|
if (breakdown) {
|
|
const isTaxableEqual = this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount)
|
|
: Math.abs(breakdown.netAmount - expectedTaxableAmount) < 0.01;
|
|
|
|
if (!isTaxableEqual) {
|
|
this.addError(
|
|
'BR-S-02',
|
|
`VAT taxable amount for ${rate}% incorrect`,
|
|
'taxBreakdown.netAmount',
|
|
breakdown.netAmount,
|
|
expectedTaxableAmount
|
|
);
|
|
}
|
|
|
|
const isTaxEqual = this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount)
|
|
: Math.abs(breakdown.taxAmount - expectedTaxAmount) < 0.01;
|
|
|
|
if (!isTaxEqual) {
|
|
this.addError(
|
|
'BR-S-03',
|
|
`VAT tax amount for ${rate}% incorrect`,
|
|
'taxBreakdown.vatAmount',
|
|
breakdown.taxAmount,
|
|
expectedTaxAmount
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// BR-Z-01: Zero rated VAT rules
|
|
const hasZeroRatedLine = invoice.items?.some(item =>
|
|
item.vatPercentage === 0
|
|
);
|
|
|
|
if (hasZeroRatedLine) {
|
|
const hasZeroRatedBreakdown = invoice.taxBreakdown?.some(breakdown =>
|
|
breakdown.taxPercent === 0
|
|
);
|
|
|
|
if (!hasZeroRatedBreakdown) {
|
|
this.addError(
|
|
'BR-Z-01',
|
|
'Invoice with zero rated lines must have zero rated VAT breakdown',
|
|
'taxBreakdown'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate line level rules (BR-21 to BR-30)
|
|
*/
|
|
private validateLineRules(invoice: EInvoice): void {
|
|
invoice.items?.forEach((item, index) => {
|
|
// BR-21: Each Invoice line shall have an Invoice line identifier
|
|
if (!item.position && item.position !== 0) {
|
|
this.addError(
|
|
'BR-21',
|
|
`Invoice line ${index + 1} must have an identifier`,
|
|
`items[${index}].id`
|
|
);
|
|
}
|
|
|
|
// BR-22: Each Invoice line shall have an Item name
|
|
if (!item.name) {
|
|
this.addError(
|
|
'BR-22',
|
|
`Invoice line ${index + 1} must have an item name`,
|
|
`items[${index}].name`
|
|
);
|
|
}
|
|
|
|
// BR-23: An Invoice line shall have an Invoiced quantity
|
|
if (item.unitQuantity === undefined || item.unitQuantity === null) {
|
|
this.addError(
|
|
'BR-23',
|
|
`Invoice line ${index + 1} must have a quantity`,
|
|
`items[${index}].quantity`
|
|
);
|
|
}
|
|
|
|
// BR-24: An Invoice line shall have an Invoiced quantity unit of measure code
|
|
if (!item.unitType) {
|
|
this.addError(
|
|
'BR-24',
|
|
`Invoice line ${index + 1} must have a unit of measure code`,
|
|
`items[${index}].unitCode`
|
|
);
|
|
}
|
|
|
|
// BR-25: An Invoice line shall have an Invoice line net amount
|
|
const lineNetAmount = item.unitNetPrice * item.unitQuantity;
|
|
if (isNaN(lineNetAmount)) {
|
|
this.addError(
|
|
'BR-25',
|
|
`Invoice line ${index + 1} must have a valid net amount`,
|
|
`items[${index}]`
|
|
);
|
|
}
|
|
|
|
// BR-26: Each Invoice line shall have an Invoice line VAT category code
|
|
if (item.vatPercentage === undefined) {
|
|
this.addError(
|
|
'BR-26',
|
|
`Invoice line ${index + 1} must have a VAT category code`,
|
|
`items[${index}].vatPercentage`
|
|
);
|
|
}
|
|
|
|
// BR-27: Invoice line net price shall be present
|
|
if (item.unitNetPrice === undefined || item.unitNetPrice === null) {
|
|
this.addError(
|
|
'BR-27',
|
|
`Invoice line ${index + 1} must have a net price`,
|
|
`items[${index}].unitPrice`
|
|
);
|
|
}
|
|
|
|
// BR-28: Item price base quantity shall be greater than zero
|
|
const baseQuantity = 1; // Default to 1 as TAccountingDocItem doesn't have priceBaseQuantity
|
|
if (baseQuantity <= 0) {
|
|
this.addError(
|
|
'BR-28',
|
|
`Invoice line ${index + 1} price base quantity must be greater than zero`,
|
|
`items[${index}].metadata.priceBaseQuantity`,
|
|
baseQuantity,
|
|
'> 0'
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate allowances and charges
|
|
*/
|
|
private validateAllowancesCharges(invoice: EInvoice): void {
|
|
// BR-31: Document level allowance shall have an amount
|
|
invoice.metadata?.allowances?.forEach((allowance: any, index: number) => {
|
|
if (!allowance.amount && allowance.amount !== 0) {
|
|
this.addError(
|
|
'BR-31',
|
|
`Document allowance ${index + 1} must have an amount`,
|
|
`metadata.allowances[${index}].amount`
|
|
);
|
|
}
|
|
|
|
// BR-32: Document level allowance shall have VAT category code
|
|
if (!allowance.vatCategoryCode) {
|
|
this.addError(
|
|
'BR-32',
|
|
`Document allowance ${index + 1} must have a VAT category code`,
|
|
`metadata.allowances[${index}].vatCategoryCode`
|
|
);
|
|
}
|
|
|
|
// BR-33: Document level allowance shall have a reason
|
|
if (!allowance.reason) {
|
|
this.addError(
|
|
'BR-33',
|
|
`Document allowance ${index + 1} must have a reason`,
|
|
`metadata.allowances[${index}].reason`
|
|
);
|
|
}
|
|
});
|
|
|
|
// BR-36: Document level charge shall have an amount
|
|
invoice.metadata?.charges?.forEach((charge: any, index: number) => {
|
|
if (!charge.amount && charge.amount !== 0) {
|
|
this.addError(
|
|
'BR-36',
|
|
`Document charge ${index + 1} must have an amount`,
|
|
`metadata.charges[${index}].amount`
|
|
);
|
|
}
|
|
|
|
// BR-37: Document level charge shall have VAT category code
|
|
if (!charge.vatCategoryCode) {
|
|
this.addError(
|
|
'BR-37',
|
|
`Document charge ${index + 1} must have a VAT category code`,
|
|
`metadata.charges[${index}].vatCategoryCode`
|
|
);
|
|
}
|
|
|
|
// BR-38: Document level charge shall have a reason
|
|
if (!charge.reason) {
|
|
this.addError(
|
|
'BR-38',
|
|
`Document charge ${index + 1} must have a reason`,
|
|
`metadata.charges[${index}].reason`
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
private calculateLineTotal(items: TAccountingDocItem[]): number {
|
|
return items.reduce((sum, item) => {
|
|
const lineTotal = (item.unitNetPrice || 0) * (item.unitQuantity || 0);
|
|
const rounded = this.currencyCalculator
|
|
? this.currencyCalculator.round(lineTotal)
|
|
: lineTotal;
|
|
return sum + rounded;
|
|
}, 0);
|
|
}
|
|
|
|
private calculateDocumentAllowances(invoice: EInvoice): number {
|
|
return invoice.metadata?.allowances?.reduce((sum: number, allowance: any) =>
|
|
sum + (allowance.amount || 0), 0
|
|
) || 0;
|
|
}
|
|
|
|
private calculateDocumentCharges(invoice: EInvoice): number {
|
|
return invoice.metadata?.charges?.reduce((sum: number, charge: any) =>
|
|
sum + (charge.amount || 0), 0
|
|
) || 0;
|
|
}
|
|
|
|
private calculateTotalVAT(invoice: EInvoice): number {
|
|
const vatGroups = this.groupItemsByVAT(invoice.items || []);
|
|
let totalVAT = 0;
|
|
|
|
vatGroups.forEach((items, rate) => {
|
|
const taxableAmount = items.reduce((sum, item) => {
|
|
const lineNet = item.unitNetPrice * item.unitQuantity;
|
|
return sum + (this.currencyCalculator ? this.currencyCalculator.round(lineNet) : lineNet);
|
|
}, 0);
|
|
|
|
const vatAmount = taxableAmount * (rate / 100);
|
|
const roundedVAT = this.currencyCalculator
|
|
? this.currencyCalculator.round(vatAmount)
|
|
: vatAmount;
|
|
|
|
totalVAT += roundedVAT;
|
|
});
|
|
|
|
return totalVAT;
|
|
}
|
|
|
|
private groupItemsByVAT(items: TAccountingDocItem[]): Map<number, TAccountingDocItem[]> {
|
|
const groups = new Map<number, TAccountingDocItem[]>();
|
|
|
|
items.forEach(item => {
|
|
const rate = item.vatPercentage || 0;
|
|
if (!groups.has(rate)) {
|
|
groups.set(rate, []);
|
|
}
|
|
groups.get(rate)!.push(item);
|
|
});
|
|
|
|
return groups;
|
|
}
|
|
|
|
private addError(
|
|
ruleId: string,
|
|
message: string,
|
|
field?: string,
|
|
value?: any,
|
|
expected?: any
|
|
): void {
|
|
this.results.push({
|
|
ruleId,
|
|
source: 'EN16931',
|
|
severity: 'error',
|
|
message,
|
|
field,
|
|
value,
|
|
expected
|
|
});
|
|
}
|
|
|
|
private addWarning(
|
|
ruleId: string,
|
|
message: string,
|
|
field?: string,
|
|
value?: any,
|
|
expected?: any
|
|
): void {
|
|
this.results.push({
|
|
ruleId,
|
|
source: 'EN16931',
|
|
severity: 'warning',
|
|
message,
|
|
field,
|
|
value,
|
|
expected
|
|
});
|
|
}
|
|
} |