feat: ParseMinor/ParseCents — the one exact decimal-string → minor-units conversion

Upstream billing APIs (DigitalOcean, AWS, GCP) report money as a decimal
STRING and every ledger stores an integer, so this conversion sits on the
edge of the money path. It had been rewritten by hand three times — vm,
commerce and ai each carried a private copy — and the copies disagreed
about real inputs: "1,234.56" was 123456 in one and 0 in the other two;
"-5.00" was 0, 0 and -500. Three answers means at least two are wrong,
and nothing in the code said which.

All three shared one root cause: ParseFloat, then multiply by 100. That
loses a cent on ordinary prices (19.99 has no exact binary form, so
f*100 is 1998.999… and truncation gives 1998) and the error is unbounded
above 2^53 minor units. This is the module that already holds exact
decimals and the FromMinor/FromCents constructors; the inverse belongs
here beside them, not three times in three callers.

Two policies the copies got wrong are fixed rather than reproduced:

  - an unreadable amount returns an ERROR, never 0. Zero is a legal
    amount, so a caller that cannot tell "spent nothing" from "could not
    read what was spent" reports a confident $0 and the spend vanishes.
  - a negative parses and STAYS negative. On these APIs it is a credit
    or a refund; clamping it to zero overstates spend by the refund.

int64 overflow is refused too. Wrapping is worse than refusing: it
reports a balance of the wrong sign.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-01 22:05:56 -07:00
co-authored by Hanzo Dev
parent b70a679c4b
commit dd33800850
5 changed files with 233 additions and 3 deletions
+4
View File
@@ -17,4 +17,8 @@ var (
ErrNoRate = errors.New("money: no exchange rate")
// ErrNonPositive is returned when a positive value was required (split parts, ratios).
ErrNonPositive = errors.New("money: value must be positive")
// ErrOverflow is returned when an exact value does not fit the requested
// machine type. It is never a rounded or truncated answer: a money conversion
// that silently wraps reports a balance nobody holds.
ErrOverflow = errors.New("money: value does not fit an int64")
)
+1 -1
View File
@@ -2,4 +2,4 @@ module github.com/hanzoai/money
go 1.23
require github.com/hanzoai/decimal v0.1.1
require github.com/hanzoai/decimal v0.1.2
+2 -2
View File
@@ -1,2 +1,2 @@
github.com/hanzoai/decimal v0.1.1 h1:vANjwEzfdq1EmsgaPR0CMMi2qxuR8+pbK6/InJVkRmo=
github.com/hanzoai/decimal v0.1.1/go.mod h1:jTNufaSe93avQTuXYP3ns2THBtRPR2SbaC0EdODLAFI=
github.com/hanzoai/decimal v0.1.2 h1:QUprOn6BSxQCZfj9YwISV+ouPoN0S9p6LZYk7O7JIYA=
github.com/hanzoai/decimal v0.1.2/go.mod h1:jTNufaSe93avQTuXYP3ns2THBtRPR2SbaC0EdODLAFI=
+42
View File
@@ -3,6 +3,7 @@
package money
import (
"fmt"
"strings"
"github.com/hanzoai/decimal"
@@ -77,6 +78,47 @@ func parseAmount(s string, commaDecimal bool, defaultCode ...string) (Amount, er
return Amount{v: d, cur: cur}, nil
}
// ParseMinor parses a decimal string of MAJOR units — "19.99", "1,234.56", "-5.00" —
// into an exact integer count of cur's minor units. It is the inverse of FromMinor,
// and it is the conversion every ledger edge needs, because upstream billing APIs
// (DigitalOcean, AWS, GCP) report money as a decimal STRING while every ledger stores
// an integer.
//
// The whole point is that it goes nowhere near float64. The obvious spelling of this
// conversion — ParseFloat then multiply by 100 — loses a cent on ordinary prices:
// 19.99 has no exact binary representation, so f*100 is 1998.999… and truncation
// gives 1998. Rounding papers over that one, but the float is still an approximation
// of the money and the error is unbounded in the large: past 2^53 minor units the
// nearest float64 is not the amount at all. Parsing the digits exactly has no such
// regime.
//
// Errors are RETURNED, never absorbed. Mapping an unparseable amount to zero is the
// expensive bug in this family: zero is a legal amount, so a caller that cannot see
// the difference between "the account spent nothing" and "we could not read what the
// account spent" reports a confident $0 and the spend silently vanishes from the
// ledger. The caller decides what an unreadable row means; this function only
// reports that it was unreadable. An empty string is an error for the same reason —
// an absent field is a fact the caller must handle, not a zero.
//
// A NEGATIVE PARSES AND STAYS NEGATIVE. On these APIs a negative row is a credit or
// a refund, and clamping it to zero overstates spend by exactly the amount refunded.
// Rounding is half-away-from-zero, so a credit rounds like the charge it reverses.
func ParseMinor(s string, cur Currency) (int64, error) {
d, err := ParseDecimal(s)
if err != nil {
return 0, fmt.Errorf("%w: %q", ErrInvalidAmount, s)
}
minor := d.Rescale(cur.Decimals).Coef()
if !minor.IsInt64() {
return 0, fmt.Errorf("%w: %q is %s minor units of %s", ErrOverflow, s, minor, cur.Code)
}
return minor.Int64(), nil
}
// ParseCents is ParseMinor for USD — the inverse of FromCents, and the spelling the
// dollar-denominated cost APIs want.
func ParseCents(s string) (int64, error) { return ParseMinor(s, USD) }
// ParseDecimal parses just the numeric part (separators handled) — no currency.
func ParseDecimal(s string) (decimal.Decimal, error) {
num, err := cleanNumber(strings.TrimSpace(s), false)
+184
View File
@@ -0,0 +1,184 @@
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
package money
import (
"errors"
"math"
"math/big"
"strconv"
"testing"
)
// TestParseCents is the table three separate hand-rolled converters used to answer
// differently. Each case below is a real shape the DigitalOcean/AWS/GCP billing APIs
// emit, and the three copies this function replaces disagreed on half of them:
// "1,234.56" was 123456 in one and 0 in the other two, "-5.00" was 0, 0 and -500.
func TestParseCents(t *testing.T) {
cases := []struct {
name string
in string
want int64
}{
// ── the ordinary case, and the one a float64 gets wrong ──────────────
{"cent-precise price", "19.99", 1999},
{"whole dollars", "12", 1200},
{"trailing zero cent", "9.90", 990},
{"sub-dollar", "0.29", 29},
{"zero", "0", 0},
{"zero with cents", "0.00", 0},
// ── the thousands separator DO actually emits on large invoices ──────
{"thousands separator", "1,234.56", 123456},
{"multiple groups", "1,234,567.89", 123456789},
{"grouped, no fraction", "12,345", 1234500},
// ── a negative is a CREDIT. Swallowing it overstates spend. ──────────
{"credit", "-5.00", -500},
{"credit, cent-precise", "-19.99", -1999},
{"grouped credit", "-1,234.56", -123456},
// ── sign and space handling ──────────────────────────────────────────
{"explicit plus", "+7.50", 750},
{"surrounding space", " 42.42 ", 4242},
// ── sub-cent input rounds half-away-from-zero, both directions ───────
{"rounds up at the half", "0.005", 1},
{"rounds down below the half", "0.004", 0},
{"credit rounds away from zero", "-0.005", -1},
{"third decimal", "1.234", 123},
{"third decimal, up", "1.235", 124},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseCents(tc.in)
if err != nil {
t.Fatalf("ParseCents(%q) errored: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("ParseCents(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
// TestParseCentsRejects pins the half that matters most: an amount that cannot be
// read is an ERROR, not a zero. Zero is a legal amount, so a converter that maps
// garbage to it hands the caller a confident "$0.00 spent" for a row it could not
// read at all — the spend disappears from the ledger and nothing reports it.
func TestParseCentsRejects(t *testing.T) {
for _, in := range []string{
"", // absent field — a fact to handle, not a zero
" ", // whitespace only
"abc", // not a number
"12.34.56", // not a decimal
"$12.34", // symbol: ParseAmount's job, not this one
"12.34 USD", // ditto for a code
"1.2e3", // exponent notation is not a money literal
"--5", // malformed sign
"12,34,5.6.7", // salad
"NaN",
"Inf",
} {
t.Run(strconv.Quote(in), func(t *testing.T) {
got, err := ParseCents(in)
if err == nil {
t.Fatalf("ParseCents(%q) = %d with no error — an unreadable amount became a legal one", in, got)
}
if !errors.Is(err, ErrInvalidAmount) {
t.Fatalf("ParseCents(%q) error = %v, want it to match ErrInvalidAmount", in, err)
}
if got != 0 {
t.Fatalf("ParseCents(%q) = %d alongside an error, want 0", in, got)
}
})
}
}
// TestParseMinorHonorsCurrencyScale proves the general form is not a USD function in
// disguise: the scale comes from the currency, so a zero-decimal currency does not
// silently gain two.
func TestParseMinorHonorsCurrencyScale(t *testing.T) {
cases := []struct {
in string
cur Currency
want int64
}{
{"1234", JPY, 1234}, // 0 decimals: yen are already minor units
{"1,234", JPY, 1234}, // separators still handled
{"1234.6", JPY, 1235}, // rounds to the currency's precision
{"12.34", USD, 1234}, // 2 decimals
{"12.34", EUR, 1234}, // ditto
{"-1234", JPY, -1234}, // credits survive at any scale
{"0.000000000000000001", HUSD, 1}, // 18 decimals: one wei-equivalent
}
for _, tc := range cases {
t.Run(tc.cur.Code+"/"+tc.in, func(t *testing.T) {
got, err := ParseMinor(tc.in, tc.cur)
if err != nil {
t.Fatalf("ParseMinor(%q, %s) errored: %v", tc.in, tc.cur.Code, err)
}
if got != tc.want {
t.Fatalf("ParseMinor(%q, %s) = %d, want %d", tc.in, tc.cur.Code, got, tc.want)
}
})
}
}
// TestParseMinorOverflows guards the boundary an int64 return imposes. Wrapping is
// the one failure mode worse than refusing: it reports a balance of the wrong SIGN.
func TestParseMinorOverflows(t *testing.T) {
// One minor unit past math.MaxInt64, written as major units.
tooBig := new(big.Int).Add(big.NewInt(math.MaxInt64), big.NewInt(1))
s := new(big.Int).Quo(tooBig, big.NewInt(100)).String() + "." +
tooBig.String()[len(tooBig.String())-2:]
if got, err := ParseMinor(s, USD); err == nil {
t.Fatalf("ParseMinor(%q, USD) = %d with no error — an int64 cannot hold it", s, got)
} else if !errors.Is(err, ErrOverflow) {
t.Fatalf("ParseMinor(%q, USD) error = %v, want it to match ErrOverflow", s, err)
}
// The largest amount that DOES fit must still convert, so the guard is a
// boundary and not a cap that rejects legitimate money.
ok := strconv.FormatInt(math.MaxInt64/100, 10) + ".00"
if _, err := ParseMinor(ok, USD); err != nil {
t.Fatalf("ParseMinor(%q, USD) errored on an amount that fits: %v", ok, err)
}
}
// TestParseMinorIsExactNotFloat is the property the three replaced converters could
// not hold. Every cent value in a wide sweep must round-trip through its decimal
// string EXACTLY; the float spelling (int64(f*100) / math.Round(f*100)) drifts,
// which is how prices like 19.99 became 1998.
func TestParseMinorIsExactNotFloat(t *testing.T) {
for cents := int64(-20000); cents <= 20000; cents++ {
s := FromCents(cents).String()
got, err := ParseCents(s)
if err != nil {
t.Fatalf("ParseCents(%q) errored: %v", s, err)
}
if got != cents {
t.Fatalf("ParseCents(%q) = %d, want %d — the conversion is not exact", s, got, cents)
}
}
}
// TestParseCentsInvertsFromCents states the relationship as a law: ParseCents undoes
// FromCents for every representable amount, including the extremes where a float64
// has no bit left to distinguish neighbouring cents.
func TestParseCentsInvertsFromCents(t *testing.T) {
for _, cents := range []int64{
0, 1, -1, 99, 100, -100, 1999, -1999, 123456, -123456,
1 << 40, -(1 << 40), 1 << 53, (1 << 53) + 1, math.MaxInt64 / 100,
} {
s := FromCents(cents).String()
got, err := ParseCents(s)
if err != nil {
t.Fatalf("ParseCents(%q) errored: %v", s, err)
}
if got != cents {
t.Fatalf("ParseCents(FromCents(%d).String() = %q) = %d, want %d", cents, s, got, cents)
}
}
}