Skip to content

gocanto/money

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

96 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Money

A Go implementation of Martin Fowler's Money pattern, inspired by moneyphp/money.

Go Report Card Go Reference

📖 Table of Contents


🤔 Why Use a Money Library?

You shouldn't represent monetary values with floats due to precision issues. This library uses integers internally to avoid floating-point arithmetic errors and provides safe money calculations.

// ❌ DON'T DO THIS
price := 0.1 + 0.2  // 0.30000000000000004

// ✅ DO THIS
mm := money.NewManager()
a := mm.Create(10, currency.USD) // 10 cents
b := mm.Create(20, currency.USD) // 20 cents

sum, _ := mm.Add(a, b) // $0.30

🚀 Installation

go get github.com/gocanto/money

📋 Requirements

  • Go 1.25.5 (per go.mod)

✨ Features

  • Safe Money Arithmetic: Add, subtract, multiply without floats
  • Currency Support: ISO 4217 dataset + custom currencies
  • Formatting: Currency-aware formatting and major-unit conversion
  • Parsing: Parse human-entered amounts (symbols/codes, separators)
  • Aggregation: Sum/Min/Max/Avg via an Aggregator
  • Currency Exchange: Convert via an exchange.Exchange
  • JSON + DB Integration: json.Marshaler/Unmarshaler, sql.Scanner/driver.Valuer

📦 Packages

  • github.com/gocanto/money/money: Money, Manager, Aggregator, Converter
  • github.com/gocanto/money/currency: ISO currency data + Manager
  • github.com/gocanto/money/exchange: in-memory exchange rates + conversion
  • github.com/gocanto/money/parser: parse strings like $1,234.56, EUR 10,50
  • github.com/gocanto/money/format: currency formatter used by currency.Currency
  • github.com/gocanto/money/exception: sentinel errors used across the module

🏁 Quick Start

package main

import (
	"fmt"

	"github.com/gocanto/money/currency"
	"github.com/gocanto/money/money"
)

func main() {
	mm := money.NewManager()

	// Amounts are in minor units (cents, pence, etc.)
	price := mm.Create(10000, currency.USD) // $100.00
	tax := mm.Create(850, currency.USD)     // $8.50

	total, _ := mm.Add(price, tax)
	display, _ := total.Display()
	fmt.Println(display) // $108.50

	// Convenience constructors (use the default Manager)
	eur := money.FromEUR(50000) // €500.00
	eurDisplay, _ := eur.Display()
	fmt.Println(eurDisplay)
}

🧠 Core Concepts

Creating Money

mm := money.NewManager()

// Create with amount in minor units (cents, pence, etc.)
usd := mm.Create(10000, currency.USD) // $100.00

// From float (use only for user input conversion; floats are imprecise)
approx := mm.CreateFromFloat(99.99, currency.USD)

// From exact decimal string (recommended for external/user-provided decimals)
exact, _ := mm.CreateFromString("99.99", currency.USD)

// Convenience constructors (use the default Manager)
eur := money.FromEUR(5000)  // €50.00
jpy := money.FromJPY(10000) // ¥10000

Arithmetic Operations

Use money.Manager for arithmetic operations.

mm := money.NewManager()
a := mm.Create(10000, currency.USD) // $100.00
b := mm.Create(2500, currency.USD)  // $25.00

// Addition
sum, _ := mm.Add(a, b)
sumDisplay, _ := sum.Display()
fmt.Println(sumDisplay) // $125.00

// Subtraction
diff, _ := mm.Subtract(a, b)
diffDisplay, _ := diff.Display()
fmt.Println(diffDisplay) // $75.00

// Multiplication
product, _ := mm.Multiply(a, 2)
productDisplay, _ := product.Display()
fmt.Println(productDisplay) // $200.00

Comparison Operations

mm := money.NewManager()
a := mm.Create(10000, currency.USD)
b := mm.Create(5000, currency.USD)

equals, _ := a.Equals(b)                    // false
greaterThan, _ := a.GreaterThan(b)          // true
lessThan, _ := a.LessThan(b)                // false
greaterOrEqual, _ := a.GreaterThanOrEqual(b) // true
lessOrEqual, _ := a.LessThanOrEqual(b)      // false

// Compare returns -1, 0, or 1
cmp, _ := a.Compare(b)  // 1 (a > b)

// Check properties
a.IsZero()      // (false, nil)
a.IsPositive()  // (true, nil)
a.IsNegative()  // (false, nil)

Money Allocation

Split money without losing pennies due to rounding.

mm := money.NewManager()
m := mm.Create(10000, currency.USD) // $100.00

// Split evenly
parts, _ := mm.Split(m, 3)
// parts[0]: $33.34
// parts[1]: $33.33
// parts[2]: $33.33

// Allocate by ratios
m2 := mm.Create(10000, currency.USD) // $100.00
parts, _ = mm.Allocate(m2, 50, 30, 20)
// parts[0]: $50.00 (50%)
// parts[1]: $30.00 (30%)
// parts[2]: $20.00 (20%)

Formatting and Display

mm := money.NewManager()
m := mm.Create(123456, currency.USD)

// Display formatted
display, _ := m.Display() // "$1,234.56"

// Get as major units (float)
major, _ := m.AsMajorUnits() // 1234.56

// Access raw values
amount, _ := m.Amount()   // 123456 (int64)
curr, _ := m.Currency()   // *currency.Currency{Code: "USD", ...}

Parsing

import (
	"github.com/gocanto/money/money"
	"github.com/gocanto/money/parser"
)

p := parser.NewParser()

// Parse with currency symbol
val, currency, err := p.ParseAmount("$100.50")        // 100.50, "USD"
m := money.NewManager().CreateFromFloat(val, currency)

val, currency, err = p.ParseAmount("€250.75")        // 250.75, "EUR"
val, currency, err = p.ParseAmount("£99.99")         // 99.99, "GBP"

// Parse with currency code
val, currency, err = p.ParseAmount("100.50 USD")     // 100.50, "USD"
val, currency, err = p.ParseAmount("EUR 250.75")     // 250.75, "EUR"

// Parse with thousands separator
val, currency, err = p.ParseAmount("$1,234.56")      // 1234.56, "USD"

// Parse with default currency
val, currency, err = p.ParseAmount("100.50", "USD")  // 100.50, "USD"

// Parse just decimal values (no currency)
amount, err := p.ParseDecimal("1234.56")             // 1234.56

Decimal Separator Handling

The parser intelligently handles different decimal separator formats:

// Mixed separators - unambiguous
p.ParseAmount("$1,234.56")     // US format: 1234.56 USD
p.ParseAmount("€1.234,56")     // European format: 1234.56 EUR

// Comma-only input - ambiguous
p.ParseAmount("$1,000")        // Treated as 1000.00 USD (thousands separator)
p.ParseAmount("€10,50")        // Treated as 1050.00 EUR (NOT €10.50)

// For European decimal-only format, use ParseAmountWithDecimalComma
p.ParseAmountWithDecimalComma("€10,50")  // Correctly parses as 10.50 EUR
p.ParseDecimalWithComma("10,50")         // Correctly parses as 10.50

Important: When only commas are present (no dots), the parser treats them as thousands separators by default. To parse European-style decimal commas (e.g., "10,50" as 10.50), use ParseAmountWithDecimalComma() or ParseDecimalWithComma() methods.

Aggregation

mm := money.NewManager()
agg := money.NewAggregator(mm)
prices := []*money.Money{
	mm.Create(10000, currency.USD),
	mm.Create(20000, currency.USD),
	mm.Create(30000, currency.USD),
}

// Sum all
total, _ := agg.Sum(prices...)

// Get minimum
min, _ := agg.Min(prices...)

// Get maximum
max, _ := agg.Max(prices...)

// Calculate average
avg, _ := agg.Avg(prices...)

Currency Exchange

import (
	"github.com/gocanto/money/currency"
	"github.com/gocanto/money/exchange"
	"github.com/gocanto/money/money"
)

currencies := currency.NewManager()
ex := exchange.NewExchange()
_ = ex.AddRate(currency.USD, currency.EUR, 0.85)
_ = ex.AddRate(currency.USD, currency.GBP, 0.73)

// Get exchange rate
rate, _ := ex.GetRate(currency.USD, currency.EUR) // 0.85

converter, _ := money.NewConverter(currencies, ex)

mm := money.NewManager()
usd := mm.Create(10000, currency.USD) // $100.00
eur, _ := converter.Convert(usd, currency.EUR)
eurDisplay, _ := eur.Display()
fmt.Println(eurDisplay) // €85.00

gbp, _ := converter.ConvertWithRate(usd, currency.GBP, 0.73)
gbpDisplay, _ := gbp.Display()
fmt.Println(gbpDisplay) // £73.00

JSON Serialization

type Product struct {
    Name  string       `json:"name"`
    Price *money.Money `json:"price"`
}

// Marshal
product := Product{
    Name:  "Book",
    Price: money.NewManager().Create(2999, currency.USD),
}
json, _ := json.Marshal(product)
// {"name":"Book","price":{"amount":2999,"currency":"USD"}}

// Unmarshal
var p Product
json.Unmarshal([]byte(jsonStr), &p)

Database Support

type Product struct {
    ID    int          `db:"id"`
    Price *money.Money `db:"price"`
}

// Money implements sql.Scanner and driver.Valuer
// Stores as a delimited string: "amount|currency_code" (separator is configurable)
db.Exec("INSERT INTO products (price) VALUES (?)", product.Price)
// Stores as: 2999|USD

Currency Management

import (
	"github.com/gocanto/money/currency"
	"github.com/gocanto/money/money"
)

// Get currency information
cm := currency.NewManager()
c := cm.FindByCode(currency.USD)
c.Code     // "USD"
c.Fraction // 2 (decimal places)
c.Grapheme // "$"

// Get by numeric code (ISO 4217)
c2 := cm.FindByNumericCode("840") // USD

// Add custom currency
cm.AddFrom("BTC", "₿", "$1", ".", ",", "000", 8)
mm := money.NewManager()
bitcoin := mm.Create(100000000, "BTC") // 1.00000000 ₿

🌍 Supported Currencies

The default currency.Manager includes an ISO 4217 dataset (including many historical entries) and can be extended with custom currencies.

The money package also includes many FromXXX(amount int64) helpers (e.g. money.FromUSD) that create Money values using the default manager.

🚧 Error Handling

Operations that could fail return errors:

import (
	"fmt"

	"github.com/gocanto/money/currency"
	"github.com/gocanto/money/exception"
	"github.com/gocanto/money/money"
)

mm := money.NewManager()
usd := mm.Create(100, currency.USD)
eur := mm.Create(100, currency.EUR)

// This will return error
_, err := mm.Add(usd, eur)
if err != nil {
    fmt.Println("Cannot add different currencies:", err)
    // Error message: "currencies don't match"
}

Common sentinel errors live in github.com/gocanto/money/exception (e.g. exception.ErrCurrencyMismatch).

✅ Best Practices

  1. Always use minor units: Store amounts as integers in the smallest currency unit (cents, pence, etc.)

    // ✅ Good
    price := money.NewManager().Create(9999, currency.USD) // $99.99
  2. Check errors: Money operations can fail (currency mismatch, etc.)

    mm := money.NewManager()
    result, err := mm.Add(a, b)
    if err != nil {
        // Handle error
    }
  3. Use allocation for splits: Don't manually divide - use Split() or Allocate()

    // ✅ Good
    mm := money.NewManager()
    m := mm.Create(10000, currency.USD)
    parts, _ := mm.Split(m, 3)
    
    // ❌ Loses pennies
    third := mm.Create(10000/3, currency.USD)
  4. Prefer exact decimal strings for user input: CreateFromString("12.34", "USD") avoids float rounding surprises.

👨‍💻 Development

  • Run tests + auto-format: make tests
  • Run a per-package test summary: make pretty-test
  • Format only: make format

⚖️ Differences from MoneyPHP

  • Uses int64 instead of strings for amounts (supports up to ~92 quadrillion)
  • Go-idiomatic API (e.g., money.FromUSD() helper, and money.Manager for operations)
  • Database support via sql.Scanner and driver.Valuer
  • Aggregation via money.Aggregator
  • Simple in-memory exchange system with explicit rate management

📝 License

This project is licensed under the MIT License - see the LICENSE file for details.

🙏 Credits

Inspired by moneyphp/money and Martin Fowler's Money pattern.

🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

📚 Resources

About

A Go implementation of Martin Fowler's Money pattern

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

No packages published