Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/exact-money-decimal-rounding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@croco/billing-core": patch
---

Convert decimal money values to minor units with exact decimal-ratio arithmetic so half-up rounding is not distorted by floating-point multiplication.
49 changes: 44 additions & 5 deletions packages/billing-core/src/libs/Money.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import {
InvalidMoneyAmountProblem,
InvalidMoneyCurrencyProblem,
Expand Down Expand Up @@ -40,7 +40,7 @@
const normalizedCurrency = Money.normalizeCurrency(currency);
const fractionDigits = Money.getFractionDigits(normalizedCurrency);
const scale = 10 ** fractionDigits;
const scaledAmount = Money.applyRounding(amount * scale, roundingMode);
const scaledAmount = Money.scaleDecimalToInteger(amount, scale, roundingMode);
return new Money(scaledAmount, normalizedCurrency);
}

Expand Down Expand Up @@ -203,6 +203,31 @@
return `${sign}${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`;
}

private static scaleDecimalToInteger(
decimal: number,
scale: number,
roundingMode: MoneyRoundingMode,
): number {
const safeScale = Money.toSafeInteger(scale);

if (!Number.isFinite(decimal)) {
throw new InvalidMoneyAmountProblem(decimal);
}

const valueText = Money.normalizeDecimalText(decimal);
const sign = valueText.startsWith("-") ? BigInt(-1) : BigInt(1);
const unsignedText = valueText.replace(/^[+-]/, "");
const [integerPart, fractionalPart = ""] = unsignedText.split(".");
const digits = `${integerPart}${fractionalPart}`.replace(/^0+(?=\d)/, "") || "0";
const numerator = BigInt(safeScale) * BigInt(digits) * sign;
const denominator = BigInt(10) ** BigInt(fractionalPart.length);
const quotient = numerator / denominator;
const remainder = numerator % denominator;
const rounded = Money.roundBigQuotient(quotient, remainder, denominator, roundingMode);

return Money.toSafeInteger(Number(rounded));
}

private static simplifyRatio(ratio: DecimalRatio): DecimalRatio {
const denominatorSign = ratio.denominator < 0 ? -1 : 1;
const numerator = ratio.numerator * denominatorSign;
Expand Down Expand Up @@ -254,16 +279,30 @@
return absoluteRemainder * 2 >= denominator ? quotient + remainderSign : quotient;
}

private static applyRounding(value: number, roundingMode: MoneyRoundingMode): number {
private static roundBigQuotient(
quotient: bigint,
remainder: bigint,
denominator: bigint,
roundingMode: MoneyRoundingMode,
): bigint {
const zero = BigInt(0);

if (remainder === zero) {
return quotient;
}

const remainderSign = remainder > zero ? BigInt(1) : BigInt(-1);
const absoluteRemainder = remainder > zero ? remainder : -remainder;

if (roundingMode === "down") {
return Math.trunc(value);
return quotient;
}

if (roundingMode === "up") {
return value >= 0 ? Math.ceil(value) : Math.floor(value);
return quotient + remainderSign;
}

return value >= 0 ? Math.round(value) : -Math.round(Math.abs(value));
return absoluteRemainder * BigInt(2) >= denominator ? quotient + remainderSign : quotient;
}

private static toSafeInteger(value: number): number {
Expand Down
34 changes: 34 additions & 0 deletions packages/billing-core/src/tests/Money.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { Money } from "../libs/Money";
import {
InvalidMoneyAmountProblem,
InvalidMoneyCurrencyProblem,
MoneyCurrencyMismatchProblem,
MoneyDivisionByZeroProblem,
Expand Down Expand Up @@ -149,9 +150,42 @@ describe("Money", () => {

it("should create money from decimal amounts", () => {
expect(Money.fromDecimal(19.99, "usd").toJSON()).toEqual({ amount: 1999, currency: "USD" });
expect(Money.fromDecimal(0.3, "USD").amount).toBe(30);
expect(Money.zero("eur").toJSON()).toEqual({ amount: 0, currency: "EUR" });
});

it("should round exact decimal values to minor units", () => {
expect(Money.fromDecimal(1.005, "USD").amount).toBe(101);
expect(Money.fromDecimal(2.675, "USD").amount).toBe(268);
expect(Money.fromDecimal(-1.005, "USD").amount).toBe(-101);
});

it("should preserve valid amounts whose decimal ratios exceed safe integer intermediates", () => {
expect(Money.fromDecimal(0.1 + 0.2, "USD").amount).toBe(30);
expect(Money.fromDecimal(1 / 3, "USD").amount).toBe(33);
expect(Money.fromDecimal(1e-16, "USD").amount).toBe(0);
expect(Money.fromDecimal(1000000000000.01, "USD").amount).toBe(100000000000001);
expect(Money.fromDecimal(-0.1 - 0.2, "USD").amount).toBe(-30);
expect(Money.fromDecimal(-1 / 3, "USD").amount).toBe(-33);
});

it("should apply decimal rounding modes symmetrically", () => {
expect(Money.fromDecimal(1.005, "USD", "down").amount).toBe(100);
expect(Money.fromDecimal(1.005, "USD", "up").amount).toBe(101);
expect(Money.fromDecimal(-1.005, "USD", "down").amount).toBe(-100);
expect(Money.fromDecimal(-1.005, "USD", "up").amount).toBe(-101);
});

it("should reject non-finite or unrepresentable decimal amounts", () => {
expect(() => Money.fromDecimal(Number.NaN, "USD")).toThrow(InvalidMoneyAmountProblem);
expect(() => Money.fromDecimal(Number.POSITIVE_INFINITY, "USD")).toThrow(
InvalidMoneyAmountProblem,
);
expect(() => Money.fromDecimal(Number.MAX_SAFE_INTEGER, "USD")).toThrow(
InvalidMoneyAmountProblem,
);
});

it("should reject currency mismatch and invalid operations", () => {
const usd = new Money(1000, "USD");
const eur = new Money(1000, "EUR");
Expand Down
Loading