humanblnk tutorial

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.

02-onboarding/main.go
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.

02-onboarding/main.go
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
Ownerrequired
Currencyone per account: a second currency is a second account
Ledgeroptional: empty is the WithAccountLedger one. Use your Savings ledger for a savings account. Must be a customer ledger.

Step 3: read balances

02-onboarding/main.go
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:

FieldMeaning
Totaleverything recorded
AvailableTotal less outgoing holds and queued debits: what she can spend now
OutgoingHeldreserved by holds out of it, not yet captured or cancelled
IncomingHeldreserved 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.

02-onboarding/main.go
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.

02-onboarding/main.go
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:

02-onboarding/main.go
_, 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)
CallResult
OpenForCustomer without an ownerErrInvalid
OpenForCustomer in a ledger that isn't a customer ledgerErrInvalid
CreateInternal in a customer ledgerErrInvalid: use OpenForCustomer
CreateInternal in the General LedgerErrInvalid: Blnk makes system accounts itself

The whole example

go run ./docs/examples/02-onboarding
02-onboarding/main.go
// 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)
}