The first version of a gift card balance often looks like one database column: balance = 70. It works until someone asks a reasonable question: why is it 70?
Was a purchase completed? Is part of the value temporarily held? Did a failed checkout release its hold? A single number cannot answer those questions on its own.
I find it more useful to think of the balance shown to a customer as a projection of events. The events explain how the card got there; the number is only the current view.
A small example
Imagine a card issued with $100 of value:
| Event | Available | Reserved | Unspent value |
|---|---|---|---|
| Issue $100 | $100 | $0 | $100 |
| Redeem $30 | $70 | $0 | $70 |
| Reserve $20 for checkout | $50 | $20 | $70 |
| Release the $20 hold | $70 | $0 | $70 |
The reservation changes what can be spent right now, but it does not mean $20 has been redeemed. That distinction matters when a checkout times out or a payment is abandoned.
If the checkout succeeds instead, the $20 hold becomes a capture: reserved value falls to zero and unspent value falls to $50. The available amount stays at $50 because it was reduced when the hold was created.
Make each transition explicit
One possible event model is:
type Event =
| { kind: "issued"; amount: number }
| { kind: "redeemed"; amount: number }
| { kind: "hold_created"; amount: number }
| { kind: "hold_released"; amount: number }
| { kind: "hold_captured"; amount: number };
function project(events: Event[]) {
let available = 0;
let reserved = 0;
for (const event of events) {
switch (event.kind) {
case "issued": available += event.amount; break;
case "redeemed": available -= event.amount; break;
case "hold_created":
available -= event.amount;
reserved += event.amount;
break;
case "hold_released":
reserved -= event.amount;
available += event.amount;
break;
case "hold_captured": reserved -= event.amount; break;
}
}
if (available < 0 || reserved < 0) throw new Error("Invalid ledger state");
return { available, reserved, unspent: available + reserved };
}
This reducer is a way to read the history, not a complete transaction system. In production, the write that creates a hold has to be atomic with the check that enough value is available. Each hold also needs an identifier, so a release or capture applies to the right checkout rather than merely subtracting an amount from a shared bucket.
The question I'd put on the dashboard
Instead of showing only “Balance: $50,” I would show “$50 available” and, when relevant, “$20 pending.” The support view should show the event trail behind both figures. If a customer says a purchase failed but their balance dropped, there is now a concrete place to look.
This is the kind of small systems detail I pay attention to when writing about gift cards at CardNaro. The interesting question is rarely just what is the balance? It is what happened to it?
Top comments (0)