mirror of
https://github.com/hanzoai/money.git
synced 2026-09-30 22:56:51 +00:00
feat: full money library — currencies/ISO dataset, Display, Allocate, Aggregate, Exchange, Parser, JSON+SQL
Superset of gocanto/money's surface with the big.Int precision core (fiat + 18-dec crypto, no int64/uint64 ceiling). Adds: ISO-4217 currency dataset (symbol/numeric) + crypto + Register; Display (symbol + thousands separators) + AsMajorUnits; Allocate by ratios; Sum/Min/Max/Avg; Exchange rates + Convert; ParseAmount (symbols/codes/separators, European comma) + ParseDecimal; JSON Unmarshal + sql.Scanner/driver.Valuer; FromXXX helpers; sentinel errors.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
# money
|
||||
|
||||
The one exact money type for Go: an `Amount` in a `Currency`, backed by
|
||||
[`hanzoai/decimal`](https://github.com/hanzoai/decimal) (big.Int fixed-point).
|
||||
The one exact money type for Go — an `Amount` in a `Currency`, backed by
|
||||
[`hanzoai/decimal`](https://github.com/hanzoai/decimal) (**big.Int** fixed-point).
|
||||
|
||||
The community money pattern — never floats, integer-exact, `Add`/`Sub`/`Split`/`Allocate`,
|
||||
same-currency safety — **without the precision ceiling**. int64/uint64 money libraries cap
|
||||
around 19 digits (~`$9.20` at 18 decimals); this holds a fiat cent *and* an 18-decimal
|
||||
Martin Fowler's Money pattern (never floats, integer-exact, `Add`/`Sub`/`Split`/`Allocate`,
|
||||
same-currency safety) **without the precision ceiling**. int64/uint64 money libraries cap
|
||||
around 19 digits (~`$9.20` at 18 decimals); this holds a fiat cent **and** an 18-decimal
|
||||
on-chain balance exactly.
|
||||
|
||||
```go
|
||||
@@ -13,10 +13,25 @@ price, _ := money.Parse("6.60", money.USD)
|
||||
cost := price.Mul(decimal.New(200, 0)).Mul(decimal.MustParse("0.000001")) // "0.00132", never 0
|
||||
bal, _ := money.FromCents(100).Sub(cost) // "0.99868"
|
||||
|
||||
husd, _ := money.Parse("100003.01", money.HUSD) // 18-dec on-chain unit
|
||||
husd.MinorString() // "100003010000000000000000"
|
||||
|
||||
parts, _ := money.FromCents(100).Split(3) // 34 + 33 + 33 = 100, nothing lost
|
||||
money.FromCents(123456).Display() // "$1,234.56"
|
||||
parts, _ := money.FromCents(10000).Allocate(50, 30, 20) // $50 / $30 / $20, nothing lost
|
||||
a, _ := money.ParseAmount("$1,234.56") // symbol/code/separator aware
|
||||
money.FromHUSD(wei) // 18-dec on-chain stablecoin
|
||||
```
|
||||
|
||||
Currencies carry their smallest-unit decimals (USD 2, USDC 6, HUSD/ETH 18). BSD-3-Clause.
|
||||
## Features
|
||||
- **Exact, uncapped**: big.Int core — fiat cents to 18-decimal crypto wei, no float, no ceiling
|
||||
- **Arithmetic**: `Add` `Sub` `Mul` `Neg` `Cmp` `Sign`, same-currency safety (zero-value agnostic)
|
||||
- **Allocation**: `Split(n)` and `Allocate(ratios…)` — no sub-unit ever lost
|
||||
- **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`
|
||||
- **Currencies**: ISO-4217 set (symbol, numeric code) + crypto (USDC/USDT/BTC/ETH/HUSD) + `Register` custom
|
||||
- **Integration**: `json.Marshaler`/`Unmarshaler`, `sql.Scanner`/`driver.Valuer`
|
||||
|
||||
## Packages
|
||||
Single package `github.com/hanzoai/money` (`Amount`, `Currency`, `Exchange`, parse/format/aggregate),
|
||||
on `github.com/hanzoai/decimal`.
|
||||
|
||||
Original work (big.Int core written from scratch, not a fork). BSD-3-Clause.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
|
||||
|
||||
package money
|
||||
|
||||
import "github.com/hanzoai/decimal"
|
||||
|
||||
// Sum adds all amounts, which must share a currency (a zero-value operand is agnostic).
|
||||
// The empty sum is a zero-value Amount.
|
||||
func Sum(amounts ...Amount) (Amount, error) {
|
||||
acc := Amount{}
|
||||
for _, a := range amounts {
|
||||
var err error
|
||||
if acc, err = acc.Add(a); err != nil {
|
||||
return Amount{}, err
|
||||
}
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
// Min returns the smallest amount (same currency). Errors on an empty list or a currency
|
||||
// mismatch.
|
||||
func Min(amounts ...Amount) (Amount, error) {
|
||||
if len(amounts) == 0 {
|
||||
return Amount{}, ErrNonPositive
|
||||
}
|
||||
m := amounts[0]
|
||||
for _, a := range amounts[1:] {
|
||||
if _, err := m.resolveCur(a); err != nil {
|
||||
return Amount{}, err
|
||||
}
|
||||
if a.Cmp(m) < 0 {
|
||||
m = a
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Max returns the largest amount (same currency).
|
||||
func Max(amounts ...Amount) (Amount, error) {
|
||||
if len(amounts) == 0 {
|
||||
return Amount{}, ErrNonPositive
|
||||
}
|
||||
m := amounts[0]
|
||||
for _, a := range amounts[1:] {
|
||||
if _, err := m.resolveCur(a); err != nil {
|
||||
return Amount{}, err
|
||||
}
|
||||
if a.Cmp(m) > 0 {
|
||||
m = a
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Avg returns the mean, rounded to the currency's decimals (half-away-from-zero). Errors
|
||||
// on an empty list or a currency mismatch.
|
||||
func Avg(amounts ...Amount) (Amount, error) {
|
||||
if len(amounts) == 0 {
|
||||
return Amount{}, ErrNonPositive
|
||||
}
|
||||
s, err := Sum(amounts...)
|
||||
if err != nil {
|
||||
return Amount{}, err
|
||||
}
|
||||
cur := s.cur
|
||||
if cur.Code == "" {
|
||||
cur = amounts[0].cur
|
||||
}
|
||||
mean := s.v.Quo(decimal.New(int64(len(amounts)), 0), cur.Decimals)
|
||||
return Amount{v: mean, cur: cur}, nil
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
|
||||
|
||||
package money
|
||||
|
||||
import "strings"
|
||||
|
||||
// Currency is a currency's identity: its ISO-4217 (or custom) code, the number of
|
||||
// decimal places in its smallest unit (Decimals — USD 2, JPY 0, an 18-decimal ERC-20 18),
|
||||
// a display Symbol, and the ISO numeric code where applicable. Custom currencies (crypto,
|
||||
// loyalty points) are first-class — construct one directly or Register it.
|
||||
type Currency struct {
|
||||
Code string // ISO-4217 alpha code or custom ("USD", "HUSD")
|
||||
Decimals int32 // fractional digits of the smallest unit
|
||||
Symbol string // display grapheme ("$", "€", "₿")
|
||||
Numeric string // ISO-4217 numeric code ("840"); "" for custom
|
||||
Name string
|
||||
}
|
||||
|
||||
// registry holds all known currencies by code; seeded with the tables below and
|
||||
// extendable via Register.
|
||||
var registry = map[string]Currency{}
|
||||
|
||||
func reg(c Currency) Currency {
|
||||
registry[strings.ToUpper(c.Code)] = c
|
||||
return c
|
||||
}
|
||||
|
||||
// Fiat (ISO 4217) — the common set; extend via Register for the long tail.
|
||||
var (
|
||||
USD = reg(Currency{"USD", 2, "$", "840", "US Dollar"})
|
||||
EUR = reg(Currency{"EUR", 2, "€", "978", "Euro"})
|
||||
GBP = reg(Currency{"GBP", 2, "£", "826", "Pound Sterling"})
|
||||
JPY = reg(Currency{"JPY", 0, "¥", "392", "Yen"})
|
||||
CNY = reg(Currency{"CNY", 2, "¥", "156", "Yuan Renminbi"})
|
||||
CHF = reg(Currency{"CHF", 2, "Fr", "756", "Swiss Franc"})
|
||||
CAD = reg(Currency{"CAD", 2, "$", "124", "Canadian Dollar"})
|
||||
AUD = reg(Currency{"AUD", 2, "$", "036", "Australian Dollar"})
|
||||
NZD = reg(Currency{"NZD", 2, "$", "554", "New Zealand Dollar"})
|
||||
HKD = reg(Currency{"HKD", 2, "$", "344", "Hong Kong Dollar"})
|
||||
SGD = reg(Currency{"SGD", 2, "$", "702", "Singapore Dollar"})
|
||||
INR = reg(Currency{"INR", 2, "₹", "356", "Indian Rupee"})
|
||||
KRW = reg(Currency{"KRW", 0, "₩", "410", "Won"})
|
||||
BRL = reg(Currency{"BRL", 2, "R$", "986", "Brazilian Real"})
|
||||
MXN = reg(Currency{"MXN", 2, "$", "484", "Mexican Peso"})
|
||||
ZAR = reg(Currency{"ZAR", 2, "R", "710", "Rand"})
|
||||
SEK = reg(Currency{"SEK", 2, "kr", "752", "Swedish Krona"})
|
||||
NOK = reg(Currency{"NOK", 2, "kr", "578", "Norwegian Krone"})
|
||||
DKK = reg(Currency{"DKK", 2, "kr", "208", "Danish Krone"})
|
||||
PLN = reg(Currency{"PLN", 2, "zł", "985", "Zloty"})
|
||||
AED = reg(Currency{"AED", 2, "د.إ", "784", "UAE Dirham"})
|
||||
SAR = reg(Currency{"SAR", 2, "﷼", "682", "Saudi Riyal"})
|
||||
TRY = reg(Currency{"TRY", 2, "₺", "949", "Turkish Lira"})
|
||||
RUB = reg(Currency{"RUB", 2, "₽", "643", "Russian Ruble"})
|
||||
)
|
||||
|
||||
// Crypto / stablecoins — decimals are the token's on-chain decimals so an off-chain
|
||||
// Amount and an on-chain balance share one integer scale.
|
||||
var (
|
||||
USDC = reg(Currency{"USDC", 6, "$", "", "USD Coin"})
|
||||
USDT = reg(Currency{"USDT", 6, "$", "", "Tether"})
|
||||
BTC = reg(Currency{"BTC", 8, "₿", "", "Bitcoin"})
|
||||
ETH = reg(Currency{"ETH", 18, "Ξ", "", "Ether"})
|
||||
HUSD = reg(Currency{"HUSD", 18, "$", "", "Hanzo USD"}) // Hanzo USD stablecoin, ERC-20
|
||||
)
|
||||
|
||||
// Register adds (or overrides) a currency in the registry and returns it, so custom
|
||||
// currencies resolve by code the same as the built-ins.
|
||||
func Register(c Currency) Currency { return reg(c) }
|
||||
|
||||
// ByCode looks a currency up by alpha code (case-insensitive).
|
||||
func ByCode(code string) (Currency, error) {
|
||||
if c, ok := registry[strings.ToUpper(strings.TrimSpace(code))]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return Currency{}, ErrUnknownCurrency
|
||||
}
|
||||
|
||||
// ByNumeric looks a currency up by ISO-4217 numeric code ("840").
|
||||
func ByNumeric(num string) (Currency, error) {
|
||||
num = strings.TrimSpace(num)
|
||||
for _, c := range registry {
|
||||
if c.Numeric == num && num != "" {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return Currency{}, ErrUnknownCurrency
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
|
||||
|
||||
package money
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors, matched with errors.Is.
|
||||
var (
|
||||
// ErrCurrencyMismatch is returned when an operation combines two different
|
||||
// (non-zero) currencies.
|
||||
ErrCurrencyMismatch = errors.New("money: currencies do not match")
|
||||
// ErrUnknownCurrency is returned when a currency code/number is not in the registry.
|
||||
ErrUnknownCurrency = errors.New("money: unknown currency")
|
||||
// ErrInvalidAmount is returned when an amount string cannot be parsed.
|
||||
ErrInvalidAmount = errors.New("money: invalid amount")
|
||||
// ErrNoRate is returned when an exchange rate is missing.
|
||||
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")
|
||||
)
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/decimal"
|
||||
)
|
||||
|
||||
// Exchange is an in-memory table of conversion rates. A rate from → to means one unit of
|
||||
// `from` buys `rate` units of `to`. Not safe for concurrent writes; build it once, then
|
||||
// read.
|
||||
type Exchange struct {
|
||||
rates map[string]decimal.Decimal
|
||||
}
|
||||
|
||||
// NewExchange returns an empty rate table.
|
||||
func NewExchange() *Exchange { return &Exchange{rates: map[string]decimal.Decimal{}} }
|
||||
|
||||
func rateKey(from, to Currency) string {
|
||||
return strings.ToUpper(from.Code) + ">" + strings.ToUpper(to.Code)
|
||||
}
|
||||
|
||||
// AddRate registers (or replaces) the from→to rate from a decimal string ("0.85"). The
|
||||
// inverse is NOT implied — add it explicitly if you need both directions.
|
||||
func (e *Exchange) AddRate(from, to Currency, rate string) error {
|
||||
r, err := decimal.Parse(rate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.rates[rateKey(from, to)] = r
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rate returns the from→to rate (identity 1 when from == to).
|
||||
func (e *Exchange) Rate(from, to Currency) (decimal.Decimal, error) {
|
||||
if strings.EqualFold(from.Code, to.Code) {
|
||||
return decimal.New(1, 0), nil
|
||||
}
|
||||
if r, ok := e.rates[rateKey(from, to)]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return decimal.Decimal{}, ErrNoRate
|
||||
}
|
||||
|
||||
// Convert converts amount into `to`, rounded to `to`'s decimals, using the registered rate.
|
||||
func (e *Exchange) Convert(amount Amount, to Currency) (Amount, error) {
|
||||
r, err := e.Rate(amount.cur, to)
|
||||
if err != nil {
|
||||
return Amount{}, err
|
||||
}
|
||||
return ConvertWithRate(amount, to, r), nil
|
||||
}
|
||||
|
||||
// ConvertWithRate converts amount into `to` at an explicit rate (rounded to `to`'s decimals).
|
||||
func ConvertWithRate(amount Amount, to Currency, rate decimal.Decimal) Amount {
|
||||
return Amount{v: amount.v.Mul(rate).Round(to.Decimals), cur: to}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package money
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisplay(t *testing.T) {
|
||||
if got := FromCents(123456).Display(); got != "$1,234.56" {
|
||||
t.Errorf("USD display = %q", got)
|
||||
}
|
||||
if got := FromMinor(10000, JPY).Display(); got != "¥10,000" {
|
||||
t.Errorf("JPY display = %q", got)
|
||||
}
|
||||
if got := FromCents(-500).Display(); got != "-$5.00" {
|
||||
t.Errorf("neg display = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocate(t *testing.T) {
|
||||
parts, err := FromCents(10000).Allocate(50, 30, 20) // $100 by 50/30/20
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parts[0].Display() != "$50.00" || parts[1].Display() != "$30.00" || parts[2].Display() != "$20.00" {
|
||||
t.Fatalf("allocate = %s %s %s", parts[0].Display(), parts[1].Display(), parts[2].Display())
|
||||
}
|
||||
// remainder distribution: $100/3 by equal ratios → 34,33,33
|
||||
p2, _ := FromCents(10000).Allocate(1, 1, 1)
|
||||
sum, _ := Sum(p2...)
|
||||
if sum.MinorString() != FromCents(10000).MinorString() {
|
||||
t.Fatal("allocate lost pennies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregate(t *testing.T) {
|
||||
xs := []Amount{FromCents(10000), FromCents(20000), FromCents(30000)}
|
||||
if s, _ := Sum(xs...); s.Display() != "$600.00" {
|
||||
t.Errorf("sum")
|
||||
}
|
||||
if m, _ := Min(xs...); m.Display() != "$100.00" {
|
||||
t.Errorf("min")
|
||||
}
|
||||
if m, _ := Max(xs...); m.Display() != "$300.00" {
|
||||
t.Errorf("max")
|
||||
}
|
||||
if a, _ := Avg(xs...); a.Display() != "$200.00" {
|
||||
t.Errorf("avg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchange(t *testing.T) {
|
||||
ex := NewExchange()
|
||||
_ = ex.AddRate(USD, EUR, "0.85")
|
||||
eur, err := ex.Convert(FromCents(10000), EUR) // $100 → €85
|
||||
if err != nil || eur.Display() != "€85.00" {
|
||||
t.Fatalf("convert = %v %s", err, eur.Display())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want, cur string }{
|
||||
{"$1,234.56", "1234.56", "USD"},
|
||||
{"EUR 250.75", "250.75", "EUR"},
|
||||
{"100.50 USD", "100.5", "USD"},
|
||||
{"£99.99", "99.99", "GBP"},
|
||||
} {
|
||||
a, err := ParseAmount(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAmount(%q): %v", tc.in, err)
|
||||
}
|
||||
if a.String() != tc.want || a.Currency().Code != tc.cur {
|
||||
t.Errorf("ParseAmount(%q) = %s %s", tc.in, a.String(), a.Currency().Code)
|
||||
}
|
||||
}
|
||||
eu, _ := ParseAmountEuropean("€10,50")
|
||||
if eu.String() != "10.5" {
|
||||
t.Errorf("european = %s", eu.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONAndSQL(t *testing.T) {
|
||||
a := FromCents(2999)
|
||||
b, _ := json.Marshal(a)
|
||||
if string(b) != `{"amount":"29.99","currency":"USD"}` {
|
||||
t.Fatalf("marshal = %s", b)
|
||||
}
|
||||
var back Amount
|
||||
if err := json.Unmarshal(b, &back); err != nil || back.Cmp(a) != 0 || back.Currency().Code != "USD" {
|
||||
t.Fatalf("json round-trip: %v %s", err, back.String())
|
||||
}
|
||||
v, _ := a.Value()
|
||||
if v.(string) != "29.99|USD" {
|
||||
t.Fatalf("sql value = %v", v)
|
||||
}
|
||||
var s Amount
|
||||
if err := s.Scan("29.99|USD"); err != nil || s.Cmp(a) != 0 {
|
||||
t.Fatalf("sql scan: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/decimal"
|
||||
)
|
||||
|
||||
// FromFloat builds an Amount from a float — USE ONLY for converting user input; floats are
|
||||
// imprecise, so prefer Parse for exact decimals. The float is rendered at the currency's
|
||||
// decimals and parsed exactly from there.
|
||||
func FromFloat(f float64, cur Currency) Amount {
|
||||
s := strconv.FormatFloat(f, 'f', int(cur.Decimals), 64)
|
||||
d, _ := decimal.Parse(s)
|
||||
return Amount{v: d, cur: cur}
|
||||
}
|
||||
|
||||
// AsMajorUnits returns the value as a float in major units (e.g. dollars) — for DISPLAY
|
||||
// only; never round-trip money through it.
|
||||
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 {
|
||||
d := a.v.Rescale(a.cur.Decimals)
|
||||
neg := d.Sign() < 0
|
||||
s := d.Abs().String()
|
||||
|
||||
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
|
||||
for int32(len(frac)) < a.cur.Decimals {
|
||||
frac += "0"
|
||||
}
|
||||
grouped := group(intPart)
|
||||
out := grouped
|
||||
if a.cur.Decimals > 0 {
|
||||
out += "." + frac
|
||||
}
|
||||
sym := a.cur.Symbol
|
||||
switch {
|
||||
case neg && sym != "":
|
||||
return "-" + sym + out
|
||||
case sym != "":
|
||||
return sym + out
|
||||
case neg:
|
||||
return "-" + out + " " + a.cur.Code
|
||||
default:
|
||||
return out + " " + a.cur.Code
|
||||
}
|
||||
}
|
||||
|
||||
// group inserts thousands separators into a non-negative integer string.
|
||||
func group(s string) string {
|
||||
n := len(s)
|
||||
if n <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
pre := n % 3
|
||||
if pre > 0 {
|
||||
b.WriteString(s[:pre])
|
||||
if n > pre {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
for i := pre; i < n; i += 3 {
|
||||
b.WriteString(s[i : i+3])
|
||||
if i+3 < n {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FromUSD builds a USD amount from integer cents. The FromXXX helpers mirror gocanto's
|
||||
// convenience constructors; they take the currency's smallest unit.
|
||||
func FromUSD(minor int64) Amount { return FromMinor(minor, USD) }
|
||||
|
||||
// FromEUR builds a EUR amount from integer cents.
|
||||
func FromEUR(minor int64) Amount { return FromMinor(minor, EUR) }
|
||||
|
||||
// FromGBP builds a GBP amount from integer pence.
|
||||
func FromGBP(minor int64) Amount { return FromMinor(minor, GBP) }
|
||||
|
||||
// FromJPY builds a JPY amount from integer yen (0 decimals).
|
||||
func FromJPY(minor int64) Amount { return FromMinor(minor, JPY) }
|
||||
|
||||
// FromHUSD builds a Hanzo-USD amount from integer 18-decimal wei.
|
||||
func FromHUSD(wei *big.Int) Amount { return FromMinorBig(wei, HUSD) }
|
||||
@@ -17,28 +17,8 @@ import (
|
||||
"github.com/hanzoai/decimal"
|
||||
)
|
||||
|
||||
// Currency is a currency's identity: an ISO-4217-style code and the number of decimal
|
||||
// places in its smallest unit (USD 2, JPY 0, USDC 6, an 18-decimal ERC-20 18). Custom
|
||||
// currencies are allowed — construct one directly.
|
||||
type Currency struct {
|
||||
Code string
|
||||
Decimals int32
|
||||
}
|
||||
|
||||
// Common currencies. Fiat use the ISO minor unit; crypto/stablecoins use their on-chain
|
||||
// token decimals so an off-chain Amount and an on-chain balance share one scale.
|
||||
var (
|
||||
USD = Currency{"USD", 2}
|
||||
EUR = Currency{"EUR", 2}
|
||||
GBP = Currency{"GBP", 2}
|
||||
JPY = Currency{"JPY", 0}
|
||||
USDC = Currency{"USDC", 6}
|
||||
USDT = Currency{"USDT", 6}
|
||||
ETH = Currency{"ETH", 18}
|
||||
HUSD = Currency{"HUSD", 18} // Hanzo USD stablecoin (ERC-20, 18 decimals)
|
||||
)
|
||||
|
||||
// Amount is an exact money value: a decimal in a currency.
|
||||
// Amount is an exact money value: a decimal in a currency. (Currency and the currency
|
||||
// registry live in currency.go.)
|
||||
type Amount struct {
|
||||
v decimal.Decimal
|
||||
cur Currency
|
||||
@@ -182,12 +162,6 @@ func (a Amount) Split(n int) ([]Amount, error) {
|
||||
// String renders the exact decimal value ("0.00132").
|
||||
func (a Amount) String() string { return a.v.String() }
|
||||
|
||||
// Display renders the value rounded to the currency's decimals with the code
|
||||
// ("100003.01 USD") — the human form.
|
||||
func (a Amount) Display() string {
|
||||
return a.v.Rescale(a.cur.Decimals).String() + " " + a.cur.Code
|
||||
}
|
||||
|
||||
// MarshalJSON emits {"amount":"<decimal>","currency":"USD"} — exact strings, no float.
|
||||
func (a Amount) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`{"amount":"` + a.v.String() + `","currency":"` + a.cur.Code + `"}`), nil
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/decimal"
|
||||
)
|
||||
|
||||
// Allocate distributes the amount across the given integer ratios, summing EXACTLY to the
|
||||
// original at the currency's minor-unit granularity — each part floors, then the leftover
|
||||
// minor units go one-at-a-time to the leading parts. Ratios must be non-negative with a
|
||||
// positive total.
|
||||
func (a Amount) Allocate(ratios ...int) ([]Amount, error) {
|
||||
if len(ratios) == 0 {
|
||||
return nil, ErrNonPositive
|
||||
}
|
||||
total := 0
|
||||
for _, r := range ratios {
|
||||
if r < 0 {
|
||||
return nil, ErrNonPositive
|
||||
}
|
||||
total += r
|
||||
}
|
||||
if total == 0 {
|
||||
return nil, ErrNonPositive
|
||||
}
|
||||
minor := a.Minor()
|
||||
tb := big.NewInt(int64(total))
|
||||
remainder := new(big.Int).Set(minor)
|
||||
out := make([]Amount, len(ratios))
|
||||
for i, r := range ratios {
|
||||
share := new(big.Int).Mul(minor, big.NewInt(int64(r)))
|
||||
share.Quo(share, tb) // floor
|
||||
out[i] = FromMinorBig(share, a.cur)
|
||||
remainder.Sub(remainder, share)
|
||||
}
|
||||
one := big.NewInt(1)
|
||||
for i := 0; remainder.Sign() > 0 && i < len(out); i++ {
|
||||
out[i] = FromMinorBig(new(big.Int).Add(out[i].Minor(), one), a.cur)
|
||||
remainder.Sub(remainder, one)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonAmount is the wire shape {"amount":"<decimal>","currency":"USD"}.
|
||||
type jsonAmount struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses {"amount","currency"} exactly (a known code resolves to its full
|
||||
// Currency; an unknown code is kept as a bare code).
|
||||
func (a *Amount) UnmarshalJSON(b []byte) error {
|
||||
var raw jsonAmount
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := decimal.Parse(raw.Amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, err := ByCode(raw.Currency)
|
||||
if err != nil {
|
||||
cur = Currency{Code: raw.Currency}
|
||||
}
|
||||
*a = Amount{v: d, cur: cur}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer — stored as "amount|currency" ("0.00132|USD"), exact and
|
||||
// currency-carrying.
|
||||
func (a Amount) Value() (driver.Value, error) { return a.v.String() + "|" + a.cur.Code, nil }
|
||||
|
||||
// Scan implements sql.Scanner for the "amount|currency" form.
|
||||
func (a *Amount) Scan(src any) error {
|
||||
var s string
|
||||
switch v := src.(type) {
|
||||
case nil:
|
||||
*a = Amount{}
|
||||
return nil
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
default:
|
||||
return fmt.Errorf("money: cannot scan %T", src)
|
||||
}
|
||||
parts := strings.SplitN(s, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return ErrInvalidAmount
|
||||
}
|
||||
d, err := decimal.Parse(parts[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, err := ByCode(parts[1])
|
||||
if err != nil {
|
||||
cur = Currency{Code: parts[1]}
|
||||
}
|
||||
*a = Amount{v: d, cur: cur}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026, Hanzo AI, Inc. BSD-3-Clause.
|
||||
|
||||
package money
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/decimal"
|
||||
)
|
||||
|
||||
// symbolCurrency maps a display symbol to its default currency (ambiguous symbols like $
|
||||
// resolve to the most common — USD; pass an explicit code to disambiguate).
|
||||
var symbolCurrency = map[string]Currency{
|
||||
"$": USD, "€": EUR, "£": GBP, "¥": JPY, "₿": BTC,
|
||||
"₹": INR, "₩": KRW, "₺": TRY, "₽": RUB, "R$": BRL,
|
||||
}
|
||||
|
||||
// ParseAmount parses a human-entered money string — "$1,234.56", "EUR 250,75", "100.50 USD",
|
||||
// "£99.99" — into an exact Amount. The currency comes from a leading symbol, or a code
|
||||
// token (prefix or suffix), or the optional defaultCode. Thousands separators are handled;
|
||||
// a comma-only decimal is treated as a thousands separator (use ParseAmountEuropean for
|
||||
// "10,50" == 10.50).
|
||||
func ParseAmount(s string, defaultCode ...string) (Amount, error) {
|
||||
return parseAmount(s, false, defaultCode...)
|
||||
}
|
||||
|
||||
// ParseAmountEuropean is ParseAmount but treats a comma-only separator as the DECIMAL point
|
||||
// ("€10,50" == €10.50).
|
||||
func ParseAmountEuropean(s string, defaultCode ...string) (Amount, error) {
|
||||
return parseAmount(s, true, defaultCode...)
|
||||
}
|
||||
|
||||
func parseAmount(s string, commaDecimal bool, defaultCode ...string) (Amount, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return Amount{}, ErrInvalidAmount
|
||||
}
|
||||
var cur Currency
|
||||
found := false
|
||||
|
||||
// leading symbol
|
||||
for sym, c := range symbolCurrency {
|
||||
if strings.HasPrefix(s, sym) {
|
||||
cur, found = c, true
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, sym))
|
||||
break
|
||||
}
|
||||
}
|
||||
// code token (prefix or suffix), e.g. "USD 100" or "100 USD"
|
||||
if fields := strings.Fields(s); len(fields) == 2 {
|
||||
if c, err := ByCode(fields[0]); err == nil {
|
||||
cur, found, s = c, true, fields[1]
|
||||
} else if c, err := ByCode(fields[1]); err == nil {
|
||||
cur, found, s = c, true, fields[0]
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if len(defaultCode) == 1 {
|
||||
c, err := ByCode(defaultCode[0])
|
||||
if err != nil {
|
||||
return Amount{}, err
|
||||
}
|
||||
cur, found = c, true
|
||||
} else {
|
||||
return Amount{}, ErrUnknownCurrency
|
||||
}
|
||||
}
|
||||
|
||||
num, err := cleanNumber(s, commaDecimal)
|
||||
if err != nil {
|
||||
return Amount{}, err
|
||||
}
|
||||
d, err := decimal.Parse(num)
|
||||
if err != nil {
|
||||
return Amount{}, ErrInvalidAmount
|
||||
}
|
||||
return Amount{v: d, cur: cur}, nil
|
||||
}
|
||||
|
||||
// ParseDecimal parses just the numeric part (separators handled) — no currency.
|
||||
func ParseDecimal(s string) (decimal.Decimal, error) {
|
||||
num, err := cleanNumber(strings.TrimSpace(s), false)
|
||||
if err != nil {
|
||||
return decimal.Decimal{}, err
|
||||
}
|
||||
return decimal.Parse(num)
|
||||
}
|
||||
|
||||
// cleanNumber normalizes thousands/decimal separators to a plain decimal string.
|
||||
func cleanNumber(s string, commaDecimal bool) (string, error) {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, " ", ""))
|
||||
if s == "" {
|
||||
return "", ErrInvalidAmount
|
||||
}
|
||||
dot, comma := strings.LastIndexByte(s, '.'), strings.LastIndexByte(s, ',')
|
||||
switch {
|
||||
case dot >= 0 && comma >= 0:
|
||||
// the later separator is the decimal point; the other is thousands.
|
||||
if dot > comma {
|
||||
s = strings.ReplaceAll(s, ",", "") // US: 1,234.56
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, ".", "") // EU: 1.234,56
|
||||
s = strings.Replace(s, ",", ".", 1)
|
||||
}
|
||||
case comma >= 0:
|
||||
if commaDecimal {
|
||||
s = strings.Replace(s, ",", ".", 1)
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, ",", "") // thousands
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
Reference in New Issue
Block a user