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.
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:
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:
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:
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
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// 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)
}
}would apply: true
bln_55706683-ad70-42bc-b8a9-9a9879bb812b: 50000.00 -> 39850.00
bln_963197b3-9f8c-44ba-ab6a-c6e61e0b632b: 0.00 -> 10000.00
@FeeIncome: 0.00 -> 150.00
recorded txn_55b9addf-0927-483e-b481-4f698d4119bc: 10150.00 from Ada, 2 postings to
Ada: 39850.00 NGN | Bola: 10000.00 NGN
too much, would apply: false | because: humanblnk: insufficient funds: failed to apply transaction to balances: insufficient funds in source balance
recording it anyway: insufficient funds? true
2 entries out of Ada's wallet:
transfer:tr_1792678032583143000 150.00 APPLIED
transfer:tr_1792678032583143000 10000.00 APPLIEDGood 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
Recordholds the split first and captures it at once. A hold that fails partway is released before Blnk answers:Recordreturns the error and no account ever shows the earlier postings. The entry still reads as one applied transaction, with the idRecordreturned. - Retrying that split with the same
RefreturnsErrReferenceUsed, notErrDuplicate: nothing stands under that reference, so record it again under a new one (chapter 12).