humanblnk tutorial

Chapter 9 · Moving money

Currency exchange

Scenario: Ada sells $100 at ₦1,550.25 to the dollar. Her USD wallet goes down by $100 and her NGN wallet up by ₦155,025, together or not at all.

One transaction can't do this: a transaction is in one currency. So it's two transactions, recorded atomically with RecordAll.

Your FX desk

The other side of each half is your company's own account for currency it buys and sells. Make it a system account, kept in a variable so a typo can't create another one. As a system account it may go below zero, so it never blocks an exchange.

09-exchange/main.go
var fxDesk = humanblnk.SystemAccount("FXDesk")

The exchange

The naira amount is a credit to Ada, so it rounds down. The rate goes in Meta on both halves, for statements and audits.

09-exchange/main.go
sell := money.MustParse("100", demo.USD)
rate := decimal.RequireFromString("1550.25")
buy, err := money.Round(sell.Amount().Mul(rate), demo.NGN, money.Down) // credit rounds down
if err != nil {
    log.Fatal(err)
}
id := "fx_" + demo.Run
exchange := []humanblnk.Transaction{
    {
        Ref:  humanblnk.Reference("fx", id, "sell"),
        From: humanblnk.Single(humanblnk.AccountRef(usdWallet.ID), sell), To: humanblnk.Single(fxDesk, sell),
        Description: "Sell USD", Meta: humanblnk.Meta{"rate": rate.String()},
    },
    {
        Ref:  humanblnk.Reference("fx", id, "buy"),
        From: humanblnk.Single(fxDesk, buy), To: humanblnk.Single(humanblnk.AccountRef(ngnWallet.ID), buy),
        Description: "Buy NGN", Meta: humanblnk.Meta{"rate": rate.String()},
    },
}
entries, err := hb.Transactions.RecordAll(ctx, exchange)
if err != nil {
    log.Fatal(err) // ErrReadBack: it applied, but reading it back failed; look it up, don't redo it
}
for _, e := range entries {
    fmt.Printf("%s  %s %s\n", e.Ref, e.Amount, e.Amount.Code())
}
fmt.Println("USD:", demo.Available(ctx, hb, usdWallet.ID), "| NGN:", demo.Available(ctx, hb, ngnWallet.ID))

RecordAll rules:

  • Every transaction applies, or none does.
  • They may be in different currencies.
  • Each has one posting on each side (no splits).
  • The entries come back in the order you passed.

Retries and failures

A retry after a timeout moves nothing twice:

09-exchange/main.go
_, err = hb.Transactions.RecordAll(ctx, exchange)
fmt.Println("retried:", errors.Is(err, humanblnk.ErrDuplicate))
ResultMeans
ErrDuplicate, with the entriesthe whole exchange already happened
ErrReferenceUseda reference holds something that didn't apply: record it again under new ones
ErrInsufficientFundsnothing applied
ErrReadBackit applied, but reading the entries back failed. Don't record it again; look it up with GetByReference.

The whole example

go run ./docs/examples/09-exchange
09-exchange/main.go
// Chapter 9: a currency exchange, two transactions that apply together or
// not at all.
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/otyang/humanblnk"
    "github.com/otyang/humanblnk/docs/examples/demo"
    "github.com/otyang/humanblnk/money"
    "github.com/shopspring/decimal"
)

// fxDesk is your company's own account for currency it buys and sells: a system
// account, kept in a variable so a typo can't create another.
var fxDesk = humanblnk.SystemAccount("FXDesk")

func main() {
    ctx, hb := demo.Setup()
    usdWallet := demo.Customer(ctx, hb, "ada", demo.USD)
    owner := usdWallet.Owner
    ngnWallet, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: demo.NGN})
    if err != nil {
        log.Fatal(err)
    }
    demo.Deposit(ctx, hb, usdWallet, "200")

    // Ada sells $100 at ₦1,550.25 to the dollar.
    sell := money.MustParse("100", demo.USD)
    rate := decimal.RequireFromString("1550.25")
    buy, err := money.Round(sell.Amount().Mul(rate), demo.NGN, money.Down) // credit rounds down
    if err != nil {
        log.Fatal(err)
    }
    id := "fx_" + demo.Run
    exchange := []humanblnk.Transaction{
        {
            Ref:  humanblnk.Reference("fx", id, "sell"),
            From: humanblnk.Single(humanblnk.AccountRef(usdWallet.ID), sell), To: humanblnk.Single(fxDesk, sell),
            Description: "Sell USD", Meta: humanblnk.Meta{"rate": rate.String()},
        },
        {
            Ref:  humanblnk.Reference("fx", id, "buy"),
            From: humanblnk.Single(fxDesk, buy), To: humanblnk.Single(humanblnk.AccountRef(ngnWallet.ID), buy),
            Description: "Buy NGN", Meta: humanblnk.Meta{"rate": rate.String()},
        },
    }
    entries, err := hb.Transactions.RecordAll(ctx, exchange)
    if err != nil {
        log.Fatal(err) // ErrReadBack: it applied, but reading it back failed; look it up, don't redo it
    }
    for _, e := range entries {
        fmt.Printf("%s  %s %s\n", e.Ref, e.Amount, e.Amount.Code())
    }
    fmt.Println("USD:", demo.Available(ctx, hb, usdWallet.ID), "| NGN:", demo.Available(ctx, hb, ngnWallet.ID))

    // The app retries after a timeout: nothing moves twice.
    _, err = hb.Transactions.RecordAll(ctx, exchange)
    fmt.Println("retried:", errors.Is(err, humanblnk.ErrDuplicate))

    // Selling more than she has: neither half applies.
    tooMuch := money.MustParse("500", demo.USD)
    _, err = hb.Transactions.RecordAll(ctx, []humanblnk.Transaction{
        {Ref: humanblnk.Reference("fx", id+"_big", "sell"), From: humanblnk.Single(humanblnk.AccountRef(usdWallet.ID), tooMuch), To: humanblnk.Single(fxDesk, tooMuch), Description: "Sell USD"},
        {Ref: humanblnk.Reference("fx", id+"_big", "buy"), From: humanblnk.Single(fxDesk, money.MustParse("775125", demo.NGN)), To: humanblnk.Single(humanblnk.AccountRef(ngnWallet.ID), money.MustParse("775125", demo.NGN)), Description: "Buy NGN"},
    })
    fmt.Println("too much:", errors.Is(err, humanblnk.ErrInsufficientFunds))
    fmt.Println("USD:", demo.Available(ctx, hb, usdWallet.ID), "| NGN:", demo.Available(ctx, hb, ngnWallet.ID))
}

RecordAll holds the batch and captures it at once, as Record does a split (chapter 5), so if a later item fails, the earlier ones never move.