Chapter 7 · Moving money
Building a service: airtime
Scenario: Ada buys ₦1,000 of airtime (prepaid mobile phone credit) from her wallet. The airtime provider (VTpass, say) can deliver, decline, or not answer in time.
This chapter shows the shape every business feature takes: its own package that imports humanblnk, never the other way round. humanblnk knows nothing about airtime; the airtime package decides what to take and when.
The design: a hold around the provider call
The provider is outside your books and can fail or time out, so the money is held first, not taken outright:
- Hold the amount. If Ada can't afford it, stop before calling the provider.
- Call the provider.
- Delivered: capture. Declined: cancel. No clear answer: leave it held, and settle it when the provider's webhook or a status check answers.
The money goes to the provider's clearing account, humanblnk.ClearingAccount("vtpass"): what you owe the provider, or how much of a prepaid float is used.
Step 1: depend on interfaces
The package names the small part of humanblnk it uses, so tests can pass a fake. hb.Holds satisfies Holds. Chapter 13 tests this service against an in-memory Blnk instead, so only the provider is faked, not humanblnk.
type Holds interface {
Place(ctx context.Context, t humanblnk.Transaction, expires time.Time) (humanblnk.HoldID, error)
Capture(ctx context.Context, h humanblnk.HoldID, amount money.Money) (humanblnk.Entry, error)
Cancel(ctx context.Context, h humanblnk.HoldID) error
}
// Provider sends airtime (VTpass, Reloadly, …). id is ours and the same on
// every retry, so the provider never sends twice.
type Provider interface {
Buy(ctx context.Context, id, phone string, amount money.Money) error
}Step 2: buy
Buy places the hold, calls the provider, then settles. It's safe to call again with the same Purchase: Place returns the same hold, and a full capture can't move money twice.
func (s *Service) Buy(ctx context.Context, p Purchase) (humanblnk.HoldID, error) {
hold, err := s.holds.Place(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("airtime", p.ID),
From: humanblnk.Single(p.Wallet, p.Amount),
To: humanblnk.Single(humanblnk.ClearingAccount(s.name), p.Amount),
Description: "Airtime " + p.Phone,
Meta: humanblnk.Meta{"phone": p.Phone, "provider": s.name},
}, time.Time{}) // no expiry: only the provider's answer settles it
if err != nil {
return "", err // humanblnk.ErrInsufficientFunds: tell the customer
}
err = s.provider.Buy(ctx, p.ID, p.Phone, p.Amount)
switch {
case err == nil:
return hold, s.Settle(ctx, hold, true)
case errors.Is(err, ErrDeclined):
return hold, errors.Join(err, s.Settle(ctx, hold, false))
default:
return hold, fmt.Errorf("%w: %v", ErrPending, err)
}
}Step 3: settle
Settle captures or cancels. The provider's webhook handler calls it for a purchase that was pending.
func (s *Service) Settle(ctx context.Context, hold humanblnk.HoldID, delivered bool) error {
if !delivered {
return s.holds.Cancel(ctx, hold)
}
_, err := s.holds.Capture(ctx, hold, money.Money{})
return err
}Step 4: wire it up
The example plays a provider that delivers, declines, or times out, depending on the phone number:
type provider struct{}
func (provider) Buy(_ context.Context, id, phone string, _ money.Money) error {
switch phone {
case "08000000000":
return airtime.ErrDeclined
case "08099999999":
return errors.New("timeout talking to VTpass")
}
return nil
}air := airtime.New(hb.Holds, provider{}, "vtpass")
buy := func(id, phone string) (humanblnk.HoldID, error) {
return air.Buy(ctx, airtime.Purchase{
ID: id + "_" + demo.Run, Wallet: humanblnk.AccountRef(ada.ID), Phone: phone,
Amount: money.MustParse("1000", demo.NGN),
})
}
_, err := buy("air_1", "08031234567")
fmt.Println("delivered:", err, "| Ada:", demo.Available(ctx, hb, ada.ID))
_, err = buy("air_2", "08000000000")
fmt.Println("declined:", errors.Is(err, airtime.ErrDeclined), "| Ada:", demo.Available(ctx, hb, ada.ID))
pending, err := buy("air_3", "08099999999")
fmt.Println("no answer:", errors.Is(err, airtime.ErrPending), "| Ada:", demo.Available(ctx, hb, ada.ID), "(1000 still held)")
// Later the provider's webhook says it went through.
if err := air.Settle(ctx, pending, true); err != nil {
fmt.Println(err)
}
fmt.Println("settled after the webhook | Ada:", demo.Available(ctx, hb, ada.ID))The whole service
// Package airtime sells airtime, paid from the customer's wallet. It is the
// shape every business service takes: it imports humanblnk, names the small
// part of it it uses as an interface, and keeps its own rules.
package airtime
import (
"context"
"errors"
"fmt"
"time"
"github.com/otyang/humanblnk"
"github.com/otyang/humanblnk/money"
)
// Holds is the part of humanblnk airtime uses; hb.Holds satisfies it,
// and a test can pass a fake.
type Holds interface {
Place(ctx context.Context, t humanblnk.Transaction, expires time.Time) (humanblnk.HoldID, error)
Capture(ctx context.Context, h humanblnk.HoldID, amount money.Money) (humanblnk.Entry, error)
Cancel(ctx context.Context, h humanblnk.HoldID) error
}
// Provider sends airtime (VTpass, Reloadly, …). id is ours and the same on
// every retry, so the provider never sends twice.
type Provider interface {
Buy(ctx context.Context, id, phone string, amount money.Money) error
}
var (
// ErrDeclined is a provider's certain "no": nothing was sent.
ErrDeclined = errors.New("airtime: provider declined")
// ErrPending is no clear answer yet: the money stays held until Settle.
ErrPending = errors.New("airtime: provider hasn't answered yet")
)
// Service sells airtime.
type Service struct {
holds Holds
provider Provider
name string // the provider, for its clearing account
}
// New returns a Service selling through provider.
func New(h Holds, p Provider, providerName string) *Service {
return &Service{h, p, providerName}
}
// Purchase is one sale.
type Purchase struct {
ID string // ours, unique per purchase, the same on every retry
Wallet humanblnk.AccountRef // the customer's wallet
Phone string
Amount money.Money
}
// Buy takes the money and sends the airtime. It is safe to call again with
// the same Purchase.
func (s *Service) Buy(ctx context.Context, p Purchase) (humanblnk.HoldID, error) {
hold, err := s.holds.Place(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("airtime", p.ID),
From: humanblnk.Single(p.Wallet, p.Amount),
To: humanblnk.Single(humanblnk.ClearingAccount(s.name), p.Amount),
Description: "Airtime " + p.Phone,
Meta: humanblnk.Meta{"phone": p.Phone, "provider": s.name},
}, time.Time{}) // no expiry: only the provider's answer settles it
if err != nil {
return "", err // humanblnk.ErrInsufficientFunds: tell the customer
}
err = s.provider.Buy(ctx, p.ID, p.Phone, p.Amount)
switch {
case err == nil:
return hold, s.Settle(ctx, hold, true)
case errors.Is(err, ErrDeclined):
return hold, errors.Join(err, s.Settle(ctx, hold, false))
default:
return hold, fmt.Errorf("%w: %v", ErrPending, err)
}
}
// Settle finishes a purchase once the provider has answered: from Buy, its
// webhook, or a status check.
func (s *Service) Settle(ctx context.Context, hold humanblnk.HoldID, delivered bool) error {
if !delivered {
return s.holds.Cancel(ctx, hold)
}
_, err := s.holds.Capture(ctx, hold, money.Money{})
return err
}The whole example
go run ./docs/examples/07-airtime// Chapter 7: a business service on top of humanblnk. airtime/ is the
// service; this program plays a provider that delivers, declines, or
// doesn't answer.
package main
import (
"context"
"errors"
"fmt"
"github.com/otyang/humanblnk"
"github.com/otyang/humanblnk/docs/examples/07-airtime/airtime"
"github.com/otyang/humanblnk/docs/examples/demo"
"github.com/otyang/humanblnk/money"
)
// provider answers by phone number, to show each outcome.
type provider struct{}
func (provider) Buy(_ context.Context, id, phone string, _ money.Money) error {
switch phone {
case "08000000000":
return airtime.ErrDeclined
case "08099999999":
return errors.New("timeout talking to VTpass")
}
return nil
}
func main() {
ctx, hb := demo.Setup()
ada := demo.Customer(ctx, hb, "ada", demo.NGN)
demo.Deposit(ctx, hb, ada, "5000")
air := airtime.New(hb.Holds, provider{}, "vtpass")
buy := func(id, phone string) (humanblnk.HoldID, error) {
return air.Buy(ctx, airtime.Purchase{
ID: id + "_" + demo.Run, Wallet: humanblnk.AccountRef(ada.ID), Phone: phone,
Amount: money.MustParse("1000", demo.NGN),
})
}
_, err := buy("air_1", "08031234567")
fmt.Println("delivered:", err, "| Ada:", demo.Available(ctx, hb, ada.ID))
_, err = buy("air_2", "08000000000")
fmt.Println("declined:", errors.Is(err, airtime.ErrDeclined), "| Ada:", demo.Available(ctx, hb, ada.ID))
pending, err := buy("air_3", "08099999999")
fmt.Println("no answer:", errors.Is(err, airtime.ErrPending), "| Ada:", demo.Available(ctx, hb, ada.ID), "(1000 still held)")
// Later the provider's webhook says it went through.
if err := air.Settle(ctx, pending, true); err != nil {
fmt.Println(err)
}
fmt.Println("settled after the webhook | Ada:", demo.Available(ctx, hb, ada.ID))
// More than she has.
_, err = air.Buy(ctx, airtime.Purchase{
ID: "air_4_" + demo.Run, Wallet: humanblnk.AccountRef(ada.ID), Phone: "08031234567",
Amount: money.MustParse("50000", demo.NGN),
})
fmt.Println("too much:", errors.Is(err, humanblnk.ErrInsufficientFunds))
}delivered: <nil> | Ada: 4000.00 NGN
declined: true | Ada: 4000.00 NGN
no answer: true | Ada: 3000.00 NGN (1000 still held)
settled after the webhook | Ada: 3000.00 NGN
too much: trueWhat a real service adds
- Its own table (purchase id, wallet, phone, amount, hold id, status), so the webhook and a retry job can find pending purchases and call
Settle. - A provider that is safe to retry: send your purchase id as the provider's request id, so a retry never sends airtime twice.
- No hold expiry: if Blnk expired the hold while the provider had in fact delivered, the airtime would be free.
- A fee or commission: make
Toa split withhumanblnk.Many(...). Splits capture only in full, which is allSettledoes.