DEV Community

Arpit Mishra
Arpit Mishra

Posted on

Build a Wallet Backend with Node.js, PostgreSQL, and Kafka

Most "build a wallet" tutorials stop at a balance column and an UPDATE statement. Then someone hits it with two concurrent requests, or a network retry, and money quietly appears or disappears. A wallet backend has exactly one job it can't get wrong: never lose or duplicate money. This post builds a small but correct wallet core — a double-entry ledger in PostgreSQL, atomic transfers in Node.js, and Kafka events so the rest of your system can react.

The design in one paragraph

PostgreSQL is the source of truth. Every transfer moves money inside a single database transaction using a double-entry ledger (every debit has a matching credit), row locks stop race conditions, and an idempotency key stops retries from charging twice. Once the transaction commits, we publish an event to Kafka so downstream services — notifications, analytics, fraud — can react without slowing the transfer down.

The schema

Money is stored as integer minor units (cents/paise), never floats. ledger_entries is the immutable audit trail; accounts.balance is a cached convenience you can always rebuild from it.

sqlCREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
owner_id UUID NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
balance BIGINT NOT NULL DEFAULT 0, -- minor units
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE transfers (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT UNIQUE NOT NULL,
from_account_id BIGINT NOT NULL REFERENCES accounts(id),
to_account_id BIGINT NOT NULL REFERENCES accounts(id),
amount BIGINT NOT NULL CHECK (amount > 0),
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
transfer_id BIGINT NOT NULL REFERENCES transfers(id),
account_id BIGINT NOT NULL REFERENCES accounts(id),
direction TEXT NOT NULL CHECK (direction IN ('debit','credit')),
amount BIGINT NOT NULL CHECK (amount > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The transfer — the part that has to be bulletproof

This is the whole ballgame. One DB transaction, in this order: claim the idempotency key, lock both accounts, check funds, move money, write the ledger, commit.

jsconst { Pool } = require('pg');
const pool = new Pool();

async function transfer({ idempotencyKey, fromId, toId, amount }) {
const client = await pool.connect();
try {
await client.query('BEGIN');

// 1. Claim this transfer exactly once
const ins = await client.query(
  `INSERT INTO transfers (idempotency_key, from_account_id, to_account_id, amount)
   VALUES ($1,$2,$3,$4)
   ON CONFLICT (idempotency_key) DO NOTHING
   RETURNING id`,
  [idempotencyKey, fromId, toId, amount]
);

// Duplicate request — return the original result, don't re-charge
if (ins.rowCount === 0) {
  await client.query('COMMIT');
  const prev = await client.query(
    `SELECT id, status FROM transfers WHERE idempotency_key = $1`,
    [idempotencyKey]
  );
  return { transferId: prev.rows[0].id, status: prev.rows[0].status, duplicate: true };
}
const transferId = ins.rows[0].id;

// 2. Lock both rows in a stable order to avoid deadlocks
const ids = [fromId, toId].sort((a, b) => a - b);
const rows = await client.query(
  `SELECT id, balance FROM accounts WHERE id = ANY($1) FOR UPDATE`, [ids]
);
const bal = Object.fromEntries(rows.rows.map(r => [r.id, BigInt(r.balance)]));

// 3. Check funds
if (bal[fromId] < BigInt(amount)) {
  const err = new Error('INSUFFICIENT_FUNDS');
  err.code = 'INSUFFICIENT_FUNDS';
  throw err;
}

// 4. Move the money
await client.query(`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, [amount, fromId]);
await client.query(`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, [amount, toId]);

// 5. Immutable double-entry records
await client.query(
  `INSERT INTO ledger_entries (transfer_id, account_id, direction, amount)
   VALUES ($1,$2,'debit',$3), ($1,$4,'credit',$3)`,
  [transferId, fromId, amount, toId]
);

// 6. Finalize
await client.query(`UPDATE transfers SET status = 'completed' WHERE id = $1`, [transferId]);
await client.query('COMMIT');
return { transferId, status: 'completed', duplicate: false };
Enter fullscreen mode Exit fullscreen mode

} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}

SELECT ... FOR UPDATE is what makes concurrent transfers safe — the second request blocks until the first commits, so you can never race two withdrawals past a balance check. Sorting the IDs before locking prevents two transfers in opposite directions from deadlocking on each other.

Publish the event after commit

Only once the money has actually landed do we tell the world. Note the event is emitted after the transaction commits — never before, or you risk announcing a transfer that then rolls back.

jsconst { Kafka } = require('kafkajs');
const kafka = new Kafka({ clientId: 'wallet', brokers: ['localhost:9092'] });
const producer = kafka.producer();
await producer.connect();

async function emitTransferCompleted(evt) {
await producer.send({
topic: 'wallet.transfers',
messages: [{ key: String(evt.transferId), value: JSON.stringify(evt) }],
});
}

Wire it to an Express route:

jsapp.post('/transfers', async (req, res) => {
const idempotencyKey = req.header('Idempotency-Key');
const { fromId, toId, amount } = req.body;
try {
const result = await transfer({ idempotencyKey, fromId, toId, amount });
if (!result.duplicate) {
await emitTransferCompleted({ ...result, fromId, toId, amount });
}
res.status(result.duplicate ? 200 : 201).json(result);
} catch (e) {
if (e.code === 'INSUFFICIENT_FUNDS') return res.status(422).json({ error: 'insufficient_funds' });
res.status(500).json({ error: 'internal_error' });
}
});

A consumer that reacts

Downstream services subscribe independently. Here's a notification worker — analytics or fraud scoring would look nearly identical:

jsconst consumer = kafka.consumer({ groupId: 'notifications' });
await consumer.connect();
await consumer.subscribe({ topic: 'wallet.transfers' });
await consumer.run({
eachMessage: async ({ message }) => {
const evt = JSON.parse(message.value.toString());
console.log(Notify: transfer ${evt.transferId} for ${evt.amount} completed);
// send push, update a read model, etc.
},
});

Gotchas that bite in production

Emitting after commit can still drop events. If the process dies between COMMIT and producer.send, the transfer happened but nobody heard. The robust fix is the transactional outbox: write the event to an outbox table inside the same DB transaction, then a separate relay publishes it to Kafka and marks it sent. Now the event is as durable as the transfer itself.
Never use floats for money. Integer minor units or NUMERIC — a 0.1 + 0.2 rounding bug in a ledger is a very bad day.
The ledger is the truth, the balance is a cache. You should be able to run SELECT SUM(credits) - SUM(debits) per account and match accounts.balance exactly. Reconcile it on a schedule.
Idempotency keys are non-negotiable. Clients retry on timeouts; without the unique key you'll double-spend.
Consumers must be idempotent too. Kafka is at-least-once, so the same event can arrive twice — dedupe on transferId.

Wrapping up

That's a wallet core that stays correct under concurrency and retries, with an event stream your whole platform can build on. From here you'd add authentication, KYC, holds/authorizations, multi-currency, and the outbox relay — but the ledger-plus-events foundation stays the same. Get this part right and everything above it gets a lot easier.

Top comments (0)