Chapter 2 · Foundations
Customers and accounts
Scenario: Ada signs up. She gets a naira wallet straight away, and later a dollar wallet. Your company also needs an escrow pool of its own.
Step 1: the owner
Every customer account has an owner: a Blnk identity that holds no personal data. Ada's name, phone number and KYC stay in your own customer database; the identity holds only references such as your customer id.
Find it, or make it the first time, so a retried sign-up doesn't make a second one. Identities.Create on its own isn't safe to repeat.
owner := ""
found, err := hb.Identities.FindByCustomer(ctx, customerID)
switch {
case errors.Is(err, humanblnk.ErrNotFound):
owner, err = hb.Identities.Create(ctx, humanblnk.Meta{"customer_id": customerID})
if err != nil {
log.Fatal(err)
}
fmt.Println("created owner", owner)
case err != nil:
log.Fatal(err)
default:
owner = found.ID
}
// Save owner on your customer record now, before opening accounts.Step 2: open her wallets
OpenForCustomer is safe to repeat. A customer has at most one account per ledger and currency, and calling it again returns that one. So you can call it whenever you need Ada's wallet, instead of storing ids everywhere.
ngn, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: demo.NGN})
if err != nil {
log.Fatal(err)
}
again, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: demo.NGN})
if err != nil {
log.Fatal(err)
}
fmt.Println("NGN wallet", ngn.ID, "| opened again:", again.ID, "| same:", ngn.ID == again.ID)
// 3. A dollar wallet is a second account.
usd, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: demo.USD})
if err != nil {
log.Fatal(err)
}
fmt.Println("USD wallet", usd.ID)CustomerAccount field | |
|---|---|
Owner | required |
Currency | one per account: a second currency is a second account |
Ledger | optional: empty is the WithAccountLedger one. Use your Savings ledger for a savings account. Must be a customer ledger. |
Step 3: read balances
accounts, total, err := hb.Accounts.List(ctx, humanblnk.AccountQuery{Owner: owner})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d accounts:\n", total)
for _, a := range accounts {
fmt.Printf(" %s %s available %s\n", a.ID, a.Currency.Code(), a.Available)
}
acct, err := hb.Accounts.Get(ctx, ngn.ID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Get %s: Total %s, OutgoingHeld %s, Available %s\n", acct.ID, acct.Total, acct.OutgoingHeld, acct.Available)An Account has four amounts:
| Field | Meaning |
|---|---|
Total | everything recorded |
Available | Total less outgoing holds and queued debits: what she can spend now |
OutgoingHeld | reserved by holds out of it, not yet captured or cancelled |
IncomingHeld | reserved by holds into it; not in Total or Available until captured |
Always decide "can she pay?" on Available, read with Get. List is quicker for many accounts, but its Available leaves queued debits out.
Step 4: tag the owner later
UpdateMeta merges: keys you don't send keep their values. References only, never personal data.
meta, err := hb.Identities.UpdateMeta(ctx, owner, humanblnk.Meta{"tier": "gold"})
if err != nil {
log.Fatal(err)
}
fmt.Println("owner meta:", meta)Your company's own accounts
An account that belongs to your company uses CreateInternal, in a ledger that isn't a customer one. The owner is optional. Unlike OpenForCustomer, it makes a new account on every call: if it times out, look with List first.
ops, err := hb.Ledgers.Create(ctx, "Operations "+demo.Run, nil)
if err != nil {
log.Fatal(err)
}
pool, err := hb.Accounts.CreateInternal(ctx, humanblnk.NewAccount{Ledger: ops.ID, Currency: demo.NGN, Meta: humanblnk.Meta{"purpose": "escrow"}})
if err != nil {
log.Fatal(err)
}
fmt.Println("escrow pool", pool.ID)The rules hold both ways, so mistakes fail early:
_, err = hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Currency: demo.NGN})
fmt.Println("customer account without an owner:", err)
_, err = hb.Accounts.CreateInternal(ctx, humanblnk.NewAccount{Ledger: acct.Ledger, Currency: demo.NGN})
fmt.Println("internal account in the wallets ledger:", err)| Call | Result |
|---|---|
OpenForCustomer without an owner | ErrInvalid |
OpenForCustomer in a ledger that isn't a customer ledger | ErrInvalid |
CreateInternal in a customer ledger | ErrInvalid: use OpenForCustomer |
CreateInternal in the General Ledger | ErrInvalid: Blnk makes system accounts itself |
The whole example
go run ./docs/examples/02-onboarding// Chapter 2: onboard a customer, open their accounts and read balances.
package main
import (
"errors"
"fmt"
"log"
"github.com/otyang/humanblnk"
"github.com/otyang/humanblnk/docs/examples/demo"
)
func main() {
ctx, hb := demo.Setup()
customerID := "cus_ada_" + demo.Run // your own customer id, from your database
// 1. The owner: find it, or create it the first time.
owner := ""
found, err := hb.Identities.FindByCustomer(ctx, customerID)
switch {
case errors.Is(err, humanblnk.ErrNotFound):
owner, err = hb.Identities.Create(ctx, humanblnk.Meta{"customer_id": customerID})
if err != nil {
log.Fatal(err)
}
fmt.Println("created owner", owner)
case err != nil:
log.Fatal(err)
default:
owner = found.ID
}
// Save owner on your customer record now, before opening accounts.
// 2. Her naira wallet. Opening it again returns the same account.
ngn, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: demo.NGN})
if err != nil {
log.Fatal(err)
}
again, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: demo.NGN})
if err != nil {
log.Fatal(err)
}
fmt.Println("NGN wallet", ngn.ID, "| opened again:", again.ID, "| same:", ngn.ID == again.ID)
// 3. A dollar wallet is a second account.
usd, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: demo.USD})
if err != nil {
log.Fatal(err)
}
fmt.Println("USD wallet", usd.ID)
// 4. All her accounts, and one balance.
accounts, total, err := hb.Accounts.List(ctx, humanblnk.AccountQuery{Owner: owner})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d accounts:\n", total)
for _, a := range accounts {
fmt.Printf(" %s %s available %s\n", a.ID, a.Currency.Code(), a.Available)
}
acct, err := hb.Accounts.Get(ctx, ngn.ID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Get %s: Total %s, OutgoingHeld %s, Available %s\n", acct.ID, acct.Total, acct.OutgoingHeld, acct.Available)
// 5. Tag the owner later; keys you don't send keep their values.
meta, err := hb.Identities.UpdateMeta(ctx, owner, humanblnk.Meta{"tier": "gold"})
if err != nil {
log.Fatal(err)
}
fmt.Println("owner meta:", meta)
// 6. An account for your company itself, not a customer: CreateInternal, in a
// ledger that isn't a customer one.
ops, err := hb.Ledgers.Create(ctx, "Operations "+demo.Run, nil)
if err != nil {
log.Fatal(err)
}
pool, err := hb.Accounts.CreateInternal(ctx, humanblnk.NewAccount{Ledger: ops.ID, Currency: demo.NGN, Meta: humanblnk.Meta{"purpose": "escrow"}})
if err != nil {
log.Fatal(err)
}
fmt.Println("escrow pool", pool.ID)
// The rules hold both ways.
_, err = hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Currency: demo.NGN})
fmt.Println("customer account without an owner:", err)
_, err = hb.Accounts.CreateInternal(ctx, humanblnk.NewAccount{Ledger: acct.Ledger, Currency: demo.NGN})
fmt.Println("internal account in the wallets ledger:", err)
}created owner idt_7341bf20-0a06-46d8-9b6a-99cd241b7c62
NGN wallet bln_29be359a-dd9e-4710-897d-f9d0e62d9a98 | opened again: bln_29be359a-dd9e-4710-897d-f9d0e62d9a98 | same: true
USD wallet bln_43e7c258-a642-48a8-b640-e8bbb336ba87
2 accounts:
bln_43e7c258-a642-48a8-b640-e8bbb336ba87 USD available 0.00
bln_29be359a-dd9e-4710-897d-f9d0e62d9a98 NGN available 0.00
Get bln_29be359a-dd9e-4710-897d-f9d0e62d9a98: Total 0.00, OutgoingHeld 0.00, Available 0.00
owner meta: map[customer_id:cus_ada_1792634712487858000 tier:gold]
escrow pool bln_cac9aee4-41cc-461c-a516-4a97a9058816
customer account without an owner: humanblnk: invalid request: a customer account needs an owner
internal account in the wallets ledger: humanblnk: invalid request: ldg_9aa81032-0b45-49a0-bba3-79411336e08a holds customer accounts: use OpenForCustomer