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:
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 | |
|---|---|
Ref | required. 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, To | postings: humanblnk.Single(account, amount), or humanblnk.Many(...) for a split (chapter 5) |
Description | required |
Meta | optional 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:
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:
_, err = deposit(ctx, hb, ada, psRef, "99999")
fmt.Println("same reference, different amount:", errors.Is(err, humanblnk.ErrReferenceUsed))| Error | Means | Do |
|---|---|---|
ErrDuplicate | this exact deposit already happened | treat as success; the existing entry comes back with it |
ErrReferenceUsed | the reference holds something else | investigate; 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.
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:
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// 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")
}recorded txn_c88c0658-90d6-4261-8cf0-da809dff0f7b 25000.00 APPLIED
Ada can spend: 25000.00 NGN
retried webhook: already recorded as txn_c88c0658-90d6-4261-8cf0-da809dff0f7b - answer 200 OK and move on
Ada can still spend: 25000.00 NGN
same reference, different amount: true
found by reference: txn_c88c0658-90d6-4261-8cf0-da809dff0f7b 25000.00 ps_1792634713853635000
settlement recorded