Back to BlogIndustry Guides

Building Gift Cards and Store Credit That Always Balance

Rupak Amin

Founder & Lead Engineer, RAITHub

9 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

Gift cards and store credit are money you already took and still owe, so the balance must never drift. Store value as integer minor units, never floats; record every issue, spend, refund and expiry as one append-only ledger row; redeem inside the order transaction with a row lock so two tabs cannot both spend the last of it; and test that the balance always equals the sum of the rows.

If you would rather have it built and tested for you, see how RAITHub would build this below.

This is about stored value specifically. Refund maths and store credit issued from returns are covered in a returns/RMA system that does not lose money on refunds; here the gift card is bought, loaded and spent as a payment method in its own right.

Why does a gift card balance drift, and how do you stop it?

It drifts when the balance is a column someone edits. Two requests read the same balance, both subtract, and one write is lost, so the card is overspent. Or value is stored as a floating-point number and rounding eats a cent per transaction. The fix is the same discipline a bank uses: the balance is never stored, it is derived. Keep an append-only table of signed amounts in integer minor units, and compute the balance as their sum.

LeakWhat goes wrongThe correct rule
Editable balance columnConcurrent spends overwrite each other; the card is overdrawnNever store a balance; sum an append-only ledger
Float moneyRounding loses or invents fractions of a cent over many transactionsInteger minor units; divide only for display
Double redemptionA double-clicked checkout spends the same card twiceRedeem inside the order transaction with a row lock and an idempotency key
Expiry handled elsewhereAn expiry job and a spend path disagree on the live balanceExpiry is one more signed ledger row, in the same table

What does the data model look like?

Two tables. A gift_cards row is the card itself: its code, who owns it and when it expires. A gift_card_entries row is one movement of value. The balance of a card is the sum of its entries, scoped by expiry.

CREATE TABLE gift_cards (
  id          bigserial PRIMARY KEY,
  code_hash   text        NOT NULL UNIQUE,     -- store a hash, not the raw code
  customer_id bigint      REFERENCES customers(id),
  currency    char(3)     NOT NULL,
  expires_at  timestamptz,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE gift_card_entries (
  id            bigserial PRIMARY KEY,
  gift_card_id  bigint      NOT NULL REFERENCES gift_cards(id),
  amount_minor  bigint      NOT NULL,           -- signed: +issue/+refund, -spend, -expiry
  reason        text        NOT NULL CHECK (reason IN ('issue','spend','refund','expiry','adjustment')),
  order_id      bigint      REFERENCES orders(id),
  idempotency_key text      UNIQUE,             -- one spend per key, even on retry
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX gift_card_entries_by_card ON gift_card_entries (gift_card_id, created_at);

-- Balance: always the sum of the rows, never a stored column.
-- SELECT COALESCE(SUM(amount_minor), 0) FROM gift_card_entries WHERE gift_card_id = $1;

Store a hash of the card code, not the code itself, the same way you would a password, so a database leak does not hand an attacker spendable cards. Store value as bigint minor units: the money-type reasoning is the same as a payments ledger, covered in double-entry ledger database design.

How do you redeem a card without overspending it?

Redemption happens inside the order transaction, not before it. Lock the card, re-check the balance, write the negative entry and apply it to the order, all in one transaction, so a second checkout cannot spend value the first already claimed. This is the same check-and-write discipline that stops overselling stock, covered in preventing overselling with a reservation inside the order transaction.

BEGIN;
-- Lock this card's rows so a concurrent spend waits.
SELECT COALESCE(SUM(amount_minor), 0) AS balance
FROM gift_card_entries
WHERE gift_card_id = $1
FOR UPDATE;

-- If balance >= amount_to_spend, insert the spend; else abort and tell the user.
INSERT INTO gift_card_entries (gift_card_id, amount_minor, reason, order_id, idempotency_key)
VALUES ($1, -$2, 'spend', $3, $4);
COMMIT;

The idempotency_key (the order ID plus an attempt number) makes a retried checkout safe: the second insert hits the unique constraint and does nothing, so the card is spent once. Partial payment is normal: a card covers part of the order and a card gateway covers the rest, so treat gift-card value as one tender among several.

How do you handle expiry and refunds?

Both are just more ledger rows. On expiry, a scheduled job writes a negative expiry entry that zeroes the remaining balance, so the spend path and the expiry job never disagree. On a refund to a card, write a positive refund entry. Because the balance is always the sum, you can show a customer every movement and reconcile the total outstanding liability across all cards with one query, which your accountant will want at period end.

How do you test that the balance never drifts?

The ledger is pure arithmetic, so test it hard, then test the concurrent path.

import { describe, it, expect } from 'vitest'
import { balanceOf, redeem } from './gift-card'

describe('gift card ledger', () => {
  it('balance is the sum of entries, in minor units', async () => {
    const card = await issue({ amountMinor: 5000, currency: 'USD' })   // $50.00
    await redeem({ cardId: card.id, amountMinor: 1999, orderId: 1 })   // $19.99
    expect(await balanceOf(card.id)).toBe(3001)                        // $30.01
  })

  it('cannot be overspent by two concurrent redemptions', async () => {
    const card = await issue({ amountMinor: 1000, currency: 'USD' })
    const both = await Promise.allSettled([
      redeem({ cardId: card.id, amountMinor: 1000, orderId: 1 }),
      redeem({ cardId: card.id, amountMinor: 1000, orderId: 2 }),
    ])
    const ok = both.filter((r) => r.status === 'fulfilled')
    expect(ok.length).toBe(1)                 // exactly one spend wins
    expect(await balanceOf(card.id)).toBe(0)  // never negative
  })
})

Add a test that a retried redemption with the same idempotency key spends once, and a test that an expiry job plus a late spend can never drive the balance below zero.

Buy, build or hire?

OptionChoose this whenThe catch
A platform's built-in gift cards (Shopify, a gift-card app)A standard store on that platform, with its payment methods and tax modelPartial payment with local gateways, multi-currency cards or your own loyalty rules often fall outside it
A no-code store plus a spreadsheet of codesA handful of cards sold by handNo concurrency safety; the balance is edited by a person and drifts
Custom buildGift-card value is a real tender in your checkout, spent alongside local gateways or across currenciesYou own the ledger and the tests that keep the liability exact

How long does it take to build yourself, and what is the risk?

For an experienced backend developer adding gift cards and store credit to an existing store, our estimate is 2 to 4 weeks, including atomic redemption, expiry and the test suite. The main risk is concurrency: a redemption path that is not transactional will pass every happy-path test and overspend cards under real load. Treatment of unredeemed balances (escheatment, breakage, tax) varies by jurisdiction; this is general information, so confirm the rules that apply with your adviser.

Why RAITHub for this

  • Stored-value ledgers in production. Sundor Skin, a B2B wholesale platform RAITHub built, runs credit and adjustments as append-only entries with row-level security across 146 PostgreSQL tables and 530+ tests. See the Sundor Skin case study.
  • Integer money as standard. TheSkinProof, the founder's own venture built and run by RAITHub, stores amounts as integer minor units across four payment rails, with 750+ tests.
  • Concurrency tested. Redemption and the oversell guards are covered by tests gated in CI, so a change to the checkout cannot quietly make a card spendable twice.

When you don't need us

  • Your platform's gift cards already fit. Standard tenders, one currency, no custom loyalty: configure the built-in feature.
  • Volume is tiny. A few cards a month can be tracked by hand if you accept the risk.
  • You only need the model. The ledger above is a fair start for your own developer.

How RAITHub would build this

  • Stored-value ledger: append-only entries in integer minor units, balance by sum, hashed card codes.
  • Atomic redemption: spend inside the order transaction with a row lock and an idempotency key, and partial payment alongside other tenders.
  • Expiry and refunds: both as signed ledger rows, plus a liability report that reconciles across all cards.
  • Tests: balance-by-sum, concurrency and idempotency suites gated in CI.

Timeline: adding this to an existing store is backend work, typically 6 to 12 weeks; inside a new store MVP it fits the 4 to 6 week fixed-scope range. See SaaS development and the ecommerce industry page. A related build is multi-warehouse inventory and fulfilment logic.

You receive: automated tests and CI covering the ledger and redemption, handover docs, and full IP in your name under NDA.

Next step: book the free 15-minute technical audit with your gift-card rules, and we will follow up with a written fixed quote.

Frequently asked questions

How do I keep a gift card balance from drifting?

Never store the balance as an editable column. Keep an append-only ledger of signed amounts in integer minor units, and compute the balance as their sum. Then no two concurrent spends can overwrite each other, and rounding cannot lose a cent.

Should gift card value be stored as a decimal or an integer?

As an integer in the smallest currency unit (cents, poisha), so arithmetic is exact. Divide only when you display the amount. Floating-point money accumulates rounding errors over many transactions, which is exactly the drift you are trying to avoid.

How do I stop a gift card being spent twice?

Redeem inside the order transaction: lock the card's rows, re-check the balance, and write the spend, all in one transaction. Add an idempotency key so a retried checkout spends once. A test runs two concurrent redemptions and asserts exactly one wins.

How should gift card expiry be handled?

As one more ledger row. A scheduled job writes a negative expiry entry that zeroes the remaining balance, so the spend path and the expiry job read the same number. Treatment of unredeemed balances varies by jurisdiction, so confirm the rules with your adviser.

Can I use my platform's built-in gift cards instead of building?

Often yes, for a standard store. Build custom when gift-card value is spent alongside local payment gateways, across currencies, or under your own loyalty rules, which is where built-in features commonly stop.

Can a gift card pay for only part of an order?

Yes, and it usually should. Treat gift-card value as one tender among several: apply the card balance first, then charge the remainder to a card gateway, recording each as its own payment against the order so reconciliation stays exact.

gift card systemstore credit ledgerecommercestored valueinteger moneyledger design

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.