humanblnk tutorial

Chapter 4 · Moving money

Deposits

Scenario: Ada pays ₦25,000 into her wallet by bank transfer through Paystack. Paystack's webhook tells you, then sends it again. Days later Paystack settles the money into your bank account.

Where the money comes from

When the webhook arrives, Paystack has the money, not you. So the deposit comes from Paystack's clearing account, humanblnk.ClearingAccount("paystack") (@PaystackClearing). It goes below zero: Paystack owes you that much. When Paystack settles, it returns to zero, so a balance left in a clearing account is money still in transit.

Step 1: record the deposit

This is what your Paystack webhook handler does with a successful charge:

04-deposits/main.go
func deposit(ctx context.Context, hb *humanblnk.Client, wallet humanblnk.Account, paystackRef, amount string) (humanblnk.Entry, error) {
    m, err := money.Parse(amount, wallet.Currency) // the amount the webhook carried
    if err != nil {
        return humanblnk.Entry{}, err
    }
    return hb.Transactions.Record(ctx, humanblnk.Transaction{
        Ref:         humanblnk.Reference("deposit", "paystack", paystackRef),
        From:        humanblnk.Single(humanblnk.ClearingAccount("paystack"), m), // Paystack owes you this
        To:          humanblnk.Single(humanblnk.AccountRef(wallet.ID), m),
        Description: "Bank transfer",
        Meta:        humanblnk.Meta{"provider": "paystack", "provider_ref": paystackRef},
    })
}
Transaction field
Refrequired. Built with humanblnk.Reference from your own ids, never a random value. Here it's Paystack's id for the charge, so a retried webhook carries the same one.
From, Topostings: humanblnk.Single(account, amount), or humanblnk.Many(...) for a split (chapter 5)
Descriptionrequired
Metaoptional references, such as the provider's id

humanblnk.AccountRef(wallet.ID) names a customer's account; humanblnk.ClearingAccount(...), humanblnk.World and humanblnk.FeeIncome name system accounts.

Step 2: the retried webhook

Paystack resends webhooks on timeouts. Recording again with the same Ref can't apply twice:

04-deposits/main.go
again, err := deposit(ctx, hb, ada, psRef, "25000")
switch {
case errors.Is(err, humanblnk.ErrDuplicate):
    fmt.Println("retried webhook: already recorded as", again.ID, "- answer 200 OK and move on")
case err != nil:
    log.Fatal(err)
}
fmt.Println("Ada can still spend:", demo.Available(ctx, hb, ada.ID))

A forged or buggy webhook that reuses the reference for another amount is caught too:

04-deposits/main.go
_, err = deposit(ctx, hb, ada, psRef, "99999")
fmt.Println("same reference, different amount:", errors.Is(err, humanblnk.ErrReferenceUsed))
ErrorMeansDo
ErrDuplicatethis exact deposit already happenedtreat as success; the existing entry comes back with it
ErrReferenceUsedthe reference holds something elseinvestigate; never treat as success

Step 3: look it up

GetByReference is how you ask "did this already happen?", for example from a support tool. Get(ctx, id) fetches by entry id.

04-deposits/main.go
found, err := hb.Transactions.GetByReference(ctx, humanblnk.Reference("deposit", "paystack", psRef))
if err != nil {
    log.Fatal(err)
}
fmt.Println("found by reference:", found.ID, found.Amount, found.Meta["provider_ref"])

Step 4: Paystack settles

When the money reaches your bank, Paystack no longer owes it. It moves from outside your books (@World) into the clearing account, bringing it back to zero:

04-deposits/main.go
settled := money.MustParse("25000", demo.NGN)
if _, err := hb.Transactions.Record(ctx, humanblnk.Transaction{
    Ref:         humanblnk.Reference("settlement", "paystack", "stl_"+demo.Run),
    From:        humanblnk.Single(humanblnk.World, settled),
    To:          humanblnk.Single(humanblnk.ClearingAccount("paystack"), settled),
    Description: "Paystack settlement",
}); err != nil {
    log.Fatal(err)
}
fmt.Println("settlement recorded")

Chapter 11 checks Paystack's settlement report against these deposits.

The whole example

go run ./docs/examples/04-deposits
04-deposits/main.go
// Chapter 4: a deposit through a payment provider, the retried webhook, and
// the provider settling.
package main

import (
    "context"
    "errors"
    "fmt"
    "log"

    "github.com/otyang/humanblnk"
    "github.com/otyang/humanblnk/docs/examples/demo"
    "github.com/otyang/humanblnk/money"
)

// deposit is what your Paystack webhook handler does with a successful
// charge. paystackRef is Paystack's own id for it, so a retried webhook
// carries the same one.
func deposit(ctx context.Context, hb *humanblnk.Client, wallet humanblnk.Account, paystackRef, amount string) (humanblnk.Entry, error) {
    m, err := money.Parse(amount, wallet.Currency) // the amount the webhook carried
    if err != nil {
        return humanblnk.Entry{}, err
    }
    return hb.Transactions.Record(ctx, humanblnk.Transaction{
        Ref:         humanblnk.Reference("deposit", "paystack", paystackRef),
        From:        humanblnk.Single(humanblnk.ClearingAccount("paystack"), m), // Paystack owes you this
        To:          humanblnk.Single(humanblnk.AccountRef(wallet.ID), m),
        Description: "Bank transfer",
        Meta:        humanblnk.Meta{"provider": "paystack", "provider_ref": paystackRef},
    })
}

func main() {
    ctx, hb := demo.Setup()
    ada := demo.Customer(ctx, hb, "ada", demo.NGN)
    psRef := "ps_" + demo.Run

    // The webhook arrives.
    e, err := deposit(ctx, hb, ada, psRef, "25000")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("recorded", e.ID, e.Amount, e.Status)
    fmt.Println("Ada can spend:", demo.Available(ctx, hb, ada.ID))

    // Paystack sends the same webhook again (it does, on timeouts).
    again, err := deposit(ctx, hb, ada, psRef, "25000")
    switch {
    case errors.Is(err, humanblnk.ErrDuplicate):
        fmt.Println("retried webhook: already recorded as", again.ID, "- answer 200 OK and move on")
    case err != nil:
        log.Fatal(err)
    }
    fmt.Println("Ada can still spend:", demo.Available(ctx, hb, ada.ID))

    // A forged or buggy webhook reusing the reference for another amount.
    _, err = deposit(ctx, hb, ada, psRef, "99999")
    fmt.Println("same reference, different amount:", errors.Is(err, humanblnk.ErrReferenceUsed))

    // Look a deposit up by your reference, e.g. from a support tool.
    found, err := hb.Transactions.GetByReference(ctx, humanblnk.Reference("deposit", "paystack", psRef))
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("found by reference:", found.ID, found.Amount, found.Meta["provider_ref"])

    // Days later Paystack settles: the money reaches your bank account,
    // and Paystack no longer owes it. The clearing account returns to zero.
    settled := money.MustParse("25000", demo.NGN)
    if _, err := hb.Transactions.Record(ctx, humanblnk.Transaction{
        Ref:         humanblnk.Reference("settlement", "paystack", "stl_"+demo.Run),
        From:        humanblnk.Single(humanblnk.World, settled),
        To:          humanblnk.Single(humanblnk.ClearingAccount("paystack"), settled),
        Description: "Paystack settlement",
    }); err != nil {
        log.Fatal(err)
    }
    fmt.Println("settlement recorded")
}