Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 034 / 100

Double-entry ledgers for developers in Payments & Stripe

Accounting principles that prevent money bugs.

Expona Admin9 min read

The Silent Culprit Behind Money Bugs

You deploy a payment feature on Friday. By Monday, your finance team reports that $47,000 is unaccounted for across your ledger. The feature worked—transactions went through, confirmations were sent—but somewhere between your database and reality, money went missing. Double-entry ledgers for developers in Payments & Stripe aren't accounting theatre; they're a practical safety net that catches bugs your tests won't find. When you move money through any system, you need to record not just that the movement happened, but where it came from and where it went.

In this guide, we'll walk through the accounting principles that keep payment systems honest, how to implement them alongside Stripe, and why ignoring them costs real money.

Every Movement Has Two Sides: Debit One Account, Credit Another

Double-entry bookkeeping rests on a single rule: every financial transaction touches exactly two accounts. When a customer's balance increases by $10, that $10 must come from somewhere—your platform's clearing account, a merchant's funds, a payment processor's float. You write down both sides. This isn't redundancy; it's a checksum for money.

Consider a marketplace. A buyer sends $100 to purchase an item. In a naive system, you'd record:

  • buyer_wallet balance -= 100

Done. But where is that $100? If your seller's account doesn't increase and your platform's clearing account isn't updated, you've created an orphaned transaction. A month later, reconciliation breaks. A double-entry view records the same event correctly:

Account Debit Credit Description
buyer_wallet $100 Funds out
clearing_account $100 Funds in (held pending seller acceptance)

Now the sum of all debits equals the sum of all credits. Every dollar is accounted for. If your reconciliation reveals a $100 mismatch, you know to search for an incomplete entry.

In a Stripe integration, this principle becomes concrete when you handle webhook events. A charge.succeeded event means Stripe has moved money. Your code must create at least two ledger entries:

typescript
async function handleChargeSucceeded(chargeId: string, customerId: string, amount: number) {
  const transaction = await db.transaction();
  
  try {
    // Entry 1: Customer's balance decreases (credit)
    await ledger.entry({
      transaction,
      account: `customer:${customerId}:wallet`,
      debit: 0,
      credit: amount,
      timestamp: new Date(),
      externalId: chargeId,
      description: `Charge via Stripe: ${chargeId}`
    });

    // Entry 2: Platform clearing account increases (debit)
    await ledger.entry({
      transaction,
      account: `platform:clearing:stripe`,
      debit: amount,
      credit: 0,
      timestamp: new Date(),
      externalId: chargeId,
      description: `Received charge from Stripe: ${chargeId}`
    });

    await transaction.commit();
  } catch (error) {
    await transaction.rollback();
    throw error;
  }
}

Both entries happen or neither does. If either fails, you roll back and retry. No orphaned transactions. No missing money.

Balances Are Sums: Never Store a Balance You Cannot Rebuild

The second pillar is this: never store a balance directly. A balance is derived. Sum all entries for an account, and that's your truth. If you cache a balance in a denormalized column and it falls out of sync, you've created a land mine.

A real example: your platform holds customer wallets. You store a wallets.balance column and update it on each transaction. One day, a Stripe webhook retries. Your idempotency logic catches the duplicate charge correctly and doesn't bill the customer twice. But a bug in your code doesn't update the balance rollback, so the wallet balance remains wrong. Days later, the customer has spent money they shouldn't have. Finance can't explain the gap.

The fix: make balances immutable and derived.

typescript
// Bad: balance is stored and mutable
async function chargeWallet(customerId: string, amount: number) {
  const wallet = await db.query(
    'SELECT balance FROM wallets WHERE customer_id = $1',
    [customerId]
  );
  
  if (wallet.balance < amount) throw new Error('Insufficient balance');
  
  await db.query(
    'UPDATE wallets SET balance = balance - $1 WHERE customer_id = $2',
    [amount, customerId]
  );
}

// Good: balance is derived from immutable entries
async function getWalletBalance(customerId: string): Promise {
  const result = await db.query(`
    SELECT
      COALESCE(SUM(debit), 0) - COALESCE(SUM(credit), 0) as balance
    FROM ledger_entries
    WHERE account = $1
  `, [`customer:${customerId}:wallet`]);
  
  return result.rows[0].balance;
}

async function chargeWallet(customerId: string, amount: number) {
  const balance = await getWalletBalance(customerId);
  
  if (balance < amount) throw new Error('Insufficient balance');
  
  // Create entries instead of updating a stored balance
  await ledger.entry({
    account: `customer:${customerId}:wallet`,
    credit: amount,
    debit: 0,
    description: `Charge: ${amount}`
  });
  
  await ledger.entry({
    account: `platform:clearing`,
    debit: amount,
    credit: 0,
    description: `Received charge`
  });
}

This looks slower—and it is, without a cache. But the correctness is non-negotiable. You can add a cached balance column if performance demands it, but only as a performance layer. You rebuild it nightly or on demand. The source of truth is always the sum of entries.

When an auditor (or worse, a regulator) asks, "How did the customer end up with $5,000 they shouldn't have?", you can replay every single entry and explain exactly when, why, and from which account the money moved. You cannot do that with a mutable balance column.

Immutable Entries: Correct Mistakes With New Entries

Once an entry is written, it stays. You never edit it. If a mistake is discovered, you create a new entry that corrects it. This isn't bureaucracy; it's auditability. Every entry has a timestamp, an external reference (like a Stripe charge ID), and a human-readable description. A year from now, compliance can trace the entire history.

Say a customer reports they were double-charged. You investigate and confirm that two charge.succeeded events arrived for the same Stripe charge ID—a webhook retry that wasn't idempotent. You've already recorded two ledger entries. You don't delete or edit them. Instead:

typescript
async function reverseChargeError(
  customerId: string,
  chargeId: string,
  amount: number,
  reason: string
) {
  const transaction = await db.transaction();
  
  try {
    // Entry 1: Reverse the customer debit
    await ledger.entry({
      transaction,
      account: `customer:${customerId}:wallet`,
      debit: amount, // Now we credit back (debit side of reversal)
      credit: 0,
      timestamp: new Date(),
      externalId: `${chargeId}:reversal`,
      description: `Reversal of duplicate charge ${chargeId}. Reason: ${reason}`
    });

    // Entry 2: Reverse the platform clearing credit
    await ledger.entry({
      transaction,
      account: `platform:clearing:stripe`,
      debit: 0,
      credit: amount, // Now we debit back (credit side of reversal)
      timestamp: new Date(),
      externalId: `${chargeId}:reversal`,
      description: `Reversal of duplicate charge ${chargeId}. Reason: ${reason}`
    });

    await transaction.commit();
    
    // Log this for finance review
    await auditLog.record({
      event: 'charge_reversal',
      customerId,
      chargeId,
      amount,
      reason,
      reversalTime: new Date()
    });
  } catch (error) {
    await transaction.rollback();
    throw error;
  }
}

The original entries remain visible in the ledger, marked as reversed by new entries with the same external ID but a :reversal suffix. An accountant can see the full history. When reconciling Stripe transactions, you can match charge IDs and understand why the ledger shows both a charge and a reversal.

Use Cases: Wallets, Marketplaces, and Credits

Wallets

A customer loads $50 into their wallet via card. That creates two entries:

  • Debit: platform:acquiring_bank (funds received from the customer's card)
  • Credit: customer:wallet (customer's balance increases)

Later, the customer spends $20. Two more entries:

  • Credit: customer:wallet (balance decreases)
  • Debit: merchant:settlement (merchant gets paid)

Balance checks become simple: sum all entries for a wallet account. Reconciliation is straightforward: every dollar in the platform maps to either a customer wallet, a merchant settlement, or a clearing account.

Marketplaces

A buyer pays a seller through your platform. Stripe takes a fee. All in one transaction:

Account Debit Credit Reason
buyer_wallet $100 Purchase
seller_settlement $97 After 3% Stripe fee
platform:stripe_fees $3 Fee accrual

Debits equal credits. The buyer's money is fully accounted for across three destinations. When Stripe's settlement hits your bank, you match it to the fee accrual account. When the seller withdraws, they're withdrawing from their settlement account. No ambiguity.

Credits and Refunds

A customer buys $50 worth of goods and requests a refund within 24 hours. You process the refund via Stripe. The ledger records:

  • Credit: platform:clearing:stripe (money goes back to Stripe, which refunds the customer's card)
  • Debit: customer:wallet (or an intermediate refund-pending account, depending on your UX)

Days later, Stripe confirms the refund in a webhook. That confirms your earlier entry was correct. If the refund fails, Stripe sends a different event, and you create a reversal entry. Again: immutable record of what happened and when.

Common Mistakes

Mistake 1: Balances stored without a source-of-truth ledger. You'll eventually have reconciliation failures and no way to trace them. Start with the ledger. Add caching only if performance requires it, and always rebuild the cache from the source.

Mistake 2: Only one account per transaction. You record a customer charge but forget to credit a clearing account. Months later, reconciliation breaks and you can't explain where the money went. Every movement must have two sides.

Mistake 3: Editing or deleting ledger entries. An entry was wrong, so you delete it. Now you've lost the audit trail. Worse, you can't explain the gap to compliance. Always create reversals instead.

Mistake 4: No idempotency on webhook handlers. A Stripe webhook retries, you process it twice, and you create duplicate ledger entries. Use Stripe's charge ID (or your own idempotency key) to skip duplicate entries. Check if an entry already exists before creating a new one.

Mistake 5: Not reconciling regularly. If you don't reconcile daily or weekly, bugs hide for months. Reconciliation is your alarm bell. Set it up early.

Key Takeaways

  • Every transaction touches exactly two accounts: one decreases (credit), one increases (debit). This is your checksum for money.
  • Never store a balance directly. Balances are derived by summing ledger entries. If they fall out of sync, you have no source of truth.
  • Ledger entries are immutable. Correct mistakes with new entries (reversals). This preserves the audit trail and makes compliance audits tractable.
  • Implement ledger entries at the same transaction boundary as your business logic. If the business logic fails, the ledger entries roll back together.
  • Make each entry idempotent using external IDs (Stripe charge IDs, for example). If a webhook retries, you won't create duplicate entries.
  • Reconcile frequently. Compare your ledger to Stripe's transaction history and your bank account. Mismatches are bugs, and bugs that hide become expensive.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles