import Big from "big.js"; /** * Exact decimal parsing and minor-unit arithmetic for discount calculation. * * Everything here works on `Big` values, never on JavaScript doubles: the * figures this feeds are compared to the minor unit with no tolerance * (docs/planning/discount-authority.md §5.4), so a conversion through an * imprecise number must never happen between parsing and comparison. * * Deliberately not `Add`/`Subtract`/`Multiply`/`Divide` from * `shared/math-operations.ts`. Those return a JavaScript number and read * invalid input as zero through `GetNumber`, which is exactly the forgiving * fallback an authority input must not have. */ /** A finite decimal with an optional sign, and nothing else. Mirrors discount-input-rules.ts. */ const DecimalText = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/u; /** Largest minor-unit exponent accepted. ISO 4217 uses 0-3; 4 leaves room for none of them to surprise us. */ export const MaxMinorUnitExponent = 4; export type ParsedDecimal = | { readonly Ok: true; readonly Value: Big } | { readonly Ok: false; readonly Reason: string }; function Describe(value: unknown): string { if (Array.isArray(value)) { return "an array"; } if (typeof value === "string") { return `the string ${JSON.stringify(value)}`; } if (value === null) { return "null"; } if (typeof value === "object") { return "an object"; } return `a ${typeof value}`; } /** * Parses a finite decimal exactly, from a number or a numeric string. * * `undefined` and `null` are not parsed here: whether absence means zero or is * an error depends on the field, and the caller decides that explicitly. */ export function ParseExactDecimal(value: unknown): ParsedDecimal { if (typeof value === "number") { if (!Number.isFinite(value)) { return { Ok: false, Reason: `not a finite number (${String(value)})` }; } // String(number) is the shortest round-trip form, which is what the // client meant when it wrote the value; Big then holds it exactly. return { Ok: true, Value: new Big(value) }; } if (typeof value !== "string" || !DecimalText.test(value.trim())) { return { Ok: false, Reason: `expected a number, received ${Describe(value)}` }; } const text = value.trim(); const exactText = text.startsWith("+") ? text.slice(1) : text; try { const parsed = new Big(exactText); if (!Number.isFinite(Number(text))) { return { Ok: false, Reason: `not a finite number (${text})` }; } return { Ok: true, Value: parsed }; } catch { return { Ok: false, Reason: `expected a number, received ${Describe(value)}` }; } } /** Number of decimal places the exact value actually carries (trailing zeros ignored). */ export function DecimalPlaces(value: Big): number { // Big keeps digits in `c` with exponent `e`; places = digits after the point. const places = value.c.length - value.e - 1; return places > 0 ? places : 0; } /** * Converts an exact major-unit amount to integer minor units, refusing rather * than rounding when the amount carries more precision than the currency has. * Returns `null` on excess precision. */ export function ToMinorExact(value: Big, exponent: number): Big | null { if (DecimalPlaces(value) > exponent) { return null; } return value.times(new Big(10).pow(exponent)); } /** * Converts an exact major-unit amount to integer minor units, rounding half * away from zero. This is the one accounting rounding boundary the calculator * applies: an extended line amount (quantity × unit price) that falls between * minor units. Nothing else is rounded. */ export function ToMinorRounded(value: Big, exponent: number): Big { return value.times(new Big(10).pow(exponent)).round(0, Big.roundHalfUp); } /** Formats integer minor units as a fixed-point major-unit string, e.g. 14500 → "145.00". */ export function FormatMinor(minor: Big, exponent: number): string { return minor.div(new Big(10).pow(exponent)).toFixed(exponent); } export const Zero = new Big(0); /** Sum of Big values. */ export function SumBig(values: readonly Big[]): Big { return values.reduce((total, value) => total.plus(value), Zero); } /** * Floor division of non-negative integers held in `Big`, with exact remainder. * * `Big.div` rounds to `Big.DP` places using the current rounding mode, so a * quotient of x.999…9 past 20 places can come back as x+1. The correction loop * makes the result exact whatever `div` returned. */ export function FloorDivide(numerator: Big, denominator: Big): { Quotient: Big; Remainder: Big } { let quotient = numerator.div(denominator).round(0, Big.roundDown); while (quotient.times(denominator).gt(numerator)) { quotient = quotient.minus(1); } while (quotient.plus(1).times(denominator).lte(numerator)) { quotient = quotient.plus(1); } return { Quotient: quotient, Remainder: numerator.minus(quotient.times(denominator)) }; }