humanblnk tutorial

Chapter 8 · Moving money

Refunds and reversals

Scenario: Ada pays a shop ₦15,000 for three items. One comes back (a ₦5,000 refund), the support agent clicks "refund" twice, and then the order is cancelled. Separately, a payout to Ada's bank bounces.

Refunds, corrections, chargebacks and bounced payouts all use one call: Transactions.Reverse. It moves money back the way an entry moved it.

The order

A merchant is a customer too, with a wallet of its own:

08-refunds/main.go
order, err := hb.Transactions.Record(ctx, humanblnk.Transaction{
    Ref:  humanblnk.Reference("order", "ord_1_"+demo.Run),
    From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("15000")), To: humanblnk.Single(humanblnk.AccountRef(shop.ID), ngn("15000")),
    Description: "Order #1",
})
if err != nil {
    log.Fatal(err)
}
fmt.Println("paid | Ada:", demo.Available(ctx, hb, ada.ID), "| shop:", demo.Available(ctx, hb, shop.ID))

A partial refund

08-refunds/main.go
oneItem := humanblnk.Reversal{Ref: "refund:ord_1:item_2_" + demo.Run, Entry: order.ID, Amount: ngn("5000"), Reason: "item returned"}
r1, err := hb.Transactions.Reverse(ctx, oneItem)
if err != nil {
    log.Fatal(err)
}
fmt.Println("refunded", r1.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID))
Reversal field
Entrythe entry id (Entry.ID from Record)
Amountthe zero Money undoes all of it, or all that's left
Refyour id for this reversal. Required for a partial one, recommended for a full one.
Reasonrequired, for the audit trail

Clicked twice: the same refund

Reverse is idempotent on Ref. The second click returns the refund already made, and no money moves:

08-refunds/main.go
r1again, err := hb.Transactions.Reverse(ctx, oneItem)
fmt.Println("clicked again, same refund:", r1again.ID == r1.ID, err)

Without a Ref, a retried full reversal can only answer ErrAlreadyReversed: it can't tell its own refund from someone else's. Always pass one.

Never more than was paid

Reversals of one entry, full and partial, can never add up to more than it moved, even when two run at the same time (they take turns).

08-refunds/main.go
_, err = hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "refund:ord_1:too_much_" + demo.Run, Entry: order.ID, Amount: ngn("20000"), Reason: "mistake"})
fmt.Println("more than is left:", errors.Is(err, humanblnk.ErrAlreadyReversed))

Cancel the rest

A full reversal after partial ones undoes only what's left. Once nothing is left, Reverse is ErrAlreadyReversed.

08-refunds/main.go
rest, err := hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "refund:ord_1:cancel_" + demo.Run, Entry: order.ID, Reason: "order cancelled"})
if err != nil {
    log.Fatal(err)
}
fmt.Println("cancelled, refunded", rest.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID), "| shop:", demo.Available(ctx, hb, shop.ID))
_, err = hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "refund:ord_1:again_" + demo.Run, Entry: order.ID, Reason: "again"})
fmt.Println("nothing left to refund:", errors.Is(err, humanblnk.ErrAlreadyReversed))

A bounced payout

A payout recorded straight to @World that the bank returns is reversed the same way. The money comes back from @World, which as a system account may go below zero:

08-refunds/main.go
payout, err := hb.Transactions.Record(ctx, humanblnk.Transaction{
    Ref:  humanblnk.Reference("payout", "po_"+demo.Run),
    From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("8000")), To: humanblnk.Single(humanblnk.World, ngn("8000")),
    Description: "Withdrawal",
})
if err != nil {
    log.Fatal(err)
}
fmt.Println("paid out | Ada:", demo.Available(ctx, hb, ada.ID))
if _, err := hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "bounce:po_" + demo.Run, Entry: payout.ID, Reason: "account closed at the bank"}); err != nil {
    log.Fatal(err)
}
fmt.Println("bounced back | Ada:", demo.Available(ctx, hb, ada.ID))

The whole example

go run ./docs/examples/08-refunds
08-refunds/main.go
// Chapter 8: refunds, corrections and bounced payouts, all with Reverse.
package main

import (
    "errors"
    "fmt"
    "log"

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

func main() {
    ctx, hb := demo.Setup()
    ada := demo.Customer(ctx, hb, "ada", demo.NGN)
    shop := demo.Customer(ctx, hb, "mama_put_store", demo.NGN) // a merchant is a customer too
    demo.Deposit(ctx, hb, ada, "30000")
    ngn := func(v string) money.Money { return money.MustParse(v, demo.NGN) }

    // Ada buys three items for 15,000.
    order, err := hb.Transactions.Record(ctx, humanblnk.Transaction{
        Ref:  humanblnk.Reference("order", "ord_1_"+demo.Run),
        From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("15000")), To: humanblnk.Single(humanblnk.AccountRef(shop.ID), ngn("15000")),
        Description: "Order #1",
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("paid | Ada:", demo.Available(ctx, hb, ada.ID), "| shop:", demo.Available(ctx, hb, shop.ID))

    // One item comes back: a partial refund. Ref is your id for this refund.
    oneItem := humanblnk.Reversal{Ref: "refund:ord_1:item_2_" + demo.Run, Entry: order.ID, Amount: ngn("5000"), Reason: "item returned"}
    r1, err := hb.Transactions.Reverse(ctx, oneItem)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("refunded", r1.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID))

    // The support agent clicks twice: same Ref, same refund, no money moves.
    r1again, err := hb.Transactions.Reverse(ctx, oneItem)
    fmt.Println("clicked again, same refund:", r1again.ID == r1.ID, err)

    // Refunding more than is left is refused.
    _, err = hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "refund:ord_1:too_much_" + demo.Run, Entry: order.ID, Amount: ngn("20000"), Reason: "mistake"})
    fmt.Println("more than is left:", errors.Is(err, humanblnk.ErrAlreadyReversed))

    // The order is cancelled: a full reversal undoes what's left (10,000).
    rest, err := hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "refund:ord_1:cancel_" + demo.Run, Entry: order.ID, Reason: "order cancelled"})
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("cancelled, refunded", rest.Amount, "| Ada:", demo.Available(ctx, hb, ada.ID), "| shop:", demo.Available(ctx, hb, shop.ID))
    _, err = hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "refund:ord_1:again_" + demo.Run, Entry: order.ID, Reason: "again"})
    fmt.Println("nothing left to refund:", errors.Is(err, humanblnk.ErrAlreadyReversed))

    // A payout that bounced: the bank returns the money days later.
    payout, err := hb.Transactions.Record(ctx, humanblnk.Transaction{
        Ref:  humanblnk.Reference("payout", "po_"+demo.Run),
        From: humanblnk.Single(humanblnk.AccountRef(ada.ID), ngn("8000")), To: humanblnk.Single(humanblnk.World, ngn("8000")),
        Description: "Withdrawal",
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("paid out | Ada:", demo.Available(ctx, hb, ada.ID))
    if _, err := hb.Transactions.Reverse(ctx, humanblnk.Reversal{Ref: "bounce:po_" + demo.Run, Entry: payout.ID, Reason: "account closed at the bank"}); err != nil {
        log.Fatal(err)
    }
    fmt.Println("bounced back | Ada:", demo.Available(ctx, hb, ada.ID))

    // A refund takes money back from where it went. If the shop had
    // already spent it, the refund would be ErrInsufficientFunds: a customer
    // is never pushed below zero.
}

Good to know

  • A refund takes money from where it went. If the shop had already spent the ₦15,000, the refund would fail with ErrInsufficientFunds: a customer is never pushed below zero. System accounts (@World, @FeeIncome, clearing accounts) may go negative, even in the same reversal: refunding a transfer with a fee after the day's fees were swept still works.
  • Splits reverse in full only. Reversing a transfer with a fee undoes both postings in one entry. Which postings of a split to undo in part is a business decision: reverse it in full, then record what should stand.
  • Only applied entries can be reversed. For money still in a hold, use Holds.Cancel.
  • Each reversal is its own entry, referenced reversal:<entry>:<n> and tagged reversal_of, reversal_ref and reversal_reason in its metadata.