Skip to content

Repository files navigation

money

CI Go Reference Go 1.24+ MIT

Exact monetary arithmetic for Go, in integer minor units.

An amount is a count of cents, pence or yen plus an ISO-4217 currency code. There is no float anywhere in this package and there never will be: binary floating point cannot represent 0.10, and a system that rounds badly issues refunds that do not reverse the charge they refund.

price, _ := money.Parse("19.99", "EUR")      // 1999 minor units, exactly
line := price.Mul(3)                         // 59.97 EUR
tax, _ := line.ApplyRate(1900, money.HalfUp) // 19% VAT -> 11.39 EUR
total, _ := money.Add(line, tax)             // 71.36 EUR

No dependencies outside the standard library.

Contents

Install

go get github.com/r-52/money

Requires Go 1.24 or later.

Amounts

type Amount struct {
    Minor    int64
    Currency Currency
}

Minor is whole minor units — 1999 is €19.99, and 1500 is ¥1500, because the yen has no minor unit. Currency is the ISO-4217 alphabetic code.

price := money.New(1999, "EUR")
free  := money.Zero("EUR")

price.IsZero()      // false
price.IsNegative()  // false

Amount is a comparable value type, so == works and amounts can be map keys. The zero Amount has an empty currency; it is valid only as an accumulator seed, and combining it with a real amount adopts that amount's currency, so a var total money.Amount can start a sum.

That last rule is convenient and slightly dangerous, since a blank currency is compatible with every other one. So the places where an amount enters from outside — parsing, decoding, conversion — all insist on a real code, and Currency.Valid is exported for boundaries of your own.

Arithmetic

Anything that combines two amounts checks the currency first and returns an error rather than producing a nonsense number.

sum, err := money.Add(a, b)
diff, err := money.Sub(a, b)
total, err := money.Sum(lines...)          // errors on the first mismatch
total := money.MustSum(lines...)           // panics instead, for call sites
                                           // that already established one currency

Scaling by a whole number is exact and cannot need rounding, so it is a method with no error to check:

lineTotal := unitPrice.Mul(quantity)
credit    := charge.Neg()

Comparison and clamping:

c, err := money.Cmp(a, b)          // -1, 0, +1
lo, err := money.Min(a, b)
hi, err := money.Max(a, b)

net := subtotal.ClampNonNegative() // a discount must not turn a charge into a payout

Rounding

Division is the only place money can go wrong, so every division names its rounding mode at the call site. Tax authorities genuinely disagree about this, and burying a default in the arithmetic makes the disagreement invisible.

Mode Behaviour
money.HalfUp a .5 remainder rounds away from zero — the commercial default, and the assumption behind most published tax tables
money.HalfEven a .5 remainder rounds to the nearest even unit, so large batches do not drift upward
money.Down truncates toward zero

MulDiv is the single primitive underneath everything else:

part, err := amount.MulDiv(num, den, money.HalfUp) // amount * num / den

It computes through a 128-bit intermediate, so a large amount times a large numerator does not silently overflow on the way to a quotient that fits comfortably in an int64. A quotient that genuinely does not fit returns an error rather than a wrong price.

Rates and tax

Rates are basis points — hundredths of a percent, so 1900 is 19% and 825 is 8.25%. Basis points keep rates exact integers, which neither floats nor decimal percentages manage for a value like 8.25%.

tax, err := net.ApplyRate(1900, money.HalfUp)     // 19% of a net price
tax, err := gross.ExtractRate(1900, money.HalfUp) // the 19% already inside a gross price

ExtractRate computes gross * bp / (10000 + bp), which is not the same as applying the rate to the gross figure. Selling at a tax-inclusive price and deriving the tax from it is the norm across the EU, and getting it wrong is a classic off-by-a-few-cents bug:

gross := money.New(11_900, "EUR")               // 119.00, 19% VAT included
tax, _ := gross.ExtractRate(1900, money.HalfUp) // 19.00 EUR  ✓
wrong, _ := gross.ApplyRate(1900, money.HalfUp) // 22.61 EUR  ✗

Allocation

Allocate splits an amount across buckets in proportion to weights so that the parts sum to exactly the whole — no unit created, none lost.

parts, err := money.Allocate(total, []int64{6000, 3000, 1000})
parts, err := money.AllocateBy(total, subtotals) // same, keyed on amounts

It uses largest-remainder apportionment: each bucket takes its floored proportional share, then the leftover units go one apiece to the buckets with the largest fractional parts, ties broken by position. The result is deterministic — the same inputs split the same way on every run and every machine, which is what makes golden files and stored explanations meaningful.

// A 10.00 discount over three lines.
parts, _ := money.AllocateBy(money.New(1000, "EUR"), []money.Amount{
    money.New(1999, "EUR"),
    money.New(999, "EUR"),
    money.New(499, "EUR"),
})
// 5.71 EUR, 2.86 EUR, 1.43 EUR — which adds to exactly 10.00 EUR

This is what makes an order-level discount reversible. Refunding one line returns exactly the units that line was charged, because the split was recorded rather than recomputed from a percentage that no longer divides the same way.

Two edge cases are pinned down rather than left to chance: a negative total (a refund) allocates by magnitude and every part points the same way, and weights that are all zero fall back to an even split, since proportion is undefined but conservation still has to hold.

The invariant is property-tested over hundreds of thousands of random splits: the parts always sum to the total, and no bucket ever strays a full minor unit from its exact proportional share.

Parsing

Amounts arrive as strings all the time — a price column in a CSV, a JSON field from a partner, a form value, a command-line flag. Without a parser here, that string meets strconv.ParseFloat, which is the one thing this package exists to prevent.

price, err := money.Parse("19.99", "EUR")          // 1999
price := money.MustParse("19.99", "EUR")           // for fixtures and constants
price, err := money.ParseAmount("19.99 EUR")       // currency in the string

Precision is exact or it is an error. The number of decimal places accepted is the currency's exponent:

money.Parse("19.99", "EUR")   // 1999
money.Parse("19.9", "EUR")    // 1990 — fewer decimals just scale up
money.Parse("19.990", "EUR")  // 1999 — the extra digit is a zero, nothing is lost
money.Parse("19.995", "EUR")  // error: EUR cannot represent that
money.Parse("1500.5", "JPY")  // error: the yen has no minor unit at all

Rounding a price is a decision, so it is made explicitly or not at all:

money.ParseRound("19.995", "EUR", money.HalfUp)   // 2000
money.ParseRound("19.995", "EUR", money.HalfEven) // 2000, tie to even
money.ParseRound("19.999", "EUR", money.Down)     // 1999

The grammar is deliberately narrow: an optional sign, digits, an optional point, digits. No thousands separators, no spaces, no currency symbol, no exponent notation. "1,234.56" and "1 234,56" are rejected rather than guessed at, because the guess is how €1.234 becomes €1234 — call strings.TrimSpace or strip separators yourself, where the choice is visible. A blank or malformed currency is refused too, for the reason in Amounts.

Parse is the inverse of String, and the round trip is property-tested and fuzzed: anything that parses renders to something that parses back to it.

JSON and text

Amount implements json.Marshaler, json.Unmarshaler, encoding.TextMarshaler and encoding.TextUnmarshaler.

JSON is the exact form — minor units and the code, so no consumer has to know the exponent to read it back, and none can mistake it for a float:

{"minor": 7136, "currency": "EUR"}

Text is the human form, Amount.String and its inverse — what a config file or a flag.Value should carry, and what encoding/json uses for a map key:

19.99 EUR

Both refuse to carry a value whose currency is blank or malformed, in either direction. The one exception is the zero Amount — zero in no currency, the seed a sum starts from — which travels as itself: "0" in text, {"minor":0,"currency":""} in JSON.

b, _ := json.Marshal(money.New(7136, "EUR"))  // {"minor":7136,"currency":"EUR"}

var a money.Amount
json.Unmarshal([]byte(`{"minor":1999,"currency":"eur"}`), &a) // error: bad code
json.Unmarshal([]byte(`{"minor":1999}`), &a)                  // error: no code

UnmarshalJSON also accepts the text form in a JSON string. That is not leniency for its own sake: encoding/json quotes map keys and routes them to UnmarshalJSON rather than UnmarshalText, so without it an Amount-keyed map would marshal and then fail to unmarshal.

Currency conversion

A Rate is an exact ratio between two currencies, held as integers so a rate like 1.08734 is not approximated on the way in.

usd := money.Rate{From: "EUR", To: "USD", Num: 108_734, Den: 100_000}
usd, err := money.NewRate("EUR", "USD", 108_734, 100_000) // same, checked

converted, err := usd.Convert(money.New(10_000, "EUR"), money.HalfUp) // 108.73 USD
_, err = usd.Convert(money.New(10_000, "GBP"), money.HalfUp)          // error

This package converts money; it does not source rates. Where the number came from, when it was quoted, what spread it includes and how long it stays valid are facts a rate needs and none of them are arithmetic, so they live in your system next to your rate table.

What Rate buys you over reaching for MulDiv directly is that the currency change is checked rather than assumed. MulDiv preserves the currency, so converting with it means assembling Amount{…, "USD"} by hand — and an Amount whose Currency field was set by hand is exactly what reconciles wrong three months later.

ConvertAll converts a set of amounts so that they still add up:

parts, err := rate.ConvertAll(lines, money.HalfUp)

Converting each line on its own does not do that — every line rounds independently and the errors accumulate, so a converted invoice can miss its converted total by several units. ConvertAll converts the total once and apportions it back with AllocateBy, which is the same trick that makes a discount reversible.

Conversion is lossy in the way any division is. Rate.Invert gives you the exact reciprocal for undoing an internal conversion, but converting and converting back is not guaranteed to return the original amount, and a real market pair is not a reciprocal in either case. Convert once, at a rate you recorded, and store what came out.

Currencies and formatting

Currency.Exponent gives the number of minor units per major unit — 2 by default, 0 for JPY, KRW, ISK and the other zero-decimal currencies, 3 for the Gulf dinars. Unknown codes get the 2-decimal default. Currency.Valid checks the shape of a code: three letters, upper case.

Amount.String places the decimal point accordingly:

money.New(150_000, "EUR") // "1500.00 EUR"
money.New(150_000, "JPY") // "150000 JPY"
money.New(150_000, "KWD") // "150.000 KWD"

That format is for logs, tests, config and round trips. End-user display needs locale-aware formatting — thousands separators, symbol placement, the comma that German uses as a decimal point — and belongs at the edge of your system, with golang.org/x/text/currency or the equivalent in your frontend. Amount.Units hands out the pieces so that edge does not have to reimplement decimal placement:

major, minor := money.New(-1999, "EUR").Units() // 19, 99
// both are magnitudes; take the sign from IsNegative, because -0.99
// has no negative major unit to carry it

Errors

Error When
MismatchError two different currencies met in one operation; it carries both codes
ParseError a string could not be read as an amount; it carries the input and the reason
ErrInvalidCurrency a currency code was missing or not three upper-case letters
ErrRange a parsed value will not fit in int64 minor units
ErrDivideByZero a zero denominator reached MulDiv, or a Rate had one
ErrNegativeRate a conversion ratio had a negative side
ErrNegativeWeight an allocation weight was below zero
ErrNoBuckets an allocation had nowhere to put the money

ParseError wraps ErrInvalidCurrency or ErrRange where one applies, so errors.Is sorts a bad currency or an out-of-range value from a plain syntax error:

var mismatch money.MismatchError
if errors.As(err, &mismatch) {
    log.Printf("cannot mix %s with %s", mismatch.A, mismatch.B)
}

if errors.Is(err, money.ErrInvalidCurrency) {
    // the code was blank or malformed, wherever it came from
}

Storing amounts

Store the two fields as two columns — a BIGINT and a CHAR(3) — never as a FLOAT, and preferably not as a NUMERIC you then read back through a float.

price_minor    BIGINT NOT NULL,
price_currency CHAR(3) NOT NULL

Amount carries no db struct tags, deliberately: a library has no business naming your columns, and a fixed pair of names collides the moment one row holds two amounts. Scan into your own row struct and build the amount from it:

type priceRow struct {
    Minor    int64  `db:"price_minor"`
    Currency string `db:"price_currency"`
}
price := money.New(r.Minor, money.Currency(r.Currency))

Keeping the currency next to every stored amount, rather than assuming one currency per table or per tenant, is what lets the mismatch check do its job at the boundary instead of failing silently three joins later.

What this package deliberately does not do

  • Source exchange rates. Rate applies a ratio you supply; it will not fetch one, cache one, or decide when one went stale. Those are facts about your business, not about arithmetic.
  • Locale-aware display. Thousands separators, symbol placement and positioning rules need CLDR data, which would dwarf this package. See aboveAmount.Units is the hook.
  • A currency registry. Only the exponent matters for arithmetic, so only the exponents are here. Names, symbols and minor-unit names are presentation, and Currency is a plain string type you can key your own table on.
  • Locale-shaped parsing. Parse reads 19.99, not 19,99 or 1 234,56. Deciding which convention a given input follows is something only your system knows, and guessing wrong is silent.

Contributing

See CONTRIBUTING.md. Bug reports with a failing test case are the most useful thing there is.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages