Double-Entry Bookkeeping for African Payment Integrations
Double-entry bookkeeping means every financial event creates at least two ledger entries: a debit to one account and a credit to another. When a customer pays via Paystack, you debit Accounts Receivable (Paystack owes you money) and credit Revenue. When Paystack settles, you debit Cash and credit Accounts Receivable. The books must always balance. If they do not, something is wrong.
Why Double-Entry Matters for Payment Integrations
A single-entry ledger records one number per event: "Customer paid NGN 50,000." That is enough to know revenue came in. But it does not tell you where the money is right now. Is it still with Paystack? Was it settled to your bank? Was part of it deducted as fees?
Double-entry bookkeeping answers these questions by design. Every financial event creates two entries. One account increases (debit), another decreases (credit). The total of all debits must equal the total of all credits. If they do not match, something is wrong. You catch the error immediately instead of discovering it during month-end close when you cannot remember what happened.
This is not over-engineering. If you process more than 100 transactions a month through Paystack, a single-entry system will cost you time and money in reconciliation headaches. The double-entry system costs a little more upfront but saves you every month after that.
For the basic ledger design that this system builds on, see the payments ledger design guide.
The Accounts You Need
In traditional accounting, accounts are categories that money flows between. For a Paystack integration, you need these accounts:
- Revenue: Income from customer payments. Credits increase revenue.
- Paystack Receivable: Money that Paystack has collected but not yet settled to you. Debits increase the receivable (Paystack owes you more). Credits decrease it (Paystack paid you).
- Cash (Bank Account): Your actual bank account. Debits increase cash when settlements arrive.
- Fee Expense: Paystack processing fees. Debits increase the expense.
- Refund Expense: Money returned to customers. Debits increase this expense.
- Chargeback Expense: Money lost to chargebacks and disputes.
-- Accounts table
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
code VARCHAR(20) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL
-- 'asset', 'liability', 'revenue', 'expense'
);
INSERT INTO accounts (code, name, type) VALUES
('REVENUE', 'Revenue', 'revenue'),
('PS_RECV', 'Paystack Receivable', 'asset'),
('CASH', 'Bank Account', 'asset'),
('FEE_EXP', 'Processing Fee Expense', 'expense'),
('REFUND_EXP', 'Refund Expense', 'expense'),
('CHRGBK_EXP', 'Chargeback Expense', 'expense');
You can add more accounts as your business grows. If you operate in multiple currencies, you might have separate receivable and cash accounts per currency (PS_RECV_NGN, PS_RECV_KES, CASH_NGN, CASH_KES).
The Journal Entries Table
Each financial event creates a journal entry with multiple lines. The lines must balance: total debits equal total credits.
-- Journal entries (the "header" for each financial event)
CREATE TABLE journal_entries (
id BIGSERIAL PRIMARY KEY,
reference VARCHAR(100) NOT NULL,
event_type VARCHAR(50) NOT NULL,
description TEXT,
currency VARCHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB DEFAULT '{}',
UNIQUE(reference, event_type)
);
-- Journal lines (the debit/credit pairs)
CREATE TABLE journal_lines (
id BIGSERIAL PRIMARY KEY,
journal_id BIGINT NOT NULL REFERENCES journal_entries(id),
account_code VARCHAR(20) NOT NULL REFERENCES accounts(code),
debit_amount BIGINT NOT NULL DEFAULT 0,
credit_amount BIGINT NOT NULL DEFAULT 0,
-- Exactly one of debit_amount or credit_amount should be non-zero
CONSTRAINT check_one_side CHECK (
(debit_amount > 0 AND credit_amount = 0) OR
(debit_amount = 0 AND credit_amount > 0)
)
);
CREATE INDEX idx_journal_lines_journal ON journal_lines(journal_id);
CREATE INDEX idx_journal_lines_account ON journal_lines(account_code);
The CHECK constraint ensures each line is either a debit or a credit, never both. This prevents common recording errors. The UNIQUE constraint on journal_entries prevents duplicate entries for the same event, just like the single-entry ledger.
Recording a Paystack Charge
When a customer pays NGN 50,000 (5,000,000 kobo) through Paystack and Paystack charges a fee of NGN 750 (75,000 kobo), three things happen financially:
- You earned revenue of NGN 50,000.
- Paystack owes you NGN 50,000 minus their fee.
- You incurred a fee expense of NGN 750.
In double-entry terms:
// Record a Paystack charge with double-entry bookkeeping
async function recordCharge(txn, orderId) {
var client = await pool.connect();
try {
await client.query('BEGIN');
// Create the journal entry
var journal = await client.query(
'INSERT INTO journal_entries (reference, event_type, description, currency, metadata) '
+ 'VALUES ($1, $2, $3, $4, $5) '
+ 'ON CONFLICT (reference, event_type) DO NOTHING '
+ 'RETURNING id',
[
txn.reference,
'charge.success',
'Payment from ' + txn.customer.email,
txn.currency,
JSON.stringify(txn),
]
);
if (journal.rows.length === 0) {
// Duplicate, already recorded
await client.query('ROLLBACK');
return;
}
var journalId = journal.rows[0].id;
var grossAmount = txn.amount; // 5000000 kobo
var feeAmount = txn.fees; // 75000 kobo
var netAmount = grossAmount - feeAmount; // 4925000 kobo
// Line 1: Debit Paystack Receivable (they owe us the net amount)
await client.query(
'INSERT INTO journal_lines (journal_id, account_code, debit_amount, credit_amount) '
+ 'VALUES ($1, $2, $3, 0)',
[journalId, 'PS_RECV', netAmount]
);
// Line 2: Debit Fee Expense (the fee they will keep)
await client.query(
'INSERT INTO journal_lines (journal_id, account_code, debit_amount, credit_amount) '
+ 'VALUES ($1, $2, $3, 0)',
[journalId, 'FEE_EXP', feeAmount]
);
// Line 3: Credit Revenue (we earned the full gross amount)
await client.query(
'INSERT INTO journal_lines (journal_id, account_code, debit_amount, credit_amount) '
+ 'VALUES ($1, $2, 0, $3)',
[journalId, 'REVENUE', grossAmount]
);
// Balance check: debits (netAmount + feeAmount) = credits (grossAmount)
// 4925000 + 75000 = 5000000. Balanced.
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
The debits total 4,925,000 + 75,000 = 5,000,000. The credits total 5,000,000. They match. If they did not match, the code should reject the entry. This is the self-checking property of double-entry bookkeeping.
Recording a Settlement
When Paystack settles NGN 4,925,000 (the net amount after fees) to your bank account, the journal entry moves money from Paystack Receivable to Cash:
// Record a Paystack settlement
async function recordSettlement(settlementData) {
var client = await pool.connect();
try {
await client.query('BEGIN');
var journal = await client.query(
'INSERT INTO journal_entries (reference, event_type, description, currency, metadata) '
+ 'VALUES ($1, $2, $3, $4, $5) '
+ 'ON CONFLICT (reference, event_type) DO NOTHING '
+ 'RETURNING id',
[
'settlement_' + settlementData.id,
'settlement.processed',
'Paystack settlement #' + settlementData.id,
settlementData.currency,
JSON.stringify(settlementData),
]
);
if (journal.rows.length === 0) {
await client.query('ROLLBACK');
return;
}
var journalId = journal.rows[0].id;
// Debit Cash (money arrived in our bank)
await client.query(
'INSERT INTO journal_lines (journal_id, account_code, debit_amount, credit_amount) '
+ 'VALUES ($1, $2, $3, 0)',
[journalId, 'CASH', settlementData.total_amount]
);
// Credit Paystack Receivable (they no longer owe us this amount)
await client.query(
'INSERT INTO journal_lines (journal_id, account_code, debit_amount, credit_amount) '
+ 'VALUES ($1, $2, 0, $3)',
[journalId, 'PS_RECV', settlementData.total_amount]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
After this entry, the Paystack Receivable account balance decreases by the settlement amount. If all charges have been settled, the receivable balance should be zero. If it is not zero, Paystack still owes you money for unsettled transactions.
Recording Refunds and Chargebacks
A refund reverses part or all of a previous charge. A chargeback does the same but is initiated by the customer's bank instead of by you.
// Record a refund
async function recordRefund(refundData) {
var client = await pool.connect();
try {
await client.query('BEGIN');
var journal = await client.query(
'INSERT INTO journal_entries (reference, event_type, description, currency, metadata) '
+ 'VALUES ($1, $2, $3, $4, $5) '
+ 'ON CONFLICT (reference, event_type) DO NOTHING '
+ 'RETURNING id',
[
refundData.transaction_reference,
'refund.processed',
'Refund for ' + refundData.transaction_reference,
refundData.currency,
JSON.stringify(refundData),
]
);
if (journal.rows.length === 0) {
await client.query('ROLLBACK');
return;
}
var journalId = journal.rows[0].id;
// Debit Refund Expense (cost to us)
await client.query(
'INSERT INTO journal_lines (journal_id, account_code, debit_amount, credit_amount) '
+ 'VALUES ($1, $2, $3, 0)',
[journalId, 'REFUND_EXP', refundData.amount]
);
// Credit Paystack Receivable (Paystack deducts refund from next settlement)
await client.query(
'INSERT INTO journal_lines (journal_id, account_code, debit_amount, credit_amount) '
+ 'VALUES ($1, $2, 0, $3)',
[journalId, 'PS_RECV', refundData.amount]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Chargebacks follow the same pattern but use the CHRGBK_EXP account code instead of REFUND_EXP. Separating them matters because chargebacks often carry additional fees and have different reporting requirements. See the chargeback accounting guide and the refund accounting guide for the full treatment.
The Balance Check: Your Built-In Error Detector
The most powerful feature of double-entry bookkeeping is the trial balance. Sum all debits and all credits across all accounts. They must be equal. If they are not, you have a recording error somewhere.
-- Trial balance query
SELECT
'TOTAL DEBITS' as label,
SUM(debit_amount) as amount
FROM journal_lines
UNION ALL
SELECT
'TOTAL CREDITS' as label,
SUM(credit_amount) as amount
FROM journal_lines;
-- If these two numbers are not equal, something is wrong.
-- Account balances (more useful day-to-day)
SELECT
a.code,
a.name,
a.type,
COALESCE(SUM(jl.debit_amount), 0) as total_debits,
COALESCE(SUM(jl.credit_amount), 0) as total_credits,
COALESCE(SUM(jl.debit_amount), 0) - COALESCE(SUM(jl.credit_amount), 0) as balance
FROM accounts a
LEFT JOIN journal_lines jl ON jl.account_code = a.code
GROUP BY a.code, a.name, a.type
ORDER BY a.code;
Run this balance check daily. Automate it. If the debits and credits ever diverge, stop and investigate before processing any more transactions. A common cause of imbalance is a bug in your recording code that writes the charge entry but fails before writing the fee entry.
// Automated balance check
async function checkTrialBalance() {
var result = await db.query(
'SELECT SUM(debit_amount) as debits, SUM(credit_amount) as credits '
+ 'FROM journal_lines'
);
var row = result.rows[0];
var debits = parseInt(row.debits) || 0;
var credits = parseInt(row.credits) || 0;
if (debits !== credits) {
var diff = debits - credits;
console.error('TRIAL BALANCE MISMATCH: debits=' + debits
+ ' credits=' + credits + ' diff=' + diff);
await sendAlert('Trial balance mismatch', {
debits: debits,
credits: credits,
difference: diff,
});
return false;
}
return true;
}
Using Paystack Receivable as a Reconciliation Tool
The Paystack Receivable account balance tells you exactly how much Paystack owes you right now. Every charge increases it. Every settlement, refund, and chargeback decreases it. The running balance should match what Paystack shows in their dashboard under unsettled transactions.
-- What Paystack currently owes us, by currency
SELECT
je.currency,
SUM(jl.debit_amount) - SUM(jl.credit_amount) as paystack_owes_us
FROM journal_lines jl
JOIN journal_entries je ON je.id = jl.journal_id
WHERE jl.account_code = 'PS_RECV'
GROUP BY je.currency;
If this number is negative, Paystack has settled more than you recorded in charges. That means you missed recording some charges. If this number is much higher than expected, you have charges that were never settled. Both situations need investigation.
Compare this balance against the Paystack dashboard weekly. If they match, your books are clean. If they diverge, the difference tells you exactly how much is missing and gives you a starting point for investigation. This is far more efficient than trying to reconcile transaction by transaction. For the full settlement reconciliation process, see the settlement reconciliation guide.
When to Adopt Double-Entry
Not every Paystack integration needs full double-entry bookkeeping from day one. Here is a practical guide:
- Fewer than 50 transactions per month, single product: A simple orders table with a status column is probably fine. You can reconcile manually.
- 50-500 transactions per month, refunds happening: Use the single-entry payments ledger. It gives you separate tracking of charges, fees, and refunds without full double-entry complexity.
- 500+ transactions per month, multiple products, multiple currencies: Adopt double-entry. The self-checking balance property will save you hours every month-end.
- Any fintech application that holds customer funds: Use double-entry from day one. Regulators and auditors expect it.
If you want to build production financial systems like this from scratch, the McTaba 26-week bootcamp covers full-stack engineering from frontend to backend to production monitoring, including payment integrations and financial data systems. KES 120,000 for a career-changing deep dive.
Key Takeaways
- ✓Double-entry bookkeeping means every financial event produces two entries: a debit and a credit. The total debits must always equal the total credits.
- ✓When a Paystack charge succeeds, debit your Paystack Receivable account (Paystack owes you money) and credit Revenue.
- ✓When Paystack settles to your bank, debit Cash and credit Paystack Receivable. This clears the receivable.
- ✓Paystack fees create a third entry: debit Fee Expense, credit Paystack Receivable. Fees reduce what Paystack owes you.
- ✓A balance check query that sums all debits and credits should always return zero. If it does not, you have a recording error.
- ✓This system catches errors that a single-entry ledger misses, like missing settlements, unrecorded refunds, and fee calculation mistakes.
Frequently Asked Questions
- Do I need an accounting degree to implement double-entry bookkeeping in code?
- No. The core concept is simple: every event creates a debit and a credit of equal amounts. The accounts are just categories. If you can write a database transaction that inserts two rows, you can implement double-entry bookkeeping.
- Can I use this alongside an accounting tool like QuickBooks or Xero?
- Yes. Your internal double-entry ledger handles the real-time recording and reconciliation. You can export summaries to QuickBooks or Xero for formal financial statements, tax reporting, and sharing with accountants.
- What happens if my balance check fails?
- Stop and investigate before processing more transactions. The most common cause is a partially completed journal entry (the charge line was written but the fee line failed due to a database error). Find the incomplete entry, add the missing lines, and re-run the balance check.
- How do I handle foreign exchange in double-entry?
- Each journal entry uses a single currency. You never mix currencies in the same entry. If you need to convert between currencies, create a separate journal entry for the conversion with an exchange rate recorded in the metadata. See the multi-country currency accounting guide for details.
- Is this the same as what banks use?
- Banks use the same double-entry principle but with much more complex account structures (general ledger, sub-ledgers, control accounts). The version described here is simplified for a typical Paystack integration. It covers the same concept but with fewer accounts and simpler rules.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon