323 lines
9.2 KiB
TypeScript
323 lines
9.2 KiB
TypeScript
|
/**
|
|||
|
* 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<string, {
|
|||
|
rate: Decimal;
|
|||
|
baseAmount: Decimal;
|
|||
|
}>();
|
|||
|
|
|||
|
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);
|
|||
|
}
|