Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
85 lines
3.3 KiB
TypeScript
85 lines
3.3 KiB
TypeScript
export const DECIMAL_RADIX = 10n;
|
|
|
|
const SIGN_LENGTH = 1;
|
|
const NEGATIVE_SIGN = -1n;
|
|
const POSITIVE_SIGN = 1n;
|
|
const ZERO_UNITS = 0n;
|
|
const EQUAL = 0;
|
|
const GREATER_THAN = 1;
|
|
const LESS_THAN = -1;
|
|
const INTEGER_SCALE = 0;
|
|
const MIN_WHOLE_DIGITS = 1;
|
|
const DIGITS_START = 0;
|
|
const PREVIOUS_DIGIT_OFFSET = 1;
|
|
const DIGIT_STEP = 1;
|
|
const ROUNDING_DIVISOR = 2n;
|
|
|
|
export type Decimal = { scale: number; value: bigint };
|
|
|
|
export function decimalFromString(value: string): Decimal {
|
|
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
|
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
|
|
throw new Error('Invalid decimal string');
|
|
}
|
|
const negative = value.startsWith('-');
|
|
const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
|
|
return {
|
|
value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
|
|
scale: fraction.length,
|
|
};
|
|
}
|
|
|
|
export function add(left: Decimal, right: Decimal): Decimal {
|
|
const scale = Math.max(left.scale, right.scale);
|
|
return {
|
|
value:
|
|
left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
|
|
right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
|
|
scale,
|
|
};
|
|
}
|
|
|
|
export function subtract(left: Decimal, right: Decimal): Decimal {
|
|
return add(left, { value: -right.value, scale: right.scale });
|
|
}
|
|
|
|
export function multiply(left: Decimal, right: Decimal): Decimal {
|
|
return { value: left.value * right.value, scale: left.scale + right.scale };
|
|
}
|
|
|
|
export function isZero(value: Decimal): boolean {
|
|
return value.value === ZERO_UNITS;
|
|
}
|
|
|
|
export function compare(left: Decimal, right: Decimal): number {
|
|
const difference = subtract(left, right).value;
|
|
return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN;
|
|
}
|
|
|
|
export function formatDecimal({ value, scale }: Decimal): string {
|
|
if (value === ZERO_UNITS) return '0';
|
|
const sign = value < ZERO_UNITS ? '-' : '';
|
|
if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
|
|
const digits = (value < ZERO_UNITS ? -value : value)
|
|
.toString()
|
|
.padStart(scale + MIN_WHOLE_DIGITS, '0');
|
|
const whole = digits.slice(DIGITS_START, -scale) || '0';
|
|
const rawFraction = digits.slice(-scale);
|
|
let fractionEnd = rawFraction.length;
|
|
while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP;
|
|
const fraction = rawFraction.slice(DIGITS_START, fractionEnd);
|
|
const fractionalPart = fraction ? '.' + fraction : '';
|
|
return `${sign}${whole}${fractionalPart}`;
|
|
}
|
|
|
|
// Round half away from zero; callers choose presentation precision explicitly.
|
|
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
|
if (right.value === ZERO_UNITS) throw new Error('Division by zero');
|
|
const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
|
|
const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
|
|
const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
|
|
const n = numerator < ZERO_UNITS ? -numerator : numerator;
|
|
const d = denominator < ZERO_UNITS ? -denominator : denominator;
|
|
return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
|
|
}
|