add MajorString: the exact inverse of ParseMinor

ParseMinor gave the ledger edge one exact decimal-string -> minor-units
conversion. The reverse had no home here, so every caller grew its own, and
they were wrong in two different ways.

String() looks like the reverse and is not: it renders the value's own scale,
so FromCents(100) is "1", not "1.00". Both parse back to 100, which is why the
round-trip law held and nobody noticed, but an API whose schema says two
decimals does not accept "1".

The hand-rolled spelling -- print cents/100, a dot, then cents%100 -- is worse.
Go's % keeps the sign of the dividend, so -1999 renders "-19.-99" and -1 gives
"0.-1". A refund is the most common negative amount in a payment system.

MajorString derives the digits from the rescaled integer and pads to the
currency's decimals, so it is fixed-scale, sign-correct, and float-free.
Display now shares its one scale-and-pad step, so the wire form and the human
form cannot drift.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-02 19:44:09 -07:00
co-authored by Hanzo Dev
parent dd33800850
commit 8dbf7d0e14
3 changed files with 167 additions and 11 deletions
+5 -1
View File
@@ -26,7 +26,11 @@ 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`
- **Formatting**: `Display` (symbol + thousands separators), `AsMajorUnits`
- **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
- **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
- **Integration**: `json.Marshaler`/`Unmarshaler`, `sql.Scanner`/`driver.Valuer`
+49 -10
View File
@@ -20,30 +20,69 @@ func FromFloat(f float64, cur Currency) Amount {
}
// AsMajorUnits returns the value as a float in major units (e.g. dollars) — for DISPLAY
// only; never round-trip money through it.
// only, and for the last inch of an external API whose schema genuinely demands a JSON
// number; never round-trip money through it, and never store what it returns. When you
// want the amount as text, that is MajorString, which is exact.
func (a Amount) AsMajorUnits() float64 {
f, _ := strconv.ParseFloat(a.v.String(), 64)
return f
}
// Display renders the value at the currency's decimals with grouped thousands and the
// currency symbol ("$1,234.56", "¥10000", "100,003.01 HUSD" when no symbol). Exact —
// derived from the integer value, not a float.
func (a Amount) Display() string {
// majorParts rescales the value to the currency's decimals and splits it into its sign,
// its integer digits and its zero-padded fraction. It is the ONE place the currency's
// scale is applied, so the two renderings below cannot drift apart, and it is derived
// from the integer value — no float64 is involved.
func (a Amount) majorParts() (neg bool, intPart, frac string) {
d := a.v.Rescale(a.cur.Decimals)
neg := d.Sign() < 0
neg = d.Sign() < 0
s := d.Abs().String()
intPart, frac := s, ""
intPart, frac = s, ""
if i := strings.IndexByte(s, '.'); i >= 0 {
intPart, frac = s[:i], s[i+1:]
}
// pad fraction to the currency's decimals for a stable display
// pad the fraction to the currency's decimals for a stable, fixed-scale rendering
for int32(len(frac)) < a.cur.Decimals {
frac += "0"
}
grouped := group(intPart)
out := grouped
return neg, intPart, frac
}
// MajorString renders the value in MAJOR units as a plain fixed-scale decimal string —
// "10.99", "1234567.89", "500" for a zero-decimal currency, "-19.99" for a refund. No
// symbol, no thousands separators: this is the form every payment API means when it
// documents an amount field as a decimal string, and it is the exact inverse of
// ParseMinor.
//
// String() is NOT this conversion, and reaching for it is the easy mistake. String()
// renders the value's own scale, so it drops trailing zeros: FromCents(100).String() is
// "1", not "1.00". Both parse back to the same amount — which is why the round-trip law
// holds for either — but an API handed "1" where its schema says two decimals is a
// support ticket, and a ledger export that renders $1.00 as "1" no longer reconciles
// column-wise. MajorString always pads to the currency's decimals.
//
// The other spelling to avoid is the hand-rolled one: print cents/100, a dot, then
// cents%100. Go's % keeps the sign of the dividend, so -1999 comes out as "-19.-99" and
// -1 as "0.-1" — and a refund is the most common negative amount there is. Deriving the
// digits from the rescaled integer has no such case.
func (a Amount) MajorString() string {
neg, intPart, frac := a.majorParts()
out := intPart
if a.cur.Decimals > 0 {
out += "." + frac
}
if neg {
return "-" + out
}
return out
}
// Display renders the value at the currency's decimals with grouped thousands and the
// currency symbol ("$1,234.56", "¥10000", "100,003.01 HUSD" when no symbol) — the
// human-facing form of MajorString. Exact, derived from the integer value, not a float.
func (a Amount) Display() string {
neg, intPart, frac := a.majorParts()
out := group(intPart)
if a.cur.Decimals > 0 {
out += "." + frac
}
+113
View File
@@ -0,0 +1,113 @@
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
package money
import "testing"
// TestMajorStringIsFixedScale states the property that makes MajorString a
// different thing from String: the rendering is padded to the currency's
// decimals, always.
//
// String() renders the value's OWN scale and therefore drops trailing zeros —
// FromCents(100).String() is "1". Handing "1" to a payment API that documents
// its amount field as a decimal of major units is how a $1.00 charge becomes a
// support ticket, so the wire needs a rendering that cannot do that.
func TestMajorStringIsFixedScale(t *testing.T) {
for _, tc := range []struct {
minor int64
cur Currency
want string
}{
{1099, USD, "10.99"},
{100, USD, "1.00"}, // String() gives "1"
{1000, USD, "10.00"}, // String() gives "10"
{0, USD, "0.00"}, // String() gives "0"
{1, USD, "0.01"},
{5, USD, "0.05"},
{123456789, USD, "1234567.89"}, // no thousands separators on the wire
// A zero-decimal currency must NOT gain two: ¥500 is 500 yen.
{500, JPY, "500"},
{0, JPY, "0"},
// A six-decimal token pads to six.
{1000000, USDC, "1.000000"},
{1, USDC, "0.000001"},
} {
if got := FromMinor(tc.minor, tc.cur).MajorString(); got != tc.want {
t.Errorf("FromMinor(%d, %s).MajorString() = %q, want %q", tc.minor, tc.cur.Code, got, tc.want)
}
}
}
// TestMajorStringNegativesAreWellFormed pins the case every hand-rolled
// cents→string helper gets wrong. The obvious spelling — print cents/100, a
// dot, then cents%100 — emits a SECOND minus sign for the fraction, because Go's
// % keeps the sign of the dividend: -1999 renders as "-19.-99". A refund is the
// most common negative amount in a payment system, so the bug ships straight to
// the credit path.
func TestMajorStringNegativesAreWellFormed(t *testing.T) {
for _, tc := range []struct {
minor int64
cur Currency
want string
}{
{-1999, USD, "-19.99"}, // the naive helper gives "-19.-99"
{-1, USD, "-0.01"}, // the naive helper gives "0.-1"
{-5, USD, "-0.05"}, // the naive helper gives "0.-5"
{-100, USD, "-1.00"},
{-10000, USD, "-100.00"},
{-500, JPY, "-500"},
} {
if got := FromMinor(tc.minor, tc.cur).MajorString(); got != tc.want {
t.Errorf("FromMinor(%d, %s).MajorString() = %q, want %q", tc.minor, tc.cur.Code, got, tc.want)
}
}
}
// TestMajorStringInvertsParseMinor states the round trip as a law: MajorString
// is the exact reverse of ParseMinor, in both directions, with no float64
// anywhere between them — including past 2^53 minor units, where a float64 has
// no bit left to tell neighbouring cents apart.
func TestMajorStringInvertsParseMinor(t *testing.T) {
for _, cur := range []Currency{USD, JPY, USDC} {
for _, minor := range []int64{
0, 1, -1, 99, -99, 100, -100, 1999, -1999, 123456, -123456,
1 << 40, -(1 << 40), 1 << 53, (1 << 53) + 1,
} {
s := FromMinor(minor, cur).MajorString()
got, err := ParseMinor(s, cur)
if err != nil {
t.Fatalf("ParseMinor(%q, %s) errored: %v", s, cur.Code, err)
}
if got != minor {
t.Fatalf("ParseMinor(FromMinor(%d, %s).MajorString() = %q) = %d, want %d",
minor, cur.Code, s, got, minor)
}
}
}
}
// TestDisplayAgreesWithMajorString keeps the two renderings from drifting:
// Display is MajorString plus thousands separators and the symbol, so stripping
// those must give MajorString back. They share one scale-and-pad step for
// exactly this reason.
func TestDisplayAgreesWithMajorString(t *testing.T) {
for _, minor := range []int64{0, 1, -1, 1099, -1099, 123456789, -123456789} {
a := FromCents(minor)
plain, disp := a.MajorString(), a.Display()
want := "$" + plain
if minor < 0 {
want = "-$" + plain[1:] // the sign leads, then the symbol
}
// Display groups thousands; compare with the separators removed.
stripped := ""
for _, r := range disp {
if r != ',' {
stripped += string(r)
}
}
if stripped != want {
t.Errorf("FromCents(%d): Display()=%q strips to %q, want %q (MajorString %q)",
minor, disp, stripped, want, plain)
}
}
}