Chapter 6 · Moving money
Payouts and holds
Scenario: Ada withdraws ₦20,000 to her bank. The money must leave her spendable balance at once, but only really leaves when the bank confirms. A second withdrawal is rejected by the bank. Then she takes a ride: ₦6,000 is reserved on the estimate, and ₦4,200 is charged.
How a hold works
Placereserves the amount. It leavesFrom'sAvailableat once, but doesn't reachToyet.- When the outside world answers,
Capturemoves it (all or part; the rest is released), orCancelgives it all back. - An optional expiry cancels the hold if neither happens in time.
Step 1: place the hold
payout := humanblnk.Transaction{
Ref: humanblnk.Reference("payout", "po_1_"+demo.Run),
From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("20000")),
To: humanblnk.Single(humanblnk.World, ngn("20000")), // leaving your books
Description: "Withdrawal to GTBank ****1234",
}
hold, err := hb.Holds.Place(ctx, payout, time.Now().Add(72*time.Hour))
if err != nil {
log.Fatal(err) // humanblnk.ErrInsufficientFunds: tell Ada
}
acct, _ := hb.Accounts.Get(ctx, ada.ID)
fmt.Printf("held %s: Total %s, OutgoingHeld %s, Available %s\n", hold, acct.Total, acct.OutgoingHeld, acct.Available)
// Placing it again (a retry) gives back the same hold.
same, _ := hb.Holds.Place(ctx, payout, time.Now().Add(72*time.Hour))
fmt.Println("placed again, same hold:", same == hold)While it's held, Accounts.Get shows OutgoingHeld 20,000 and Available 20,000 less. Store the hold id with your payout, so the bank's callback can find it. Placing the same transaction again (a retry) returns the same hold.
Step 2: the bank confirms
The zero Money captures all of it. Capturing again (a retried callback) returns the same capture and moves nothing more:
e, err := hb.Holds.Capture(ctx, hold, money.Money{})
if err != nil {
log.Fatal(err)
}
fmt.Println("captured", e.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID))
// Capturing again (a retried callback) finishes nothing twice.
again, err := hb.Holds.Capture(ctx, hold, money.Money{})
fmt.Println("captured again:", again.Amount, err)Step 3: the bank rejects one
Cancel gives the money back, and is safe to repeat. Capturing after a cancel is ErrHoldSettled:
h2, err := hb.Holds.Place(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("payout", "po_2_"+demo.Run),
From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("5000")), To: humanblnk.Single(humanblnk.World, ngn("5000")),
Description: "Withdrawal to a closed account",
}, time.Time{}) // no expiry: only the bank's answer settles it
if err != nil {
log.Fatal(err)
}
if err := hb.Holds.Cancel(ctx, h2); err != nil {
log.Fatal(err)
}
fmt.Println("bank rejected, cancelled | Ada:", demo.Available(ctx, hb, ada.ID))
fmt.Println("cancel again:", hb.Holds.Cancel(ctx, h2)) // safe to repeat
_, err = hb.Holds.Capture(ctx, h2, money.Money{})
fmt.Println("capture after cancel is settled:", errors.Is(err, humanblnk.ErrHoldSettled))| Call | Repeated |
|---|---|
Capture(ctx, hold, sameAmount) | returns the same capture, moves nothing more |
Cancel(ctx, hold) | nil |
Capture after Cancel, or a different amount after a capture | ErrHoldSettled |
A partial capture
The ride reserved ₦6,000 on the estimate; the fare was ₦4,200. ₦4,200 moves and the other ₦1,800 goes back to Ada at once:
h3, err := hb.Holds.Place(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("ride", "rd_"+demo.Run),
From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("6000")), To: humanblnk.Single(humanblnk.SystemAccount("RidesClearing"), ngn("6000")),
Description: "Ride estimate",
}, time.Now().Add(2*time.Hour))
if err != nil {
log.Fatal(err)
}
fare, err := hb.Holds.Capture(ctx, h3, ngn("4200")) // the actual fare; the rest goes back
if err != nil {
log.Fatal(err)
}
fmt.Println("ride charged", fare.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID))A capture is final: whatever isn't captured is released. A split hold (a payout and its fee, say) can only be captured in full.
The whole example
go run ./docs/examples/06-payouts// Chapter 6: holds. Reserve money now, settle it when the outside world
// answers.
package main
import (
"errors"
"fmt"
"log"
"time"
"github.com/otyang/humanblnk"
"github.com/otyang/humanblnk/docs/examples/demo"
"github.com/otyang/humanblnk/money"
)
func main() {
ctx, hb := demo.Setup()
ada := demo.Customer(ctx, hb, "ada", demo.NGN)
demo.Deposit(ctx, hb, ada, "50000")
ngn := func(v string) money.Money { return money.MustParse(v, demo.NGN) }
// --- A withdrawal to Ada's bank account ---
payout := humanblnk.Transaction{
Ref: humanblnk.Reference("payout", "po_1_"+demo.Run),
From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("20000")),
To: humanblnk.Single(humanblnk.World, ngn("20000")), // leaving your books
Description: "Withdrawal to GTBank ****1234",
}
hold, err := hb.Holds.Place(ctx, payout, time.Now().Add(72*time.Hour))
if err != nil {
log.Fatal(err) // humanblnk.ErrInsufficientFunds: tell Ada
}
acct, _ := hb.Accounts.Get(ctx, ada.ID)
fmt.Printf("held %s: Total %s, OutgoingHeld %s, Available %s\n", hold, acct.Total, acct.OutgoingHeld, acct.Available)
// Placing it again (a retry) gives back the same hold.
same, _ := hb.Holds.Place(ctx, payout, time.Now().Add(72*time.Hour))
fmt.Println("placed again, same hold:", same == hold)
// The bank confirms: capture all of it.
e, err := hb.Holds.Capture(ctx, hold, money.Money{})
if err != nil {
log.Fatal(err)
}
fmt.Println("captured", e.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID))
// Capturing again (a retried callback) finishes nothing twice.
again, err := hb.Holds.Capture(ctx, hold, money.Money{})
fmt.Println("captured again:", again.Amount, err)
// --- A withdrawal the bank rejects ---
h2, err := hb.Holds.Place(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("payout", "po_2_"+demo.Run),
From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("5000")), To: humanblnk.Single(humanblnk.World, ngn("5000")),
Description: "Withdrawal to a closed account",
}, time.Time{}) // no expiry: only the bank's answer settles it
if err != nil {
log.Fatal(err)
}
if err := hb.Holds.Cancel(ctx, h2); err != nil {
log.Fatal(err)
}
fmt.Println("bank rejected, cancelled | Ada:", demo.Available(ctx, hb, ada.ID))
fmt.Println("cancel again:", hb.Holds.Cancel(ctx, h2)) // safe to repeat
_, err = hb.Holds.Capture(ctx, h2, money.Money{})
fmt.Println("capture after cancel is settled:", errors.Is(err, humanblnk.ErrHoldSettled))
// --- A partial capture: a ride, estimated then charged ---
h3, err := hb.Holds.Place(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("ride", "rd_"+demo.Run),
From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("6000")), To: humanblnk.Single(humanblnk.SystemAccount("RidesClearing"), ngn("6000")),
Description: "Ride estimate",
}, time.Now().Add(2*time.Hour))
if err != nil {
log.Fatal(err)
}
fare, err := hb.Holds.Capture(ctx, h3, ngn("4200")) // the actual fare; the rest goes back
if err != nil {
log.Fatal(err)
}
fmt.Println("ride charged", fare.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID))
}held txn_cfe718da-ff3a-4630-bab2-0b9a634c9c0f: Total 50000.00, OutgoingHeld 20000.00, Available 30000.00
placed again, same hold: true
captured 20000.00 | Ada: 30000.00 NGN
captured again: 20000.00 <nil>
bank rejected, cancelled | Ada: 30000.00 NGN
cancel again: <nil>
capture after cancel is settled: true
ride charged 4200.00 | Ada: 25800.00 NGNGood to know
- A hold's own transaction stays
StatusHeld(INFLIGHT) for good. The capture and the release are separate transactions under it, so don't use the hold's status to ask "did it settle?".Capture's return value, orErrHoldSettled, tells you. - To undo money a capture already moved, use
Transactions.Reverse(chapter 8) with the idCapturereturned, or the hold's own id: a split hold's capture is reversed as a whole.Cancelonly releases money still held. - Retrying
Placereturns the same hold, captured or not, so a whole purchase is safe to retry. If the hold was released instead (cancelled, or expired),PlacesaysErrHoldSettled: don't deliver against it.