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:
2026-07-12 23:25:19 -07:00
parent d898597db1
commit 1f3e72d788
10 changed files with 685 additions and 38 deletions
+25 -10
View File
@@ -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.
+71
View File
@@ -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
View File
@@ -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
}
+20
View File
@@ -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
View File
@@ -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}
}
+100
View File
@@ -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)
}
}
+100
View File
@@ -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) }
+2 -28
View File
@@ -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
View File
@@ -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
}
+113
View File
@@ -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
}