// Денежная арифметика без float. API отдаёт суммы decimal-строками // (shopspring/decimal на бэкенде); здесь они живут как целое × 10^-scale. // number для денег не используется нигде — ни на входе, ни внутри. export interface Decimal { readonly int: bigint; readonly scale: number; } const DECIMAL_RE = /^(-?)(\d+)(?:\.(\d+))?$/; function assertScale(scale: number): void { if (!Number.isSafeInteger(scale) || scale < 0) { throw new RangeError(`недопустимый scale: ${String(scale)}`); } } function pow10(n: number): bigint { assertScale(n); return 10n ** BigInt(n); } function make(int: bigint, scale: number): Decimal { assertScale(scale); return Object.freeze({ int, scale }); } /** Строгий разбор decimal-строки: без экспоненты, без запятой, без пробелов, только string. */ export function parseDecimal(s: unknown): Decimal { if (typeof s !== "string") { throw new TypeError(`ожидалась decimal-строка, получено ${typeof s}`); } const m = DECIMAL_RE.exec(s); if (m === null) { throw new SyntaxError(`некорректная decimal-строка: ${JSON.stringify(s.slice(0, 40))}`); } const [, sign = "", whole = "", frac = ""] = m; const int = BigInt(whole + frac); return make(sign === "-" ? -int : int, frac.length); } export function fromBigInt(n: bigint): Decimal { return make(n, 0); } function rescale(a: Decimal, scale: number): bigint { // Только повышение точности — без потерь. return a.int * pow10(scale - a.scale); } export function add(a: Decimal, b: Decimal): Decimal { const scale = Math.max(a.scale, b.scale); return make(rescale(a, scale) + rescale(b, scale), scale); } export function mul(a: Decimal, b: Decimal): Decimal { return make(a.int * b.int, a.scale + b.scale); } export function mulByBigInt(a: Decimal, n: bigint): Decimal { return make(a.int * n, a.scale); } /** Деление на 10^p — точное, просто сдвиг scale. */ export function divPow10(a: Decimal, p: number): Decimal { assertScale(p); return make(a.int, a.scale + p); } /** ROUND HALF UP (половина — от нуля), как decimal.Round в shopspring. */ export function round(a: Decimal, scale: number): Decimal { assertScale(scale); if (a.scale <= scale) { return make(rescale(a, scale), scale); } const divisor = pow10(a.scale - scale); const negative = a.int < 0n; const abs = negative ? -a.int : a.int; let q = abs / divisor; const r = abs % divisor; if (r * 2n >= divisor) { q += 1n; } return make(negative ? -q : q, scale); } export function compare(a: Decimal, b: Decimal): -1 | 0 | 1 { const scale = Math.max(a.scale, b.scale); const x = rescale(a, scale); const y = rescale(b, scale); return x < y ? -1 : x > y ? 1 : 0; } function format(int: bigint, scale: number): string { const negative = int < 0n; const digits = (negative ? -int : int).toString().padStart(scale + 1, "0"); const whole = digits.slice(0, digits.length - scale); const frac = digits.slice(digits.length - scale); const body = scale > 0 ? `${whole}.${frac}` : whole; return negative ? `-${body}` : body; } /** Канонический вид: без незначащих нулей справа; "0.00" и "-0" дают "0". */ export function toString(a: Decimal): string { let { int, scale } = a; while (scale > 0 && int % 10n === 0n) { int /= 10n; scale -= 1; } return format(int, scale); } /** Ровно scale знаков после точки, с округлением HALF UP. */ export function toFixed(a: Decimal, scale: number): string { const r = round(a, scale); return format(r.int, r.scale); }