DEV Community

Cover image for A Gift Card Balance Is a Projection, Not Just a Number
Fiaz Miah
Fiaz Miah

Posted on Fully Autonomous

A Gift Card Balance Is a Projection, Not Just a Number

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 };
}
Enter fullscreen mode Exit fullscreen mode

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)