A single balance column breaks under concurrent usage and makes auditing impossible. Building a ledger-based wallet system in Go requires atomic operations, SELECT FOR UPDATE serialization, and idempotency keys. Here is how to do it correctly for SaaS products in Lebanon and MENA.
Many SaaS products in Lebanon and MENA use prepaid credit wallets. A customer pays $100 upfront, that balance sits in their account, and every service consumed deducts from it. Promotional credits, referral bonuses, and partial refunds add back to the balance. The naive implementation stores a single balance column. The naive implementation breaks under concurrent usage, leads to negative balances, and makes auditing impossible. Here is how to build a ledger-based wallet that survives production.
Why a balance column fails in production
The instinct is to store wallet balance as a single number:
ALTER TABLE customers ADD COLUMN wallet_balance decimal(12, 2) DEFAULT 0;
Deducting from the balance then looks like:
_, err := db.ExecContext(ctx,
`UPDATE customers SET wallet_balance = wallet_balance - $1 WHERE id = $2`,
amount, customerID,
)
Two concurrent requests deducting from the same wallet create a race condition. Both reads see the same balance. Both writes compute a deduction from the same starting value. One deduction is silently lost. At low concurrency this surfaces as intermittent negative balances or incorrect totals that appear randomly and are nearly impossible to reproduce.
The deeper problem is auditability. When a customer calls and asks why their balance changed, a single column gives you no history. You have a number. You do not have a record of how it got there.
Designing the ledger table structure
A ledger stores every transaction as an immutable append-only record. The current balance is derived by summing all transactions for a given account, or by maintaining a running total using a snapshot pattern.
CREATE TABLE wallet_ledger (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
customer_id bigint NOT NULL,
amount decimal(12, 2) NOT NULL, -- positive for credits, negative for debits
balance_after decimal(12, 2) NOT NULL, -- running balance snapshot
type text NOT NULL, -- 'top_up', 'service_charge', 'refund', 'adjustment'
reference_id text, -- external reference (payment ID, order ID, etc.)
idempotency_key text UNIQUE, -- prevents duplicate entries
description text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_ledger_customer ON wallet_ledger (tenant_id, customer_id, created_at DESC);
CREATE INDEX idx_ledger_idempotency ON wallet_ledger (idempotency_key) WHERE idempotency_key IS NOT NULL;
The balance_after column stores the running balance at the time of each transaction. Deriving the current balance is a single indexed lookup for the most recent entry, not a SUM over all history.
Atomic debit and credit operations in PostgreSQL and Go
Every wallet operation must be atomic. Debiting a wallet and creating an order must either both succeed or both fail. The correct implementation uses a PostgreSQL transaction with a SELECT FOR UPDATE lock on the current balance:
func (r *WalletRepository) Debit(ctx context.Context, req DebitRequest) error {
return r.withTx(ctx, func(tx *sql.Tx) error {
// Lock the customer's latest ledger entry to prevent concurrent debits
var currentBalance decimal.Decimal
err := tx.QueryRowContext(ctx, `
SELECT balance_after
FROM wallet_ledger
WHERE tenant_id = $1 AND customer_id = $2
ORDER BY id DESC
LIMIT 1
FOR UPDATE
`, req.TenantID, req.CustomerID).Scan(¤tBalance)
if err == sql.ErrNoRows {
currentBalance = decimal.Zero
} else if err != nil {
return fmt.Errorf("lock balance: %w", err)
}
if currentBalance.LessThan(req.Amount) {
return ErrInsufficientBalance
}
newBalance := currentBalance.Sub(req.Amount)
_, err = tx.ExecContext(ctx, `
INSERT INTO wallet_ledger
(tenant_id, customer_id, amount, balance_after, type, reference_id, idempotency_key, description)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, req.TenantID, req.CustomerID, req.Amount.Neg(), newBalance,
"service_charge", req.ReferenceID, req.IdempotencyKey, req.Description)
return err
})
}
The SELECT FOR UPDATE on the most recent ledger row serializes concurrent debit attempts. The second concurrent debit will block until the first transaction commits, then read the updated balance_after value.
Handling concurrent balance updates correctly
The SELECT FOR UPDATE approach works correctly under high concurrency. A customer making multiple simultaneous requests will have each debit serialized through the lock on their latest ledger row. This is correct behavior: balances are not lost, negative balances are impossible, and every transaction is recorded.
The performance cost is real but acceptable. For a wallet with moderate usage (dozens of transactions per day per customer), the lock contention is negligible. If a single customer makes hundreds of concurrent requests, the lock queue grows, but this is an intentional serialization of financial operations, not a bug.
For high-frequency debit operations (a streaming service billing per minute of playback, for example), pre-debit authorization with deferred reconciliation is a more appropriate pattern. For the wallet systems we build for Lebanese and MENA SaaS products, SELECT FOR UPDATE covers the concurrency requirements correctly.
Idempotency for wallet operations
Payment systems and retry logic require that the same operation can be submitted multiple times without double-charging. The idempotency_key column in the ledger table handles this:
// If the idempotency key already exists, return success without inserting
var existing WalletTransaction
err := tx.QueryRowContext(ctx, `
SELECT id, amount, balance_after, created_at
FROM wallet_ledger
WHERE idempotency_key = $1
`, req.IdempotencyKey).Scan(&existing.ID, &existing.Amount, &existing.BalanceAfter, &existing.CreatedAt)
if err == nil {
// Already processed: return the existing result as if it succeeded
return &existing, nil
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("check idempotency: %w", err)
}
// Not found: proceed with the insert
Generate idempotency keys on the client side using the request context: a combination of operation type, customer ID, and the triggering event ID makes a stable key that survives retries without risking duplication.
Building a transaction history view
The ledger table is the source of truth for transaction history:
func (r *WalletRepository) GetHistory(ctx context.Context, tenantID, customerID int64, limit int) ([]LedgerEntry, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, amount, balance_after, type, description, created_at
FROM wallet_ledger
WHERE tenant_id = $1 AND customer_id = $2
ORDER BY created_at DESC
LIMIT $3
`, tenantID, customerID, limit)
// scan and return
}
func (r *WalletRepository) GetBalance(ctx context.Context, tenantID, customerID int64) (decimal.Decimal, error) {
var balance decimal.Decimal
err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(balance_after, 0)
FROM wallet_ledger
WHERE tenant_id = $1 AND customer_id = $2
ORDER BY id DESC
LIMIT 1
`, tenantID, customerID).Scan(&balance)
if err == sql.ErrNoRows {
return decimal.Zero, nil
}
return balance, err
}
Operational considerations for MENA SaaS products
Several characteristics of the MENA market shape how wallet systems need to behave.
Cash top-ups are common. Many customers in Lebanon do not have credit cards or prefer not to use them online. The wallet system needs to support cash collection by agents, verified manually by operations staff, and credited to the wallet with an adjustment entry.
Balance expiry is expected by businesses. Many Lebanese SaaS products issue promotional credits with an expiry date. The ledger table needs an expiry_at column on credit entries and a background job that expires unused credits by inserting a corresponding debit entry.
Currency is often multi-currency. Products serving both Lebanon and Gulf markets deal with USD, LBP, and local Gulf currencies. Maintain separate ledger entries per currency rather than converting at the time of transaction.
Key lessons from production
A single balance column is a concurrency bug and an audit gap waiting to happen. A ledger table with SELECT FOR UPDATE serialization handles concurrent debits correctly. Idempotency keys prevent double-charging under retries. The balance_after running snapshot makes current balance reads a single indexed row lookup rather than a full table aggregate.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
Voxire builds production-grade SaaS financial systems for clients in Lebanon and across MENA. If your product needs a wallet, billing engine, or financial data model that survives real usage, reach out at https://voxire.com/get-a-quote/



