Founder & Lead Engineer, RAITHub
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.
| Leak | What goes wrong | The correct rule |
|---|---|---|
| Editable balance column | Concurrent spends overwrite each other; the card is overdrawn | Never store a balance; sum an append-only ledger |
| Float money | Rounding loses or invents fractions of a cent over many transactions | Integer minor units; divide only for display |
| Double redemption | A double-clicked checkout spends the same card twice | Redeem inside the order transaction with a row lock and an idempotency key |
| Expiry handled elsewhere | An expiry job and a spend path disagree on the live balance | Expiry 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?
| Option | Choose this when | The 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 model | Partial payment with local gateways, multi-currency cards or your own loyalty rules often fall outside it |
| A no-code store plus a spreadsheet of codes | A handful of cards sold by hand | No concurrency safety; the balance is edited by a person and drifts |
| Custom build | Gift-card value is a real tender in your checkout, spent alongside local gateways or across currencies | You 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.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.