humanblnk tutorial

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

03-money/main.go
ngn, ok := currency.Get("NGN")
if !ok {
    panic("no NGN")
}
usd := currency.MustGet(currency.USD) // for constants you know exist

Parsing

Parsing is strict, so a bad amount is caught at the edge of your system.

03-money/main.go
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)
}
InputResult
"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

03-money/main.go
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, Sub and Cmp work only within one currency; anything else is money.ErrCurrencyMismatch.
  • Compare with Equal or Cmp, never ==. 1.5 and 1.50 USD are Equal.
  • 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.

03-money/main.go
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.

ModeUse for
money.Upa fee you charge: never undercharge by a fraction of a cent (or kobo)
money.Downan amount paid out or credited: never pay out more than exists
money.HalfEvenreports
03-money/main.go
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.

03-money/main.go
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
03-money/main.go
// 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)
}