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:
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
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 | |
|---|---|
Entry | the entry id (Entry.ID from Record) |
Amount | the zero Money undoes all of it, or all that's left |
Ref | your id for this reversal. Required for a partial one, recommended for a full one. |
Reason | required, 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:
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).
_, 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.
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:
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// 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.
}paid | Ada: 15000.00 NGN | shop: 15000.00 NGN
refunded 5000.00 | Ada: 20000.00 NGN
clicked again, same refund: true <nil>
more than is left: true
cancelled, refunded 10000.00 | Ada: 30000.00 NGN | shop: 0.00 NGN
nothing left to refund: true
paid out | Ada: 22000.00 NGN
bounced back | Ada: 30000.00 NGNGood 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 taggedreversal_of,reversal_refandreversal_reasonin its metadata.