P2P transfer between users
Where the wallet table starts to lie.
By Solomon Ajayi · Free to read, no signup
Alice has ₦20,000 in her wallet. She sends ₦5,000 to Bob. Your platform takes a ₦50 transfer fee. No money leaves your system, it just moves between liabilities you owe to different people. This is the moment engineers realize the wallet table is a denormalized cache, not the source of truth.
A peer-to-peer transfer feels like money moving, but nothing leaves your company. Your bank balance is identical before and after. All that changes is the arithmetic of who you owe: a little less to Alice, a little more to Bob, and a small fee that is now yours. Every user wallet is a liability, a promise to pay that person on demand, and a transfer just shuffles those promises around.
This is the lesson where the obvious data model starts to crack. Most apps store a balance column on each user and increment one while decrementing the other. It works, until you ask it to prove itself. Where did the fee go? What was Alice's balance last Tuesday? Did both updates definitely succeed? A bare number cannot answer any of that. The ledger can, because the balance is not stored, it is computed by summing every line that ever touched the account.
So treat the wallet table for what it is: a cache. A fast, convenient projection of the ledger that you can rebuild from the journal at any time. The journal entries are the source of truth; the wallet number is a performance optimization that must always agree with them. The day they disagree, the ledger is right and the cache is wrong.
Worked example, step by step
Alice sends Bob ₦5,000 with a ₦50 fee
Your bank balance does not move. Your provider does not move. Only the liabilities to Alice and Bob move, and you book ₦50 of revenue for facilitating it.
| Account | Debit | Credit |
|---|---|---|
| Alice Wallet (2001) | ₦5,050.00 | |
| Bob Wallet (2002) | ₦5,000.00 | |
| P2P Fee Revenue (4001) | ₦50.00 |
Alice's wallet (a liability you owe her) goes DOWN by ₦5,050. Debit it. Bob's wallet (also a liability) goes UP by ₦5,000. Credit it. P2P fee revenue is income, goes UP. Credit it ₦50. Debits ₦5,050 = Credits ₦5,050.
The wallet table is a projection. The journal is truth.
-- The truth: one entry, three lines, balanced.
INSERT INTO journal_entry (description) VALUES ('Alice -> Bob NGN5,000 (NGN50 fee)');
INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
(currval('journal_entry_id_seq'), '2001', 505000, 0), -- Alice DOWN 5,050
(currval('journal_entry_id_seq'), '2002', 0, 500000), -- Bob UP 5,000
(currval('journal_entry_id_seq'), '4001', 0, 5000); -- Fee UP 50
-- The cache: derived from the ledger. Always rebuildable.
SELECT account_code,
SUM(credit) - SUM(debit) AS balance
FROM journal_line
WHERE account_code IN ('2001', '2002', '4001')
GROUP BY account_code;Post the multi-line entry atomically with plain pg.
import { Client } from "pg";
export async function postP2PTransfer(
client: Client,
args: { fromCode: string; toCode: string; amount: bigint; fee: bigint },
): Promise<void> {
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string }>(
`INSERT INTO journal_entry (description) VALUES ($1) RETURNING id`,
[`${args.fromCode} -> ${args.toCode} NGN${args.amount} (NGN${args.fee} fee)`],
);
const entryId = rows[0].id;
await client.query(
`INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
($1, $2, $3, 0),
($1, $4, 0, $5),
($1, '4001', 0, $6)`,
[entryId, args.fromCode, args.amount + args.fee, args.toCode, args.amount, args.fee],
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
}
}Takeaway
User wallets are liability accounts on your books. A P2P transfer never moves your bank balance, it just rearranges who you owe. The 'wallet balance' in your wallet table is just a cached sum of ledger entries. The ledger is the truth.
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.