- 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.
694 lines
25 KiB
TypeScript
694 lines
25 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 { DecimalCurrencyCalculator } from '../utils/currency.calculator.decimal.js';
|
|
import { Decimal } from '../utils/decimal.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;
|
|
private decimalCalculator?: DecimalCurrencyCalculator;
|
|
|
|
/**
|
|
* Validate an invoice against EN16931 business rules
|
|
*/
|
|
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
|
|
this.results = [];
|
|
|
|
// Initialize currency calculators if currency is available
|
|
if (invoice.currency) {
|
|
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
|
|
this.decimalCalculator = new DecimalCurrencyCalculator(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;
|
|
|
|
// Use decimal calculator for precise calculations
|
|
const useDecimal = this.decimalCalculator !== undefined;
|
|
|
|
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
|
|
const calculatedLineTotal = useDecimal
|
|
? this.calculateLineTotalDecimal(invoice.items)
|
|
: this.calculateLineTotal(invoice.items);
|
|
const declaredLineTotal = useDecimal
|
|
? new Decimal(invoice.totalNet || 0)
|
|
: invoice.totalNet || 0;
|
|
|
|
const isEqual = useDecimal
|
|
? this.decimalCalculator!.areEqual(calculatedLineTotal, declaredLineTotal)
|
|
: this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(calculatedLineTotal as number, declaredLineTotal as number)
|
|
: Math.abs((calculatedLineTotal as number) - (declaredLineTotal as number)) < 0.01;
|
|
|
|
if (!isEqual) {
|
|
this.addError(
|
|
'BR-CO-10',
|
|
`Sum of line net amounts (${useDecimal ? (calculatedLineTotal as Decimal).toFixed(2) : (calculatedLineTotal as number).toFixed(2)}) does not match declared total (${useDecimal ? (declaredLineTotal as Decimal).toFixed(2) : (declaredLineTotal as number).toFixed(2)})`,
|
|
'totalNet',
|
|
useDecimal ? (declaredLineTotal as Decimal).toNumber() : declaredLineTotal as number,
|
|
useDecimal ? (calculatedLineTotal as Decimal).toNumber() : calculatedLineTotal as number
|
|
);
|
|
}
|
|
|
|
// BR-CO-11: Sum of allowances on document level
|
|
const documentAllowances = useDecimal
|
|
? this.calculateDocumentAllowancesDecimal(invoice)
|
|
: this.calculateDocumentAllowances(invoice);
|
|
|
|
// BR-CO-12: Sum of charges on document level
|
|
const documentCharges = useDecimal
|
|
? this.calculateDocumentChargesDecimal(invoice)
|
|
: this.calculateDocumentCharges(invoice);
|
|
|
|
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
|
|
const expectedTaxExclusive = useDecimal
|
|
? (calculatedLineTotal as Decimal).subtract(documentAllowances).add(documentCharges)
|
|
: (calculatedLineTotal as number) - (documentAllowances as number) + (documentCharges as number);
|
|
const declaredTaxExclusive = useDecimal
|
|
? new Decimal(invoice.totalNet || 0)
|
|
: invoice.totalNet || 0;
|
|
|
|
const isTaxExclusiveEqual = useDecimal
|
|
? this.decimalCalculator!.areEqual(expectedTaxExclusive, declaredTaxExclusive)
|
|
: this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(expectedTaxExclusive as number, declaredTaxExclusive as number)
|
|
: Math.abs((expectedTaxExclusive as number) - (declaredTaxExclusive as number)) < 0.01;
|
|
|
|
if (!isTaxExclusiveEqual) {
|
|
this.addError(
|
|
'BR-CO-13',
|
|
`Tax exclusive amount (${useDecimal ? (declaredTaxExclusive as Decimal).toFixed(2) : (declaredTaxExclusive as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedTaxExclusive as Decimal).toFixed(2) : (expectedTaxExclusive as number).toFixed(2)})`,
|
|
'totalNet',
|
|
useDecimal ? (declaredTaxExclusive as Decimal).toNumber() : declaredTaxExclusive as number,
|
|
useDecimal ? (expectedTaxExclusive as Decimal).toNumber() : expectedTaxExclusive as number
|
|
);
|
|
}
|
|
|
|
// BR-CO-14: Invoice total VAT amount = Σ(VAT category tax amount)
|
|
const calculatedVAT = useDecimal
|
|
? this.calculateTotalVATDecimal(invoice)
|
|
: this.calculateTotalVAT(invoice);
|
|
const declaredVAT = useDecimal
|
|
? new Decimal(invoice.totalVat || 0)
|
|
: invoice.totalVat || 0;
|
|
|
|
const isVATEqual = useDecimal
|
|
? this.decimalCalculator!.areEqual(calculatedVAT, declaredVAT)
|
|
: this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(calculatedVAT as number, declaredVAT as number)
|
|
: Math.abs((calculatedVAT as number) - (declaredVAT as number)) < 0.01;
|
|
|
|
if (!isVATEqual) {
|
|
this.addError(
|
|
'BR-CO-14',
|
|
`Total VAT (${useDecimal ? (declaredVAT as Decimal).toFixed(2) : (declaredVAT as number).toFixed(2)}) does not match calculation (${useDecimal ? (calculatedVAT as Decimal).toFixed(2) : (calculatedVAT as number).toFixed(2)})`,
|
|
'totalVat',
|
|
useDecimal ? (declaredVAT as Decimal).toNumber() : declaredVAT as number,
|
|
useDecimal ? (calculatedVAT as Decimal).toNumber() : calculatedVAT as number
|
|
);
|
|
}
|
|
|
|
// BR-CO-15: Invoice total with VAT = Invoice total without VAT + Invoice total VAT
|
|
const expectedGrossTotal = useDecimal
|
|
? (expectedTaxExclusive as Decimal).add(calculatedVAT)
|
|
: (expectedTaxExclusive as number) + (calculatedVAT as number);
|
|
const declaredGrossTotal = useDecimal
|
|
? new Decimal(invoice.totalGross || 0)
|
|
: invoice.totalGross || 0;
|
|
|
|
const isGrossEqual = useDecimal
|
|
? this.decimalCalculator!.areEqual(expectedGrossTotal, declaredGrossTotal)
|
|
: this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(expectedGrossTotal as number, declaredGrossTotal as number)
|
|
: Math.abs((expectedGrossTotal as number) - (declaredGrossTotal as number)) < 0.01;
|
|
|
|
if (!isGrossEqual) {
|
|
this.addError(
|
|
'BR-CO-15',
|
|
`Gross total (${useDecimal ? (declaredGrossTotal as Decimal).toFixed(2) : (declaredGrossTotal as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedGrossTotal as Decimal).toFixed(2) : (expectedGrossTotal as number).toFixed(2)})`,
|
|
'totalGross',
|
|
useDecimal ? (declaredGrossTotal as Decimal).toNumber() : declaredGrossTotal as number,
|
|
useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal as number
|
|
);
|
|
}
|
|
|
|
// BR-CO-16: Amount due for payment = Invoice total with VAT - Paid amount
|
|
const paidAmount = useDecimal
|
|
? new Decimal(invoice.metadata?.paidAmount || 0)
|
|
: invoice.metadata?.paidAmount || 0;
|
|
const expectedDueAmount = useDecimal
|
|
? (expectedGrossTotal as Decimal).subtract(paidAmount)
|
|
: (expectedGrossTotal as number) - (paidAmount as number);
|
|
const declaredDueAmount = useDecimal
|
|
? new Decimal(invoice.metadata?.amountDue || (useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal))
|
|
: invoice.metadata?.amountDue || expectedGrossTotal;
|
|
|
|
const isDueEqual = useDecimal
|
|
? this.decimalCalculator!.areEqual(expectedDueAmount, declaredDueAmount)
|
|
: this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(expectedDueAmount as number, declaredDueAmount as number)
|
|
: Math.abs((expectedDueAmount as number) - (declaredDueAmount as number)) < 0.01;
|
|
|
|
if (!isDueEqual) {
|
|
this.addError(
|
|
'BR-CO-16',
|
|
`Amount due (${useDecimal ? (declaredDueAmount as Decimal).toFixed(2) : (declaredDueAmount as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedDueAmount as Decimal).toFixed(2) : (expectedDueAmount as number).toFixed(2)})`,
|
|
'amountDue',
|
|
useDecimal ? (declaredDueAmount as Decimal).toNumber() : declaredDueAmount as number,
|
|
useDecimal ? (expectedDueAmount as Decimal).toNumber() : expectedDueAmount as number
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate VAT rules
|
|
*/
|
|
private validateVATRules(invoice: EInvoice): void {
|
|
const useDecimal = this.decimalCalculator !== undefined;
|
|
|
|
// 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 = useDecimal
|
|
? group.reduce((sum, item) => {
|
|
const unitPrice = new Decimal(item.unitNetPrice);
|
|
const quantity = new Decimal(item.unitQuantity);
|
|
return sum.add(unitPrice.multiply(quantity));
|
|
}, Decimal.ZERO)
|
|
: group.reduce((sum, item) =>
|
|
sum + (item.unitNetPrice * item.unitQuantity), 0
|
|
);
|
|
|
|
const expectedTaxAmount = useDecimal
|
|
? this.decimalCalculator!.calculateVAT(expectedTaxableAmount, new Decimal(rate))
|
|
: (expectedTaxableAmount as number) * (rate / 100);
|
|
|
|
// Find corresponding breakdown
|
|
const breakdown = invoice.taxBreakdown?.find(b =>
|
|
Math.abs((b.taxPercent || 0) - rate) < 0.01
|
|
);
|
|
|
|
if (breakdown) {
|
|
const isTaxableEqual = useDecimal
|
|
? this.decimalCalculator!.areEqual(expectedTaxableAmount, breakdown.netAmount)
|
|
: this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount as number)
|
|
: Math.abs(breakdown.netAmount - (expectedTaxableAmount as number)) < 0.01;
|
|
|
|
if (!isTaxableEqual) {
|
|
this.addError(
|
|
'BR-S-02',
|
|
`VAT taxable amount for ${rate}% incorrect`,
|
|
'taxBreakdown.netAmount',
|
|
breakdown.netAmount,
|
|
useDecimal ? (expectedTaxableAmount as Decimal).toNumber() : expectedTaxableAmount as number
|
|
);
|
|
}
|
|
|
|
const isTaxEqual = useDecimal
|
|
? this.decimalCalculator!.areEqual(expectedTaxAmount, breakdown.taxAmount)
|
|
: this.currencyCalculator
|
|
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount as number)
|
|
: Math.abs(breakdown.taxAmount - (expectedTaxAmount as number)) < 0.01;
|
|
|
|
if (!isTaxEqual) {
|
|
this.addError(
|
|
'BR-S-03',
|
|
`VAT tax amount for ${rate}% incorrect`,
|
|
'taxBreakdown.vatAmount',
|
|
breakdown.taxAmount,
|
|
useDecimal ? (expectedTaxAmount as Decimal).toNumber() : expectedTaxAmount as number
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Calculate line total using decimal arithmetic for precision
|
|
*/
|
|
private calculateLineTotalDecimal(items: TAccountingDocItem[]): Decimal {
|
|
let total = Decimal.ZERO;
|
|
|
|
for (const item of items) {
|
|
const unitPrice = new Decimal(item.unitNetPrice || 0);
|
|
const quantity = new Decimal(item.unitQuantity || 0);
|
|
const lineTotal = unitPrice.multiply(quantity);
|
|
total = total.add(this.decimalCalculator!.round(lineTotal));
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Calculate document allowances using decimal arithmetic
|
|
*/
|
|
private calculateDocumentAllowancesDecimal(invoice: EInvoice): Decimal {
|
|
if (!invoice.metadata?.allowances) {
|
|
return Decimal.ZERO;
|
|
}
|
|
|
|
let total = Decimal.ZERO;
|
|
for (const allowance of invoice.metadata.allowances) {
|
|
const amount = new Decimal(allowance.amount || 0);
|
|
total = total.add(this.decimalCalculator!.round(amount));
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Calculate document charges using decimal arithmetic
|
|
*/
|
|
private calculateDocumentChargesDecimal(invoice: EInvoice): Decimal {
|
|
if (!invoice.metadata?.charges) {
|
|
return Decimal.ZERO;
|
|
}
|
|
|
|
let total = Decimal.ZERO;
|
|
for (const charge of invoice.metadata.charges) {
|
|
const amount = new Decimal(charge.amount || 0);
|
|
total = total.add(this.decimalCalculator!.round(amount));
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Calculate total VAT using decimal arithmetic
|
|
*/
|
|
private calculateTotalVATDecimal(invoice: EInvoice): Decimal {
|
|
let totalVAT = Decimal.ZERO;
|
|
|
|
// Group items by VAT rate
|
|
const vatGroups = new Map<string, Decimal>();
|
|
|
|
for (const item of invoice.items || []) {
|
|
const vatRate = item.vatPercentage || 0;
|
|
const rateKey = vatRate.toString();
|
|
|
|
const unitPrice = new Decimal(item.unitNetPrice || 0);
|
|
const quantity = new Decimal(item.unitQuantity || 0);
|
|
const lineNet = unitPrice.multiply(quantity);
|
|
|
|
if (vatGroups.has(rateKey)) {
|
|
vatGroups.set(rateKey, vatGroups.get(rateKey)!.add(lineNet));
|
|
} else {
|
|
vatGroups.set(rateKey, lineNet);
|
|
}
|
|
}
|
|
|
|
// Calculate VAT for each group
|
|
for (const [rateKey, baseAmount] of vatGroups) {
|
|
const rate = new Decimal(rateKey);
|
|
const vat = this.decimalCalculator!.calculateVAT(baseAmount, rate);
|
|
totalVAT = totalVAT.add(vat);
|
|
}
|
|
|
|
return totalVAT;
|
|
}
|
|
|
|
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
|
|
});
|
|
}
|
|
} |