humanblnk tutorial

Chapter 5 · Moving money

Transfers and fees

Scenario: Ada sends Bola ₦10,000. You charge a 1.5% fee on top. The app shows Ada what will happen before she confirms, and refuses if she can't afford it.

Step 1: build the transfer as one split

The payment to Bola and your fee are one transaction with two postings on the To side. It applies in full or not at all: Bola is never paid without the fee being recorded, or the other way round.

05-transfers/main.go
func transfer(id string, from, to humanblnk.Account, amount money.Money) humanblnk.Transaction {
    fee, _ := amount.Percent(decimal.RequireFromString("1.5"), money.Up)
    total, _ := amount.Add(fee)
    return humanblnk.Transaction{
        Ref:  humanblnk.Reference("transfer", id),
        From: humanblnk.Single(humanblnk.AccountRef(from.ID), total),
        To: humanblnk.Many(
            humanblnk.Posting{Account: humanblnk.AccountRef(to.ID), Amount: amount},
            humanblnk.Posting{Account: humanblnk.FeeIncome, Amount: fee},
        ),
        Description: "Transfer to Bola",
    }
}

Both sides must add up to the same amount, or it's ErrUnbalanced. One side may have several postings, but not both: Blnk moves one account to many, or many to one.

Step 2: the dry run

Preview checks the transaction exactly as Record would, funds included, but moves nothing and leaves the reference unused:

05-transfers/main.go
p, err := hb.Transactions.Preview(ctx, t)
if err != nil {
    log.Fatal(err) // the request itself is wrong: bad account, currency, …
}
fmt.Println("would apply:", p.WouldApply)
for _, c := range p.Changes {
    fmt.Printf("  %s: %s -> %s\n", c.Account, c.AvailableBefore, c.AvailableAfter)
}

When it wouldn't apply, the reason is in p.Rejection and err is nil. errors.Is(p.Rejection, humanblnk.ErrInsufficientFunds) works.

Step 3: record it

The same transaction, for real:

05-transfers/main.go
e, err := hb.Transactions.Record(ctx, t)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("recorded %s: %s from Ada, %d postings to\n", e.ID, e.Amount, len(e.To))
fmt.Println("Ada:", demo.Available(ctx, hb, ada.ID), "| Bola:", demo.Available(ctx, hb, bola.ID))

If Ada can't afford it, the dry run says why, and Record refuses with humanblnk.ErrInsufficientFunds. Nothing moves:

05-transfers/main.go
big := transfer("tr_big_"+demo.Run, ada, bola, money.MustParse("100000", demo.NGN))
p, _ = hb.Transactions.Preview(ctx, big)
fmt.Println("too much, would apply:", p.WouldApply, "| because:", p.Rejection)
_, err = hb.Transactions.Record(ctx, big)
fmt.Println("recording it anyway: insufficient funds?", errors.Is(err, humanblnk.ErrInsufficientFunds))

Step 4: her history

05-transfers/main.go
out, n, err := hb.Transactions.List(ctx, humanblnk.EntryQuery{From: humanblnk.AccountRef(ada.ID), Status: humanblnk.StatusApplied, Limit: 10})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("%d entries out of Ada's wallet:\n", n)
for _, e := range out {
    fmt.Printf("  %s  %s  %s\n", e.Ref, e.Amount, e.Status)
}

Newest first. List shows Blnk's own records, each posting of a split as its own entry. Record sends a split as a hold and captures it at once (see below), so Blnk keeps each posting twice: held (StatusHeld) and captured (StatusApplied, under the transfer's reference). The example asks for StatusApplied: the money that moved. Blnk can't combine "from or to" with other conditions, so a full history is two queries: one with From, one with To. EntryQuery also filters by Status, Since and Until.

The whole example

go run ./docs/examples/05-transfers
05-transfers/main.go
// Chapter 5: a transfer between customers with a fee, checked first with a
// dry run.
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"
)

// transfer builds Ada-pays-Bola with your 1.5% fee on top, as one split:
// Bola is never paid without the fee being recorded, or the other way round.
func transfer(id string, from, to humanblnk.Account, amount money.Money) humanblnk.Transaction {
    fee, _ := amount.Percent(decimal.RequireFromString("1.5"), money.Up)
    total, _ := amount.Add(fee)
    return humanblnk.Transaction{
        Ref:  humanblnk.Reference("transfer", id),
        From: humanblnk.Single(humanblnk.AccountRef(from.ID), total),
        To: humanblnk.Many(
            humanblnk.Posting{Account: humanblnk.AccountRef(to.ID), Amount: amount},
            humanblnk.Posting{Account: humanblnk.FeeIncome, Amount: fee},
        ),
        Description: "Transfer to Bola",
    }
}

func main() {
    ctx, hb := demo.Setup()
    ada := demo.Customer(ctx, hb, "ada", demo.NGN)
    bola := demo.Customer(ctx, hb, "bola", demo.NGN)
    demo.Deposit(ctx, hb, ada, "50000")

    t := transfer("tr_"+demo.Run, ada, bola, money.MustParse("10000", demo.NGN))

    // 1. Dry run: what would happen? Nothing moves, and the reference stays free.
    p, err := hb.Transactions.Preview(ctx, t)
    if err != nil {
        log.Fatal(err) // the request itself is wrong: bad account, currency, …
    }
    fmt.Println("would apply:", p.WouldApply)
    for _, c := range p.Changes {
        fmt.Printf("  %s: %s -> %s\n", c.Account, c.AvailableBefore, c.AvailableAfter)
    }

    // 2. Do it, with the same transaction.
    e, err := hb.Transactions.Record(ctx, t)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("recorded %s: %s from Ada, %d postings to\n", e.ID, e.Amount, len(e.To))
    fmt.Println("Ada:", demo.Available(ctx, hb, ada.ID), "| Bola:", demo.Available(ctx, hb, bola.ID))

    // 3. Too much: the dry run says why, and Record refuses.
    big := transfer("tr_big_"+demo.Run, ada, bola, money.MustParse("100000", demo.NGN))
    p, _ = hb.Transactions.Preview(ctx, big)
    fmt.Println("too much, would apply:", p.WouldApply, "| because:", p.Rejection)
    _, err = hb.Transactions.Record(ctx, big)
    fmt.Println("recording it anyway: insufficient funds?", errors.Is(err, humanblnk.ErrInsufficientFunds))

    // 4. Ada's history: money out of her wallet, newest first. (Money into
    // it is a second query with To set.) StatusApplied leaves out the holds
    // Record captured: what moved, not what was reserved on the way.
    out, n, err := hb.Transactions.List(ctx, humanblnk.EntryQuery{From: humanblnk.AccountRef(ada.ID), Status: humanblnk.StatusApplied, Limit: 10})
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%d entries out of Ada's wallet:\n", n)
    for _, e := range out {
        fmt.Printf("  %s  %s  %s\n", e.Ref, e.Amount, e.Status)
    }
}

Good to know

  • A split that fails partway moves nothing. Blnk moves a split one posting at a time: applied directly, a later posting that fails leaves the earlier ones moved until Blnk's worker refunds them. So Record holds the split first and captures it at once. A hold that fails partway is released before Blnk answers: Record returns the error and no account ever shows the earlier postings. The entry still reads as one applied transaction, with the id Record returned.
  • Retrying that split with the same Ref returns ErrReferenceUsed, not ErrDuplicate: nothing stands under that reference, so record it again under a new one (chapter 12).