/** * Currency Calculator using Decimal Arithmetic * EN16931-compliant monetary calculations with exact precision */ import { Decimal, decimal, RoundingMode } from './decimal.js'; import type { TCurrency } from '@tsclass/tsclass/dist_ts/finance/index.js'; import { getCurrencyMinorUnits } from './currency.utils.js'; /** * Currency-aware calculator using decimal arithmetic for EN16931 compliance */ export class DecimalCurrencyCalculator { private readonly currency: TCurrency; private readonly minorUnits: number; private readonly roundingMode: RoundingMode; constructor( currency: TCurrency, roundingMode: RoundingMode = 'HALF_UP' ) { this.currency = currency; this.minorUnits = getCurrencyMinorUnits(currency); this.roundingMode = roundingMode; } /** * Round a decimal value according to currency rules */ round(value: Decimal | number | string): Decimal { const decimalValue = value instanceof Decimal ? value : new Decimal(value); return decimalValue.round(this.minorUnits, this.roundingMode); } /** * Calculate line net amount: (quantity × unitPrice) - discount */ calculateLineNet( quantity: Decimal | number | string, unitPrice: Decimal | number | string, discount: Decimal | number | string = '0' ): Decimal { const qty = quantity instanceof Decimal ? quantity : new Decimal(quantity); const price = unitPrice instanceof Decimal ? unitPrice : new Decimal(unitPrice); const disc = discount instanceof Decimal ? discount : new Decimal(discount); const gross = qty.multiply(price); const net = gross.subtract(disc); return this.round(net); } /** * Calculate VAT amount from base and rate */ calculateVAT( baseAmount: Decimal | number | string, vatRate: Decimal | number | string ): Decimal { const base = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount); const rate = vatRate instanceof Decimal ? vatRate : new Decimal(vatRate); const vat = base.percentage(rate); return this.round(vat); } /** * Calculate total with VAT */ calculateGrossAmount( netAmount: Decimal | number | string, vatAmount: Decimal | number | string ): Decimal { const net = netAmount instanceof Decimal ? netAmount : new Decimal(netAmount); const vat = vatAmount instanceof Decimal ? vatAmount : new Decimal(vatAmount); return this.round(net.add(vat)); } /** * Calculate sum of line items */ sumLineItems(items: Array<{ quantity: Decimal | number | string; unitPrice: Decimal | number | string; discount?: Decimal | number | string; }>): Decimal { let total = Decimal.ZERO; for (const item of items) { const lineNet = this.calculateLineNet( item.quantity, item.unitPrice, item.discount ); total = total.add(lineNet); } return this.round(total); } /** * Calculate VAT breakdown by rate */ calculateVATBreakdown(items: Array<{ netAmount: Decimal | number | string; vatRate: Decimal | number | string; }>): Array<{ rate: Decimal; baseAmount: Decimal; vatAmount: Decimal; }> { // Group by VAT rate const groups = new Map(); for (const item of items) { const net = item.netAmount instanceof Decimal ? item.netAmount : new Decimal(item.netAmount); const rate = item.vatRate instanceof Decimal ? item.vatRate : new Decimal(item.vatRate); const rateKey = rate.toString(); if (groups.has(rateKey)) { const group = groups.get(rateKey)!; group.baseAmount = group.baseAmount.add(net); } else { groups.set(rateKey, { rate, baseAmount: net }); } } // Calculate VAT for each group const breakdown: Array<{ rate: Decimal; baseAmount: Decimal; vatAmount: Decimal; }> = []; for (const group of groups.values()) { breakdown.push({ rate: group.rate, baseAmount: this.round(group.baseAmount), vatAmount: this.calculateVAT(group.baseAmount, group.rate) }); } return breakdown; } /** * Check if two amounts are equal within currency precision */ areEqual( amount1: Decimal | number | string, amount2: Decimal | number | string ): boolean { const a1 = amount1 instanceof Decimal ? amount1 : new Decimal(amount1); const a2 = amount2 instanceof Decimal ? amount2 : new Decimal(amount2); // Round both to currency precision before comparing const rounded1 = this.round(a1); const rounded2 = this.round(a2); return rounded1.equals(rounded2); } /** * Calculate payment terms discount */ calculatePaymentDiscount( amount: Decimal | number | string, discountRate: Decimal | number | string ): Decimal { const amt = amount instanceof Decimal ? amount : new Decimal(amount); const rate = discountRate instanceof Decimal ? discountRate : new Decimal(discountRate); const discount = amt.percentage(rate); return this.round(discount); } /** * Distribute a total amount across items proportionally */ distributeAmount( totalToDistribute: Decimal | number | string, items: Array<{ value: Decimal | number | string }> ): Decimal[] { const total = totalToDistribute instanceof Decimal ? totalToDistribute : new Decimal(totalToDistribute); // Calculate sum of all item values const itemSum = items.reduce((sum, item) => { const value = item.value instanceof Decimal ? item.value : new Decimal(item.value); return sum.add(value); }, Decimal.ZERO); if (itemSum.isZero()) { // Can't distribute if sum is zero return items.map(() => Decimal.ZERO); } const distributed: Decimal[] = []; let distributedSum = Decimal.ZERO; // Distribute proportionally for (let i = 0; i < items.length; i++) { const itemValue = items[i].value instanceof Decimal ? items[i].value : new Decimal(items[i].value); if (i === items.length - 1) { // Last item gets the remainder to avoid rounding errors distributed.push(total.subtract(distributedSum)); } else { const itemDecimal = itemValue instanceof Decimal ? itemValue : new Decimal(itemValue); const proportion = itemDecimal.divide(itemSum); const distributedAmount = this.round(total.multiply(proportion)); distributed.push(distributedAmount); distributedSum = distributedSum.add(distributedAmount); } } return distributed; } /** * Calculate compound amount (e.g., for multiple charges/allowances) */ calculateCompoundAmount( baseAmount: Decimal | number | string, adjustments: Array<{ type: 'charge' | 'allowance'; value: Decimal | number | string; isPercentage?: boolean; }> ): Decimal { let result = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount); for (const adjustment of adjustments) { const value = adjustment.value instanceof Decimal ? adjustment.value : new Decimal(adjustment.value); let adjustmentAmount: Decimal; if (adjustment.isPercentage) { adjustmentAmount = result.percentage(value); } else { adjustmentAmount = value; } if (adjustment.type === 'charge') { result = result.add(adjustmentAmount); } else { result = result.subtract(adjustmentAmount); } } return this.round(result); } /** * Validate monetary calculation according to EN16931 rules */ validateCalculation( expected: Decimal | number | string, calculated: Decimal | number | string, ruleName: string ): { valid: boolean; expected: string; calculated: string; difference?: string; rule: string; } { const exp = expected instanceof Decimal ? expected : new Decimal(expected); const calc = calculated instanceof Decimal ? calculated : new Decimal(calculated); const roundedExp = this.round(exp); const roundedCalc = this.round(calc); const valid = roundedExp.equals(roundedCalc); return { valid, expected: roundedExp.toFixed(this.minorUnits), calculated: roundedCalc.toFixed(this.minorUnits), difference: valid ? undefined : roundedExp.subtract(roundedCalc).abs().toFixed(this.minorUnits), rule: ruleName }; } /** * Format amount for display */ formatAmount(amount: Decimal | number | string): string { const amt = amount instanceof Decimal ? amount : new Decimal(amount); const rounded = this.round(amt); return `${rounded.toFixed(this.minorUnits)} ${this.currency}`; } /** * Get currency information */ getCurrencyInfo(): { code: TCurrency; minorUnits: number; roundingMode: RoundingMode; } { return { code: this.currency, minorUnits: this.minorUnits, roundingMode: this.roundingMode }; } } /** * Factory function to create a decimal currency calculator */ export function createDecimalCalculator( currency: TCurrency, roundingMode?: RoundingMode ): DecimalCurrencyCalculator { return new DecimalCurrencyCalculator(currency, roundingMode); }