/** * Decimal Arithmetic Library for EN16931 Compliance * Provides arbitrary precision decimal arithmetic to avoid floating-point errors * * Based on EN16931 requirements for financial calculations: * - All monetary amounts must be calculated with sufficient precision * - Rounding must be consistent and predictable * - No loss of precision in intermediate calculations */ /** * Decimal class for arbitrary precision arithmetic * Internally stores the value as an integer with a scale factor */ export class Decimal { private readonly value: bigint; private readonly scale: number; // Constants - initialized lazily to avoid initialization issues private static _ZERO: Decimal | undefined; private static _ONE: Decimal | undefined; private static _TEN: Decimal | undefined; private static _HUNDRED: Decimal | undefined; static get ZERO(): Decimal { if (!this._ZERO) this._ZERO = new Decimal(0); return this._ZERO; } static get ONE(): Decimal { if (!this._ONE) this._ONE = new Decimal(1); return this._ONE; } static get TEN(): Decimal { if (!this._TEN) this._TEN = new Decimal(10); return this._TEN; } static get HUNDRED(): Decimal { if (!this._HUNDRED) this._HUNDRED = new Decimal(100); return this._HUNDRED; } // Default scale for monetary calculations (4 decimal places for intermediate calculations) private static readonly DEFAULT_SCALE = 4; /** * Create a new Decimal from various input types */ constructor(value: string | number | bigint | Decimal, scale?: number) { if (value instanceof Decimal) { this.value = value.value; this.scale = value.scale; return; } // Special handling for direct bigint with scale (internal use) if (typeof value === 'bigint' && scale !== undefined) { this.value = value; this.scale = scale; return; } // Determine scale if not provided if (scale === undefined) { if (typeof value === 'string') { const parts = value.split('.'); scale = parts.length > 1 ? parts[1].length : 0; } else { scale = Decimal.DEFAULT_SCALE; } } this.scale = scale; // Convert to scaled integer if (typeof value === 'string') { // Remove any formatting value = value.replace(/[^\d.-]/g, ''); const parts = value.split('.'); const integerPart = parts[0] || '0'; const decimalPart = (parts[1] || '').padEnd(scale, '0').slice(0, scale); this.value = BigInt(integerPart + decimalPart); } else if (typeof value === 'number') { // Handle floating point numbers if (!isFinite(value)) { throw new Error(`Invalid number value: ${value}`); } const multiplier = Math.pow(10, scale); this.value = BigInt(Math.round(value * multiplier)); } else { // bigint this.value = value * BigInt(Math.pow(10, scale)); } } /** * Convert to string representation */ toString(decimalPlaces?: number): string { const absValue = this.value < 0n ? -this.value : this.value; const str = absValue.toString().padStart(this.scale + 1, '0'); const integerPart = this.scale > 0 ? (str.slice(0, -this.scale) || '0') : str; let decimalPart = this.scale > 0 ? str.slice(-this.scale) : ''; // Apply decimal places if specified if (decimalPlaces !== undefined) { if (decimalPlaces === 0) { return (this.value < 0n ? '-' : '') + integerPart; } decimalPart = decimalPart.padEnd(decimalPlaces, '0').slice(0, decimalPlaces); } // Remove trailing zeros if no specific decimal places requested if (decimalPlaces === undefined) { decimalPart = decimalPart.replace(/0+$/, ''); } const result = decimalPart ? `${integerPart}.${decimalPart}` : integerPart; return this.value < 0n ? '-' + result : result; } /** * Convert to number (may lose precision) */ toNumber(): number { return Number(this.value) / Math.pow(10, this.scale); } /** * Convert to fixed decimal places string */ toFixed(decimalPlaces: number): string { return this.round(decimalPlaces).toString(decimalPlaces); } /** * Add two decimals */ add(other: Decimal | number | string): Decimal { const otherDecimal = other instanceof Decimal ? other : new Decimal(other); // Align scales if (this.scale === otherDecimal.scale) { return new Decimal(this.value + otherDecimal.value, this.scale); } const maxScale = Math.max(this.scale, otherDecimal.scale); const thisScaled = this.rescale(maxScale); const otherScaled = otherDecimal.rescale(maxScale); return new Decimal(thisScaled.value + otherScaled.value, maxScale); } /** * Subtract another decimal */ subtract(other: Decimal | number | string): Decimal { const otherDecimal = other instanceof Decimal ? other : new Decimal(other); // Align scales if (this.scale === otherDecimal.scale) { return new Decimal(this.value - otherDecimal.value, this.scale); } const maxScale = Math.max(this.scale, otherDecimal.scale); const thisScaled = this.rescale(maxScale); const otherScaled = otherDecimal.rescale(maxScale); return new Decimal(thisScaled.value - otherScaled.value, maxScale); } /** * Multiply by another decimal */ multiply(other: Decimal | number | string): Decimal { const otherDecimal = other instanceof Decimal ? other : new Decimal(other); // Multiply values and add scales const newValue = this.value * otherDecimal.value; const newScale = this.scale + otherDecimal.scale; // Reduce scale if possible to avoid overflow const result = new Decimal(newValue, newScale); return result.normalize(); } /** * Divide by another decimal */ divide(other: Decimal | number | string, precision: number = 10): Decimal { const otherDecimal = other instanceof Decimal ? other : new Decimal(other); if (otherDecimal.value === 0n) { throw new Error('Division by zero'); } // Scale up the dividend to maintain precision const scaledDividend = this.value * BigInt(Math.pow(10, precision)); const quotient = scaledDividend / otherDecimal.value; return new Decimal(quotient, this.scale + precision - otherDecimal.scale).normalize(); } /** * Calculate percentage (this * rate / 100) */ percentage(rate: Decimal | number | string): Decimal { const rateDecimal = rate instanceof Decimal ? rate : new Decimal(rate); return this.multiply(rateDecimal).divide(100); } /** * Round to specified decimal places using a specific rounding mode */ round(decimalPlaces: number, mode: 'HALF_UP' | 'HALF_DOWN' | 'HALF_EVEN' | 'UP' | 'DOWN' | 'CEILING' | 'FLOOR' = 'HALF_UP'): Decimal { if (decimalPlaces === this.scale) { return this; } if (decimalPlaces > this.scale) { // Just add zeros return this.rescale(decimalPlaces); } // Need to round const factor = BigInt(Math.pow(10, this.scale - decimalPlaces)); const halfFactor = factor / 2n; let rounded: bigint; const isNegative = this.value < 0n; const absValue = isNegative ? -this.value : this.value; switch (mode) { case 'HALF_UP': // Round half away from zero rounded = (absValue + halfFactor) / factor; break; case 'HALF_DOWN': // Round half toward zero rounded = (absValue + halfFactor - 1n) / factor; break; case 'HALF_EVEN': // Banker's rounding const quotient = absValue / factor; const remainder = absValue % factor; if (remainder > halfFactor || (remainder === halfFactor && quotient % 2n === 1n)) { rounded = quotient + 1n; } else { rounded = quotient; } break; case 'UP': // Round away from zero rounded = (absValue + factor - 1n) / factor; break; case 'DOWN': // Round toward zero rounded = absValue / factor; break; case 'CEILING': // Round toward positive infinity if (isNegative) { rounded = absValue / factor; } else { rounded = (absValue + factor - 1n) / factor; } break; case 'FLOOR': // Round toward negative infinity if (isNegative) { rounded = (absValue + factor - 1n) / factor; } else { rounded = absValue / factor; } break; default: throw new Error(`Unknown rounding mode: ${mode}`); } const finalValue = isNegative ? -rounded : rounded; return new Decimal(finalValue, decimalPlaces); } /** * Compare with another decimal */ compareTo(other: Decimal | number | string): number { const otherDecimal = other instanceof Decimal ? other : new Decimal(other); // Align scales for comparison if (this.scale === otherDecimal.scale) { if (this.value < otherDecimal.value) return -1; if (this.value > otherDecimal.value) return 1; return 0; } const maxScale = Math.max(this.scale, otherDecimal.scale); const thisScaled = this.rescale(maxScale); const otherScaled = otherDecimal.rescale(maxScale); if (thisScaled.value < otherScaled.value) return -1; if (thisScaled.value > otherScaled.value) return 1; return 0; } /** * Check equality */ equals(other: Decimal | number | string, tolerance?: Decimal | number | string): boolean { if (tolerance) { const toleranceDecimal = tolerance instanceof Decimal ? tolerance : new Decimal(tolerance); const diff = this.subtract(other); const absDiff = diff.abs(); return absDiff.compareTo(toleranceDecimal) <= 0; } return this.compareTo(other) === 0; } /** * Check if less than */ lessThan(other: Decimal | number | string): boolean { return this.compareTo(other) < 0; } /** * Check if less than or equal */ lessThanOrEqual(other: Decimal | number | string): boolean { return this.compareTo(other) <= 0; } /** * Check if greater than */ greaterThan(other: Decimal | number | string): boolean { return this.compareTo(other) > 0; } /** * Check if greater than or equal */ greaterThanOrEqual(other: Decimal | number | string): boolean { return this.compareTo(other) >= 0; } /** * Get absolute value */ abs(): Decimal { return this.value < 0n ? new Decimal(-this.value, this.scale) : this; } /** * Negate the value */ negate(): Decimal { return new Decimal(-this.value, this.scale); } /** * Check if zero */ isZero(): boolean { return this.value === 0n; } /** * Check if negative */ isNegative(): boolean { return this.value < 0n; } /** * Check if positive */ isPositive(): boolean { return this.value > 0n; } /** * Rescale to a different number of decimal places */ private rescale(newScale: number): Decimal { if (newScale === this.scale) { return this; } if (newScale > this.scale) { // Add zeros const factor = BigInt(Math.pow(10, newScale - this.scale)); return new Decimal(this.value * factor, newScale); } // This would lose precision, use round() instead throw new Error('Use round() to reduce scale'); } /** * Normalize by removing trailing zeros */ private normalize(): Decimal { if (this.value === 0n) { return new Decimal(0n, 0); } let value = this.value; let scale = this.scale; while (scale > 0 && value % 10n === 0n) { value = value / 10n; scale--; } return new Decimal(value, scale); } /** * Create a Decimal from a percentage string (e.g., "19%" -> 0.19) */ static fromPercentage(value: string): Decimal { const cleaned = value.replace('%', '').trim(); return new Decimal(cleaned).divide(100); } /** * Sum an array of decimals */ static sum(values: (Decimal | number | string)[]): Decimal { return values.reduce((acc, val) => { const decimal = val instanceof Decimal ? val : new Decimal(val); return acc.add(decimal); }, Decimal.ZERO); } /** * Get the minimum value */ static min(...values: (Decimal | number | string)[]): Decimal { if (values.length === 0) { throw new Error('No values provided'); } let min = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]); for (let i = 1; i < values.length; i++) { const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]); const currentDecimal = current instanceof Decimal ? current : new Decimal(current); if (currentDecimal.lessThan(min)) { min = currentDecimal; } } return min; } /** * Get the maximum value */ static max(...values: (Decimal | number | string)[]): Decimal { if (values.length === 0) { throw new Error('No values provided'); } let max = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]); for (let i = 1; i < values.length; i++) { const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]); const currentDecimal = current instanceof Decimal ? current : new Decimal(current); if (currentDecimal.greaterThan(max)) { max = currentDecimal; } } return max; } } /** * Helper function to create a Decimal */ export function decimal(value: string | number | bigint | Decimal): Decimal { return new Decimal(value); } /** * Export commonly used rounding modes */ export const RoundingMode = { HALF_UP: 'HALF_UP' as const, HALF_DOWN: 'HALF_DOWN' as const, HALF_EVEN: 'HALF_EVEN' as const, UP: 'UP' as const, DOWN: 'DOWN' as const, CEILING: 'CEILING' as const, FLOOR: 'FLOOR' as const } as const; export type RoundingMode = typeof RoundingMode[keyof typeof RoundingMode];