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?