From dd2f28b51133d77e6b27ecd3632c4dab5a1702d8 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Wed, 23 Sep 2026 01:10:05 +0900 Subject: [PATCH] fix(billing-core): round decimal money exactly --- .changeset/exact-money-decimal-rounding.md | 5 ++ packages/billing-core/src/libs/Money.ts | 49 +++++++++++++++++-- packages/billing-core/src/tests/Money.spec.ts | 34 +++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 .changeset/exact-money-decimal-rounding.md diff --git a/.changeset/exact-money-decimal-rounding.md b/.changeset/exact-money-decimal-rounding.md new file mode 100644 index 0000000000..0a046b29db --- /dev/null +++ b/.changeset/exact-money-decimal-rounding.md @@ -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. diff --git a/packages/billing-core/src/libs/Money.ts b/packages/billing-core/src/libs/Money.ts index 06c6d8c681..f5b540ad5c 100644 --- a/packages/billing-core/src/libs/Money.ts +++ b/packages/billing-core/src/libs/Money.ts @@ -40,7 +40,7 @@ export class Money { 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); } @@ -203,6 +203,31 @@ export class Money { 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; @@ -254,16 +279,30 @@ export class Money { 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 { diff --git a/packages/billing-core/src/tests/Money.spec.ts b/packages/billing-core/src/tests/Money.spec.ts index f26622eeae..a66e148f15 100644 --- a/packages/billing-core/src/tests/Money.spec.ts +++ b/packages/billing-core/src/tests/Money.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { Money } from "../libs/Money"; import { + InvalidMoneyAmountProblem, InvalidMoneyCurrencyProblem, MoneyCurrencyMismatchProblem, MoneyDivisionByZeroProblem, @@ -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");