mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 05:31:46 +02:00
54 lines
3.4 KiB
TypeScript
54 lines
3.4 KiB
TypeScript
/** Monetary storage uses nanodollars: 10^7 units per cent, 10^9 per USD.
|
|
* Numbers are accepted only at compatibility boundaries. All arithmetic uses
|
|
* integers; decimal inputs are rounded once, half away from zero. */
|
|
export const MONEY_SCALE = 10_000_000n;
|
|
export const MAX_MONEY_UNITS = 10n ** 24n - 1n;
|
|
export type MoneyInput = string | number;
|
|
|
|
function decimalUnits(value: MoneyInput, places: number): bigint {
|
|
if (typeof value === "number" && !Number.isFinite(value)) throw new Error("Money must be finite");
|
|
if (typeof value === "number" && Math.abs(value) > Number.MAX_SAFE_INTEGER) throw new Error("Use a decimal string for large monetary values");
|
|
const text = String(value);
|
|
if (text.length > 160) throw new Error("Money exceeds supported precision");
|
|
const match = /^([+-]?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(text);
|
|
if (!match) throw new Error("Invalid decimal money amount");
|
|
const exponent = Number(match[4] ?? 0);
|
|
if (!Number.isInteger(exponent) || Math.abs(exponent) > 100) throw new Error("Money exponent is out of range");
|
|
const digits = BigInt(match[2] + (match[3] ?? ""));
|
|
const shift = places + exponent - (match[3]?.length ?? 0);
|
|
const divisor = shift < 0 ? 10n ** BigInt(-shift) : 1n;
|
|
const magnitude = shift >= 0 ? digits * 10n ** BigInt(shift) : (digits + divisor / 2n) / divisor;
|
|
if (magnitude > MAX_MONEY_UNITS) throw new Error("Money exceeds numeric(24,7) storage");
|
|
return match[1] === "-" ? -magnitude : magnitude;
|
|
}
|
|
|
|
export function centsToUnits(value: MoneyInput): bigint { return decimalUnits(value, 7); }
|
|
export function usdToUnits(value: MoneyInput): bigint { return decimalUnits(value, 9); }
|
|
export function unitsToCents(value: bigint): string {
|
|
if (value > MAX_MONEY_UNITS || value < -MAX_MONEY_UNITS) throw new Error("Money exceeds numeric(24,7) storage");
|
|
const magnitude = value < 0n ? -value : value;
|
|
return `${value < 0n ? "-" : ""}${magnitude / MONEY_SCALE}.${String(magnitude % MONEY_SCALE).padStart(7, "0")}`;
|
|
}
|
|
export function normalizeCents(value: MoneyInput): string { return unitsToCents(centsToUnits(value)); }
|
|
export function usdToCents(value: MoneyInput): string { return unitsToCents(usdToUnits(value)); }
|
|
/** Exact editable USD value; unlike display formatting, this never rounds cents. */
|
|
export function centsToUsd(value: MoneyInput): string {
|
|
const units = centsToUnits(value);
|
|
const magnitude = units < 0n ? -units : units;
|
|
return `${units < 0n ? "-" : ""}${magnitude / 1_000_000_000n}.${String(magnitude % 1_000_000_000n).padStart(9, "0")}`;
|
|
}
|
|
/** Approximate legacy/UI value. Never use this conversion for a money decision. */
|
|
export function centsToNumber(value: MoneyInput): number { return Number(normalizeCents(value)); }
|
|
export function addCents(...values: MoneyInput[]): string { return unitsToCents(values.reduce((sum, value) => sum + centsToUnits(value), 0n)); }
|
|
export function subtractCents(a: MoneyInput, b: MoneyInput): string { return unitsToCents(centsToUnits(a) - centsToUnits(b)); }
|
|
export function compareCents(a: MoneyInput, b: MoneyInput): number {
|
|
const left = centsToUnits(a), right = centsToUnits(b);
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
export function formatUsdExact(cents: MoneyInput): string {
|
|
const units = centsToUnits(cents);
|
|
const magnitude = units < 0n ? -units : units;
|
|
const rounded = (magnitude + MONEY_SCALE / 2n) / MONEY_SCALE;
|
|
return `${units < 0n ? "-" : ""}$${rounded / 100n}.${String(rounded % 100n).padStart(2, "0")}`;
|
|
}
|