Chapter 3 · Foundations
Money
Scenario: a price of ₦1,500.50, a 1.5% fee, and the total that goes into your books and out as JSON.
money.Money is an exact amount in one currency. It is never a float: amounts are parsed from the decimal someone wrote and kept exact. Every amount you give humanblnk is a money.Money. This chapter needs no Blnk.
Currencies
ngn, ok := currency.Get("NGN")
if !ok {
panic("no NGN")
}
usd := currency.MustGet(currency.USD) // for constants you know existParsing
Parsing is strict, so a bad amount is caught at the edge of your system.
price, err := money.Parse("1500.50", ngn)
fmt.Println("price:", price, price.Code(), err)
for _, bad := range []string{"1500.505", "1e3", "1,500"} {
_, err := money.Parse(bad, ngn)
fmt.Printf("Parse(%q): %v\n", bad, err)
}| Input | Result |
|---|---|
"1500.50", "-3", "1500" | fine |
"1500.505" | ErrTooManyDecimals: NGN has 2 |
"1e3", "1,500", ".5", "+3" | ErrInvalidAmount |
money.MustParse panics instead of returning an error. Use it only for constants in your own code, never for what a client sent.
Arithmetic
fee, _ := price.Percent(decimal.RequireFromString("1.5"), money.Up)
total, _ := price.Add(fee)
fmt.Println("fee:", fee, "| total:", total)
// Only one currency at a time.
_, err = price.Add(money.MustParse("10", usd))
fmt.Println("NGN + USD:", err, "| is ErrCurrencyMismatch:", errors.Is(err, money.ErrCurrencyMismatch))
// Compare with Cmp and Equal, never ==.
cmp, _ := total.Cmp(price)
fmt.Println("total > price:", cmp > 0, "| 1.5 equals 1.50:", money.MustParse("1.5", usd).Equal(money.MustParse("1.50", usd)))Add,SubandCmpwork only within one currency; anything else ismoney.ErrCurrencyMismatch.- Compare with
EqualorCmp, never==.1.5and1.50USD areEqual. - Also:
IsPositive,IsNegative,IsZeroAmount,Sign,Neg,Abs,Amount(the decimal),Code,Currency,Valid.
The smallest unit
Ledgers store integers: kobo, cents, wei. Minor and FromMinor convert exactly, even for 18-decimal crypto.
minor, _ := total.Minor()
fmt.Println("in kobo:", minor, "| precision:", total.Precision())
back, _ := money.FromMinor(minor, ngn)
fmt.Println("back from kobo:", back)Rounding: only where you ask
Nothing rounds by itself. Where you need it, you choose the direction.
| Mode | Use for |
|---|---|
money.Up | a fee you charge: never undercharge by a fraction of a cent (or kobo) |
money.Down | an amount paid out or credited: never pay out more than exists |
money.HalfEven | reports |
share := decimal.RequireFromString("100").Div(decimal.NewFromInt(3)) // 33.333…
payout, _ := money.Round(share, ngn, money.Down) // pay out: round down
fmt.Println("a third of 100, paid out:", payout)JSON
The amount is a string, so nothing in between turns it into a float. Decoding is strict: exactly amount and currency, with the code in capitals.
b, _ := json.Marshal(total)
fmt.Println("JSON:", string(b))
var decoded money.Money
err = json.Unmarshal([]byte(`{"amount":"99.99","currency":"USD"}`), &decoded)
fmt.Println("decoded:", decoded, decoded.Code(), err)The whole example
go run ./docs/examples/03-money// Chapter 3: exact amounts with money. No Blnk needed.
package main
import (
"encoding/json"
"errors"
"fmt"
"github.com/otyang/humanblnk/money"
"github.com/otyang/humanblnk/money/currency"
"github.com/shopspring/decimal"
)
func main() {
// Look the currency up once. Get is for codes people type; Lookup for
// codes from storage.
ngn, ok := currency.Get("NGN")
if !ok {
panic("no NGN")
}
usd := currency.MustGet(currency.USD) // for constants you know exist
// Parse what a person or client wrote.
price, err := money.Parse("1500.50", ngn)
fmt.Println("price:", price, price.Code(), err)
for _, bad := range []string{"1500.505", "1e3", "1,500"} {
_, err := money.Parse(bad, ngn)
fmt.Printf("Parse(%q): %v\n", bad, err)
}
// A 1.5% fee, rounded up: you never undercharge by a fraction of a kobo.
fee, _ := price.Percent(decimal.RequireFromString("1.5"), money.Up)
total, _ := price.Add(fee)
fmt.Println("fee:", fee, "| total:", total)
// Only one currency at a time.
_, err = price.Add(money.MustParse("10", usd))
fmt.Println("NGN + USD:", err, "| is ErrCurrencyMismatch:", errors.Is(err, money.ErrCurrencyMismatch))
// Compare with Cmp and Equal, never ==.
cmp, _ := total.Cmp(price)
fmt.Println("total > price:", cmp > 0, "| 1.5 equals 1.50:", money.MustParse("1.5", usd).Equal(money.MustParse("1.50", usd)))
// The smallest unit, as a ledger stores it.
minor, _ := total.Minor()
fmt.Println("in kobo:", minor, "| precision:", total.Precision())
back, _ := money.FromMinor(minor, ngn)
fmt.Println("back from kobo:", back)
// Rounding only where you ask, in a direction you choose.
share := decimal.RequireFromString("100").Div(decimal.NewFromInt(3)) // 33.333…
payout, _ := money.Round(share, ngn, money.Down) // pay out: round down
fmt.Println("a third of 100, paid out:", payout)
// JSON is exact both ways.
b, _ := json.Marshal(total)
fmt.Println("JSON:", string(b))
var decoded money.Money
err = json.Unmarshal([]byte(`{"amount":"99.99","currency":"USD"}`), &decoded)
fmt.Println("decoded:", decoded, decoded.Code(), err)
}price: 1500.50 NGN <nil>
Parse("1500.505"): more decimal places than the currency has: 1500.505 has 2 for NGN
Parse("1e3"): invalid amount: "1e3"
Parse("1,500"): invalid amount: "1,500"
fee: 22.51 | total: 1523.01
NGN + USD: currencies differ: NGN and USD | is ErrCurrencyMismatch: true
total > price: true | 1.5 equals 1.50: true
in kobo: 152301 | precision: 100
back from kobo: 1523.01
a third of 100, paid out: 33.33
JSON: {"amount":"1523.01","currency":"NGN"}
decoded: 99.99 USD <nil>