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.
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.
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:
_, err = hb.Transactions.RecordAll(ctx, exchange)
fmt.Println("retried:", errors.Is(err, humanblnk.ErrDuplicate))| Result | Means |
|---|---|
ErrDuplicate, with the entries | the whole exchange already happened |
ErrReferenceUsed | a reference holds something that didn't apply: record it again under new ones |
ErrInsufficientFunds | nothing applied |
ErrReadBack | it 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// 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))
}fx:fx_1792634717768817000:sell 100.00 USD
fx:fx_1792634717768817000:buy 155025.00 NGN
USD: 100.00 USD | NGN: 155025.00 NGN
retried: true
too much: true
USD: 100.00 USD | NGN: 155025.00 NGNRecordAll 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.