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