Authorization holds and the reservation pattern
Why a 'wallet balance' is actually three different numbers.
By Solomon Ajayi · Free to read, no signup
A user with ₦20,000 in their wallet initiates a ₦5,000 withdrawal to their bank. Your provider settles bank withdrawals on T+1. Between now and tomorrow, the user must NOT be able to spend that ₦5,000 again. Welcome to the reservation pattern: the reason every fintech engineer eventually realizes 'available balance' and 'total balance' are different things.
The instant a user starts a withdrawal, you have a problem: the money has not left yet (the provider settles tomorrow) but the user must not be able to spend it again in the meantime. The reservation pattern solves this by moving the funds into a separate compartment. You still owe the user the same total, you have just split it into what they can spend now and what is already spoken for.
That is why a wallet balance is never a single number. There is the total (everything you owe the user), the available (what they can actually spend right now), and the pending (what is committed to in-flight withdrawals, holds, or reviews). Each is just a sum over a different ledger account. Show the user the available number, gate new spending on the available number, and reconcile against the total.
The hold and its release are two halves of one lifecycle. Every reservation needs a path that either settles it (the money leaves, the hold clears) or cancels it (the money returns to available). Skip the release path and funds get stranded in pending forever, which is its own support nightmare.
Worked example, step by step
Set the stage: user has ₦20,000 in their wallet
Before we hold anything, the user already has ₦20,000 of available balance. We seed that with a single funding entry against their wallet.
| Account | Debit | Credit |
|---|---|---|
| Bank Account (1200) | ₦20,000.00 | |
| User Wallet (Available) (2000) | ₦20,000.00 |
Just establishing the starting state. The user wallet credit of ₦20,000 means we owe them that money, they can spend it.
User initiates a ₦5,000 withdrawal (hold the funds)
The user clicks 'Withdraw ₦5,000 to my bank.' The money has not actually left yet, your provider will move it tomorrow. But you need to remove ₦5,000 from their AVAILABLE balance immediately, or they could double-spend by initiating another transaction.
| Account | Debit | Credit |
|---|---|---|
| User Wallet (Available) (2000) | ₦5,000.00 | |
| User Pending Withdrawal (2100) | ₦5,000.00 |
Available wallet drops by ₦5,000. Pending withdrawal RISES by ₦5,000. Your TOTAL liability to the user is still ₦20,000, it just shifted from 'available' to 'pending'. This is the reservation pattern: the same money exists in two compartments.
Three balances from one ledger. Compute, never cache as truth.
-- Available, pending, and total, each just a SUM over the ledger.
-- "Available" is what the user can spend right now.
-- "Pending" is in flight: settling, held for review, awaiting capture.
-- "Total" is everything you owe the user across all states.
SELECT
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line WHERE account_code = '2000') AS available,
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line WHERE account_code = '2100') AS pending_withdrawal,
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line WHERE account_code IN ('2000', '2100')) AS total;Same shape in the API layer. Read three sums, return three numbers.
import { Client } from "pg";
export type Balances = {
available: bigint;
pending: bigint;
total: bigint;
};
export async function getBalances(client: Client, userId: string): Promise<Balances> {
const { rows: [row] } = await client.query<{
available: string; pending: string; total: string;
}>(
`SELECT
COALESCE(SUM(CASE WHEN account_code = '2000'
THEN credit - debit END), 0)::text AS available,
COALESCE(SUM(CASE WHEN account_code = '2100'
THEN credit - debit END), 0)::text AS pending,
COALESCE(SUM(CASE WHEN account_code IN ('2000', '2100')
THEN credit - debit END), 0)::text AS total
FROM journal_line jl
JOIN journal_entry je ON je.id = jl.entry_id
WHERE je.user_id = $1`,
[userId],
);
return {
available: BigInt(row.available),
pending: BigInt(row.pending),
total: BigInt(row.total),
};
}Tomorrow: provider settles, money leaves your bank
T+1. The provider has wired the ₦5,000 to the user's external bank. Your bank balance now actually drops, and the pending hold clears.
| Account | Debit | Credit |
|---|---|---|
| User Pending Withdrawal (2100) | ₦5,000.00 | |
| Bank Account (1200) | ₦5,000.00 |
Pending withdrawal liability clears to zero. Bank Account (asset) drops by ₦5,000. The reservation has converted into a real movement of cash.
Takeaway
A wallet balance is not one number, it's at least three: total, available, and pending. The reservation pattern uses a separate liability account to track funds that are committed but not yet settled. Without it, you build double-spend bugs into your platform on day one.
The code behind it
Reference solution
-- Check whether the user can withdraw $1.
-- Reads AVAILABLE only (account 2000); pending (2100) is excluded
-- because pending funds are already committed to an in-flight withdrawal.
SELECT (
SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line
WHERE account_code = '2000'
) >= $1 AS can_withdraw;Practice this on a real ledger
Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.