What you'll learn
- Quick answer
- Why "balance += amount" Is Not How Real Money Gets Tracked
- The Actual Rule: Every Transaction Touches At Least Two Accounts, And Balances
- Building a Real Ledger and Proving It Actually Stays Balanced
- Why Replaying The Same Transaction Must Be A No-Op
- Reconciliation: Checking Your Ledger Against Reality
- What Actually Causes Reconciliation Discrepancies In Practice
- FAQ
Quick Answer
Real financial systems never just increment a balance column — they record every movement of money as a matched pair of debit and credit postings across accounts, so the books are structurally guaranteed to balance or the transaction gets rejected. Reconciliation is the separate, ongoing process of comparing your own ledger against the payment gateway's settlement report to catch the money that quietly went missing between the two.
Why "balance += amount" Is Not How Real Money Gets Tracked
The most natural first instinct for tracking money in a database is a single mutable column: a users table with a wallet_balance field, incremented on a payment and decremented on a refund or withdrawal. It works fine in a demo. It falls apart under the conditions real payment systems actually operate under, for reasons that have nothing to do with the programming language and everything to do with what a balance column can't tell you.
First, a single number has no memory of how it got there. If a support ticket comes in asking "why does this user's balance say ₹1,458?", a balance column has no answer beyond the current figure — you can't reconstruct the sequence of charges, refunds, and fees that produced it unless you've separately logged every mutation somewhere, at which point you've reinvented half a ledger anyway, just without the guarantees. Second, concurrent updates race: two requests reading the same balance, each adding their own delta, and writing back can silently drop one of the two changes unless every single write path is wrapped in the right locking or atomic increment, and it only takes one code path that does balance = old_balance + amount instead of an atomic increment to lose money invisibly. Third, and most importantly for anything handling real transactions: a balance column has no built-in way to reject an invalid change. If a bug computes the wrong amount, the column happily accepts it — there's no structural check that catches "this update doesn't actually make sense."
Double-entry bookkeeping, the centuries-old accounting technique underneath every real bank, ERP system, and payment processor, solves exactly these three problems, and it's worth understanding as a backend engineer even if you'll never file a balance sheet yourself.
The Actual Rule: Every Transaction Touches At Least Two Accounts, And Balances
Double-entry bookkeeping's one core rule is this: money never just "appears" in one place. Every transaction is recorded as a set of postings against two or more accounts, where each posting is either a debit or a credit, and the total value of all debits in a transaction must exactly equal the total value of all credits. "Debit" and "credit" don't mean "subtract" and "add" the way they might colloquially — they're just the two sides of every entry, and which side increases a given account's balance depends on what kind of account it is (an asset account like a bank balance increases with debits; a revenue account increases with credits). What actually matters for our purposes is the invariant: sum(debits) == sum(credits), always, for every single transaction, no exceptions.
Think about what this buys you. A customer paying ₹999 for a course isn't just "+999 somewhere" — it's a debit of ₹999 to a "Razorpay Clearing" account (money the gateway is holding on your behalf, not yet in your bank) and a credit of ₹999 to a "Course Revenue" account. When Razorpay settles that batch to your actual bank account a day later and takes a 2% fee, that's a second, separate transaction: a debit to "Bank Account" for the net amount, a debit to "Payment Gateway Fees" for the fee itself, and a credit to "Razorpay Clearing" clearing out the amount that had been sitting there. Every rupee is accounted for at every step, and — this is the important part — if any code path ever tries to record a transaction where the debits and credits don't match, that's not a rounding curiosity, it's a bug, and the system can refuse to accept it before it ever corrupts the books.
Building a Real Ledger and Proving It Actually Stays Balanced
Here's a minimal but genuinely functioning double-entry ledger in Python. It accepts a transaction only if its debits and credits match, and rejects it otherwise:
class LedgerError(Exception):
pass
class Ledger:
"""Every transaction is a list of postings: (account, direction, amount).
A transaction is only accepted if total debits == total credits."""
def __init__(self):
self.transactions = []
self.processed_ids = set() # for idempotent replay handling
def post_transaction(self, txn_id, postings, description=""):
if txn_id in self.processed_ids:
return False # already recorded -- a retried webhook is a no-op
total_debit = sum(amt for (_, d, amt) in postings if d == "debit")
total_credit = sum(amt for (_, d, amt) in postings if d == "credit")
if total_debit != total_credit:
raise LedgerError(
f"Transaction {txn_id} does not balance: "
f"debits={total_debit} credits={total_credit}"
)
self.transactions.append({"id": txn_id, "postings": postings, "description": description})
self.processed_ids.add(txn_id)
return True
def trial_balance_check(self):
total_debits = sum(amt for t in self.transactions for (_, d, amt) in t["postings"] if d == "debit")
total_credits = sum(amt for t in self.transactions for (_, d, amt) in t["postings"] if d == "credit")
return total_debits, total_creditsNow let's actually run three realistic transactions through it — the course payment, the settlement with its fee split, and a refund on a different order — and check whether the whole ledger stays balanced:
ledger = Ledger()
# Customer pays Rs 999 via Razorpay for a course
ledger.post_transaction("razorpay_pay_A1001", [
("Razorpay Clearing", "debit", 999),
("Course Revenue", "credit", 999),
])
# Razorpay settles to our bank, taking a Rs 20 fee
ledger.post_transaction("settlement_S5001", [
("Bank Account", "debit", 979),
("Payment Gateway Fees", "debit", 20),
("Razorpay Clearing", "credit", 999),
])
# Refund on a separate order
ledger.post_transaction("refund_R2002", [
("Course Revenue", "debit", 499),
("Bank Account", "credit", 499),
])
# Replay the SAME webhook event for txn 1 (gateways retry webhook delivery)
replay_result = ledger.post_transaction("razorpay_pay_A1001", [
("Razorpay Clearing", "debit", 999),
("Course Revenue", "credit", 999),
])
print("Replay accepted as new transaction?", replay_result)
debits, credits = ledger.trial_balance_check()
print("Total debits:", debits, "Total credits:", credits)
assert debits == credits
# Now try to post a DELIBERATELY unbalanced transaction (a bug that forgets the fee leg)
try:
ledger.post_transaction("bad_settlement_S9999", [
("Bank Account", "debit", 979),
("Razorpay Clearing", "credit", 999),
# missing: ("Payment Gateway Fees", "debit", 20)
])
except LedgerError as e:
print("Correctly rejected:", e)This is the real output from actually running that code, not a predicted result:
Replay accepted as new transaction? False
Total debits: 2497 Total credits: 2497
Correctly rejected: Transaction bad_settlement_S9999 does not balance: debits=979 credits=999Two things to notice. The replay of razorpay_pay_A1001 correctly returned False instead of double-recording the payment — we'll come back to why that matters in a moment. And the deliberately broken transaction, which forgot to post the ₹20 fee as its own leg, was rejected with a specific error naming the exact mismatch (debits=979 credits=999) rather than being silently accepted and quietly corrupting the books. That rejection is the entire value of the double-entry structure: an entire category of accounting bugs gets caught structurally, at write time, instead of being discovered three months later during an audit.
Why Replaying The Same Transaction Must Be A No-Op
Payment gateway webhooks are not guaranteed to arrive exactly once. Network blips, retries after a slow response, and gateway-side retry policies mean your webhook handler for "payment captured" can and eventually will be called two or three times for the exact same underlying payment. If your ledger's post_transaction function just appended postings every time it was called, a duplicated webhook would double-count real revenue — and because the two calls carry matching debits and credits, that duplicate would pass the balance check with no complaint at all. Balancing correctly and being correct are not the same guarantee.
This is why every transaction needs a stable, unique identifier — a txn_id derived from the gateway's own payment ID, not something generated fresh on each webhook delivery — and why the ledger checks that ID against everything it's already recorded before accepting anything. In the code above, self.processed_ids is exactly that check: the second delivery of the same webhook for razorpay_pay_A1001 is recognized as already-processed and silently ignored rather than recorded a second time. This property — that applying the same operation twice has the same effect as applying it once — is called idempotency, and it's a non-negotiable requirement anywhere money and network retries intersect, which in practice is everywhere in payments.
Reconciliation: Checking Your Ledger Against Reality
A perfectly balanced internal ledger only proves your own bookkeeping is internally consistent — it says nothing about whether it matches what actually happened at the payment gateway or in your bank account. Reconciliation is the separate, ongoing process of comparing your internal ledger against an external source of truth — typically the settlement report or statement the payment gateway or bank provides — transaction by transaction, to catch anywhere the two disagree.
In practice this means pulling the gateway's settlement report (via API or a downloadable CSV, depending on the provider) for a given batch or date range, matching each line against your own recorded transaction using a shared identifier (the gateway's payment ID, order ID, or a bank UTR number), and flagging anything that doesn't line up cleanly. A clean match means: the amount agrees, the fee agrees, and the transaction exists on both sides. Anything else — a payment in your ledger with no matching settlement line, a settlement line with no matching ledger entry, or an amount that doesn't match — becomes an exception that a human (or an automated alert) needs to look at before the books get closed for that period.
What Actually Causes Reconciliation Discrepancies In Practice
Discrepancies are rarely dramatic fraud — they're almost always mundane timing and encoding issues, and knowing the usual suspects makes triaging them much faster:
- Settlement timing lag. A payment captured on one day might not appear in a settlement report until T+1 or T+2 business days later, so a naive same-day comparison will always show "missing" entries that are really just not settled yet.
- Fee miscalculation. If your system estimates the gateway's fee at capture time (before the actual settlement report confirms it) and the real fee differs by a few paise due to rounding or a fee-tier change, your ledger and the gateway's report will disagree by a small, consistent-looking amount.
- Failed or missed webhooks. If a webhook delivery genuinely never arrived (not just delayed) and there's no fallback polling job checking payment status directly against the gateway's API, a real successful payment can exist at the gateway with no corresponding entry in your own ledger at all — which reconciliation is often the only thing that will ever surface.
- Partial or split refunds. A refund processed for less than the original amount, or split across two payouts, needs its own matching postings; treating it as a simple mirror of the original transaction will throw off both the ledger balance and the reconciliation match.
The practical takeaway is that reconciliation isn't a one-time audit exercise — it's a recurring job (daily, for anything handling live payments) that compares two independently-maintained records of the same reality and treats any disagreement as a signal worth investigating, precisely because your own ledger being internally balanced can never, by itself, prove it's correct.
