add ScaleMinor: scaling money by a rate rounds, it does not truncate

ParseMinor and MajorString gave the ledger edge its two exact conversions.
The third one — an amount scaled by a rate — had no home, so callers grew
their own, and every copy truncated.

commerce alone spells a percentage discount six different ways across five
files: int(math.Floor(float64(cents) * float64(pct) * 0.01)), the same
without the int(), the same with / 100 instead of * 0.01, a bare
currency.Cents(float64(price) * rate) that truncates toward zero on
conversion, and two integer forms that floor. Six spellings, one answer
they all agree on, and it is the wrong one.

The float is not what makes them wrong, which is worth stating because it
is the obvious suspect. Those callers start from an integer count of minor
units, so entering float64 is lossless and the product is exact; scanning
every whole-cent case up to 200000 minor units against every percentage
from 1 to 100 finds zero results where the float lands on the wrong side
of an integer. The error is entirely the truncation. 10% off 1999 is
199.9, and dropping the fraction gives 199 — a cent, on the same side of
the trade, every time. Over a 1c-to-$100 price book at 10/15/33% that is
15000 minor units withheld across 30000 lines: half a cent per line, never
random, never self-cancelling.

So ScaleMinor rounds HALF AWAY FROM ZERO, the rule ParseMinor and Rescale
already use, and it is the rule rather than a discount-specific one so an
amount cannot depend on which conversion carried it. A refund rounds like
the charge it reverses. The arithmetic runs in big.Int, so the rate is
applied exactly at any magnitude and only the final answer has to fit an
int64; when it does not, that is reported, because a scaled amount that
silently wraps is a balance nobody holds.

RateFromFloat comes with it, for the schemas that predate this package and
store a rate as a float64. It takes the shortest decimal that round-trips,
so the 0.1 a human typed comes back as 0.1. Rendering at a fixed precision
instead is the trap: %.2f turns a 0.375% fee into 0.38%, and two decimals
of a fraction turn it into 0.00. NaN and Inf are reported rather than
mapped to zero, for the same reason ParseMinor refuses to: a zero rate is
a legal rate, and a caller that cannot tell "no discount" from "unreadable
discount" charges full price and calls it a sale.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-02 20:20:14 -07:00
co-authored by Hanzo Dev
parent 8dbf7d0e14
commit e13b9ace7b
3 changed files with 285 additions and 3 deletions
+4 -3
View File
@@ -26,9 +26,10 @@ money.FromHUSD(wei) // 18-dec on-chain stablecoin
- **Aggregation**: `Sum` `Min` `Max` `Avg`
- **Exchange**: rate table + `Convert` / `ConvertWithRate`
- **Parsing**: `ParseAmount` (`$1,234.56`, `EUR 250,75`, `£99.99`), European decimal-comma, `ParseDecimal`
- **Ledger edge**: `ParseMinor`/`ParseCents` (decimal string → integer minor units) and its exact
inverse `MajorString` (integer minor units → fixed-scale decimal string) — the pair every billing
API needs, neither of which touches a float
- **Ledger edge**: `ParseMinor`/`ParseCents` (decimal string → integer minor units), its exact
inverse `MajorString` (integer minor units → fixed-scale decimal string), and `ScaleMinor`
(minor units × rate → minor units, rounded half away from zero) — the set every billing API
needs, none of which touches a float. `RateFromFloat` makes a legacy float64 rate exact first
- **Formatting**: `MajorString` (`"10.99"`, `"1.00"`, `"-19.99"` — the wire), `Display`
(symbol + thousands separators — the human), `AsMajorUnits` (float, display only)
- **Currencies**: ISO-4217 set (symbol, numeric code) + crypto (USDC/USDT/BTC/ETH/HUSD) + `Register` custom
+63
View File
@@ -0,0 +1,63 @@
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
package money
import (
"fmt"
"math"
"strconv"
"github.com/hanzoai/decimal"
)
// ScaleMinor scales an integer count of minor units by an exact rate and returns whole
// minor units — a percentage off a price, a commission on a charge, a tax on a subtotal.
// It is the third member of the ledger-edge set alongside ParseMinor (string → minor) and
// MajorString (minor → string): minor × rate → minor, the only one of the three that has
// to decide what to do with a fraction of a cent.
//
// It decides by ROUNDING HALF AWAY FROM ZERO, the same rule ParseMinor and Rescale use, so
// one amount cannot depend on which conversion it went through. The obvious spellings
// instead TRUNCATE — int64(float64(cents)*rate) drops the fraction toward zero, and
// math.Floor drops it toward negative infinity — and truncation is not a small error but a
// biased one: 10% off $19.99 is $1.999, which truncates to $1.99 and rounds to $2.00, and
// the missing cent always falls on the same side of the trade. Across a price book that is
// a systematic half-cent per line, never random, never self-cancelling.
//
// A rate is dimensionless, so no currency is needed: one minor unit scaled by 0.1 is a
// tenth of a minor unit whatever the currency's decimals are. Build the rate exactly —
// decimal.New(10, 2) for 10%, decimal.New(1500, 4) for 1500 basis points, RateFromFloat
// for a float64 that a legacy schema handed you.
//
// The result is computed in big.Int, so the rate is applied exactly at any magnitude; only
// the final answer has to fit an int64, and if it does not that is REPORTED. A scaled
// amount that silently wraps is a balance nobody holds.
func ScaleMinor(units int64, rate decimal.Decimal) (int64, error) {
scaled := decimal.New(units, 0).Mul(rate).Rescale(0).Coef()
if !scaled.IsInt64() {
return 0, fmt.Errorf("%w: %d minor units scaled by %s is %s", ErrOverflow, units, rate, scaled)
}
return scaled.Int64(), nil
}
// RateFromFloat recovers the exact decimal that a float64 rate was meant to be: the
// shortest decimal which round-trips to that float, so the 0.1 a human typed comes back as
// 0.1 and not as 0.1000000000000000055511151231257827.
//
// It exists because schemas predating this package store rates as float64 — a percent
// column, a commission, a tax rate — and the value has to become exact somewhere before it
// touches money. That conversion has exactly one correct spelling. Rendering with a fixed
// precision instead (%.2f and friends) silently truncates any rate finer than the format:
// a 0.375% fee becomes 0.38%, or with two decimals of a fraction, 0.00.
//
// This does not make the stored float exact — nothing can, the information is already
// gone. It recovers the decimal the float was the nearest representation OF, which for a
// human-entered rate is the number that was meant. NaN and ±Inf are not rates and are
// reported, never mapped to zero: a zero rate is a legal rate, and a caller that cannot
// tell "no discount" from "unreadable discount" charges full price and calls it a sale.
func RateFromFloat(f float64) (decimal.Decimal, error) {
if math.IsNaN(f) || math.IsInf(f, 0) {
return decimal.Decimal{}, fmt.Errorf("%w: %v is not a rate", ErrInvalidAmount, f)
}
return decimal.Parse(strconv.FormatFloat(f, 'f', -1, 64))
}
+218
View File
@@ -0,0 +1,218 @@
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
package money
import (
"errors"
"math"
"math/big"
"testing"
"github.com/hanzoai/decimal"
)
// pct is the exact rate for a whole percentage: 10 → 0.10.
func pct(p int64) decimal.Decimal { return decimal.New(p, 2) }
// TestScaleMinorRoundsHalfAwayFromZero pins the rule, and pins it against the exact values
// the truncating spellings produce — every `want` below that differs from `trunc` is a cent
// that the old arithmetic kept.
func TestScaleMinorRoundsHalfAwayFromZero(t *testing.T) {
for _, tc := range []struct {
units int64
rate decimal.Decimal
want int64
trunc int64 // what int64(float64(units)*rate) / math.Floor gave
}{
// The brief's price book. .5 and above rounds up, so the customer gets the cent.
{1999, pct(10), 200, 199}, // $19.99 @ 10% = 1.999
{1999, pct(15), 300, 299}, // 2.9985
{1999, pct(33), 660, 659}, // 6.5967
{995, pct(10), 100, 99}, // 0.995 — exactly a half, rounds away from zero
{995, pct(15), 149, 149}, // 1.4925 — below the half, unchanged
{995, pct(33), 328, 328}, // 3.2835
{3333, pct(10), 333, 333}, // 3.333 exactly representable as a third of a cent
{3333, pct(15), 500, 499}, // 4.9995
{3333, pct(33), 1100, 1099}, // 10.9989
{29, pct(10), 3, 2}, // 0.29 — the smallest prices are hit hardest
{29, pct(15), 4, 4}, // 0.435
{29, pct(33), 10, 9}, // 0.957
// Exact results must not move.
{1000, pct(10), 100, 100},
{2000, pct(50), 1000, 1000},
{0, pct(33), 0, 0},
// A rate of 1 and a rate of 0 are identity and annihilator.
{1999, decimal.New(1, 0), 1999, 1999},
{1999, decimal.New(0, 0), 0, 0},
// Basis points, the promotion spelling: 1500 bps = 15%.
{1999, decimal.New(1500, 4), 300, 299},
// A NEGATIVE amount is a refund and rounds like the charge it reverses: away from
// zero, so |result| matches. Truncation toward -Inf (math.Floor) would give -200
// here and toward zero would give -199; half-away-from-zero gives -200 because
// 1.999 rounds to 2.000 in magnitude.
{-1999, pct(10), -200, -199},
{-995, pct(10), -100, -99},
{-29, pct(33), -10, -9},
} {
got, err := ScaleMinor(tc.units, tc.rate)
if err != nil {
t.Fatalf("ScaleMinor(%d, %s) error: %v", tc.units, tc.rate, err)
}
if got != tc.want {
t.Errorf("ScaleMinor(%d, %s) = %d, want %d", tc.units, tc.rate, got, tc.want)
}
}
}
// TestScaleMinorBeatsTheFloat states the property that makes this worth having: the
// truncating float spelling is wrong on a large fraction of ordinary prices, always in the
// same direction.
func TestScaleMinorBeatsTheFloat(t *testing.T) {
var truncLoss, scaleLoss int64
var cases int
for units := int64(1); units <= 10000; units++ {
for _, p := range []int64{10, 15, 33} {
exact := exactHalfAway(units, p)
got, err := ScaleMinor(units, pct(p))
if err != nil {
t.Fatal(err)
}
if got != exact {
t.Fatalf("ScaleMinor(%d, %d%%) = %d, want %d", units, p, got, exact)
}
trunc := int64(math.Floor(float64(units) * float64(p) * 0.01))
truncLoss += exact - trunc
scaleLoss += exact - got
cases++
}
}
if scaleLoss != 0 {
t.Fatalf("ScaleMinor lost %d minor units over %d cases; it must lose none", scaleLoss, cases)
}
if truncLoss <= 0 {
t.Fatalf("expected the truncating spelling to be biased low; it lost %d", truncLoss)
}
t.Logf("over %d cases the truncating float spelling withheld %d minor units; ScaleMinor withholds %d",
cases, truncLoss, scaleLoss)
}
// exactHalfAway is units*p/100 by integer arithmetic, rounded half away from zero.
func exactHalfAway(units, p int64) int64 {
n := new(big.Int).Mul(big.NewInt(units), big.NewInt(p))
neg := n.Sign() < 0
a := new(big.Int).Abs(n)
q, r := new(big.Int).QuoRem(a, big.NewInt(100), new(big.Int))
if new(big.Int).Mul(r, big.NewInt(2)).Cmp(big.NewInt(100)) >= 0 {
q.Add(q, big.NewInt(1))
}
if neg {
q.Neg(q)
}
return q.Int64()
}
// TestScaleMinorOverflowIsReported — a scaled amount that does not fit is an error, never a
// wrapped number.
func TestScaleMinorOverflowIsReported(t *testing.T) {
if _, err := ScaleMinor(math.MaxInt64, decimal.New(2, 0)); !errors.Is(err, ErrOverflow) {
t.Errorf("MaxInt64 doubled: got %v, want ErrOverflow", err)
}
if _, err := ScaleMinor(math.MinInt64, decimal.New(2, 0)); !errors.Is(err, ErrOverflow) {
t.Errorf("MinInt64 doubled: got %v, want ErrOverflow", err)
}
// The boundary itself fits.
if got, err := ScaleMinor(math.MaxInt64, decimal.New(1, 0)); err != nil || got != math.MaxInt64 {
t.Errorf("MaxInt64 x 1 = %d, %v; want %d, nil", got, err, int64(math.MaxInt64))
}
}
// TestScaleMinorIsExactAtAnyMagnitude — the rate is applied in big.Int, so an amount past
// the float64 integer limit (2^53) is scaled exactly rather than approximately.
func TestScaleMinorIsExactAtAnyMagnitude(t *testing.T) {
const units = int64(1) << 60 // 1152921504606846976, far past 2^53
got, err := ScaleMinor(units, pct(50))
if err != nil {
t.Fatal(err)
}
if want := units / 2; got != want {
t.Errorf("ScaleMinor(2^60, 50%%) = %d, want %d", got, want)
}
// The float spelling cannot even represent the operands here.
if float64(units)*0.5 == float64(units/2) {
return // it happened to land; the exactness claim above is what matters
}
t.Logf("float64 spelling gives %.0f, exact is %d", float64(units)*0.5, units/2)
}
// TestRateFromFloat recovers the decimal a float64 was the nearest representation of.
func TestRateFromFloat(t *testing.T) {
for _, tc := range []struct {
f float64
want string
}{
{0.1, "0.1"},
{0.15, "0.15"},
{0.33, "0.33"},
{0.075, "0.075"},
{1.0 / 3.0, "0.3333333333333333"},
{0, "0"},
{1, "1"},
{-0.1, "-0.1"},
{0.0001, "0.0001"},
} {
got, err := RateFromFloat(tc.f)
if err != nil {
t.Fatalf("RateFromFloat(%v) error: %v", tc.f, err)
}
if got.String() != tc.want {
t.Errorf("RateFromFloat(%v) = %s, want %s", tc.f, got, tc.want)
}
}
}
// TestRateFromFloatRejectsNonRates — NaN and Inf are reported, not mapped to a zero rate.
func TestRateFromFloatRejectsNonRates(t *testing.T) {
for _, f := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
if _, err := RateFromFloat(f); !errors.Is(err, ErrInvalidAmount) {
t.Errorf("RateFromFloat(%v): got %v, want ErrInvalidAmount", f, err)
}
}
}
// TestRateFromFloatFeedsScaleMinor — the two compose into the conversion a legacy float64
// percent column actually needs, and it is exact where the direct float multiply is not.
func TestRateFromFloatFeedsScaleMinor(t *testing.T) {
for _, tc := range []struct {
units int64
f float64
want int64
trunc int64 // currency.Cents(float64(price) * amt.Percent)
}{
{1999, 0.10, 200, 199},
{1999, 0.15, 300, 299},
{1999, 0.33, 660, 659},
{995, 1.0 / 3.0, 332, 331},
{29, 1.0 / 3.0, 10, 9},
{3333, 0.15, 500, 499},
} {
rate, err := RateFromFloat(tc.f)
if err != nil {
t.Fatal(err)
}
got, err := ScaleMinor(tc.units, rate)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("ScaleMinor(%d, RateFromFloat(%v)) = %d, want %d", tc.units, tc.f, got, tc.want)
}
if trunc := int64(float64(tc.units) * tc.f); trunc != tc.trunc {
t.Errorf("the old spelling int64(float64(%d)*%v) = %d, expected it to give %d",
tc.units, tc.f, trunc, tc.trunc)
}
}
}