mirror of
https://github.com/hanzoai/money.git
synced 2026-09-30 22:56:51 +00:00
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:
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user