package.json, tsconfig, eslint, генерация типов из OpenAPI-спеки, модули config/redact/errors/money/sanitize/cache с тестами и обезличенные фикстуры живых ответов API openrusrouter.ru. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
3.8 KiB
TypeScript
118 lines
3.8 KiB
TypeScript
// Денежная арифметика без 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);
|
|
}
|