humanblnk tutorial

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

  1. Place reserves the amount. It leaves From's Available at once, but doesn't reach To yet.
  2. When the outside world answers, Capture moves it (all or part; the rest is released), or Cancel gives it all back.
  3. An optional expiry cancels the hold if neither happens in time.

Step 1: place the hold

06-payouts/main.go
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:

06-payouts/main.go
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:

06-payouts/main.go
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))
CallRepeated
Capture(ctx, hold, sameAmount)returns the same capture, moves nothing more
Cancel(ctx, hold)nil
Capture after Cancel, or a different amount after a captureErrHoldSettled

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:

06-payouts/main.go
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
06-payouts/main.go
// 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))
}

Good 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, or ErrHoldSettled, tells you.
  • To undo money a capture already moved, use Transactions.Reverse (chapter 8) with the id Capture returned, or the hold's own id: a split hold's capture is reversed as a whole. Cancel only releases money still held.
  • Retrying Place returns the same hold, captured or not, so a whole purchase is safe to retry. If the hold was released instead (cancelled, or expired), Place says ErrHoldSettled: don't deliver against it.