Use one amount model throughout the journal

This example uses positive integer amounts plus a debit or credit direction. It does not store negative credits. Each row has a journal ID, account ID, direction, amount in minor units, and currency. Each posted journal must have at least two rows, and total debits must equal total credits within its currency. Reject zero or negative row amounts and mixed currencies.

The account type sets how a balance changes. A debit increases an asset such as cash. A credit increases a liability such as merchant payable. This is why a generic rule that every debit reduces every account gives wrong balances. Write each account's type in the chart of accounts and use that type when rebuilding balances.

Post a capture and two partial refunds

Use a synthetic fee-free payment C-100 for ₦10,000 owed to Merchant A. The cash account is an asset; merchant payable is a liability. For this simple example, both refunds have completed and cash has left the same account. Real provider flows can require a refund clearing account before cash moves.

Positive amounts in NGN kobo
Journal / economic eventAccountDirectionAmount
J-1 / capture C-100CashDebit1,000,000
J-1 / capture C-100Merchant A payableCredit1,000,000
J-2 / refund R-1Merchant A payableDebit400,000
J-2 / refund R-1CashCredit400,000
J-3 / refund R-2Merchant A payableDebit200,000
J-3 / refund R-2CashCredit200,000

Each journal balances on its own. Across all three journals, cash and Merchant A payable are each ₦4,000. Completed refunds total ₦6,000. Replaying R-1 must return J-2, while the distinct R-2 must be allowed. The refunds link to C-100, but each refund has its own economic event ID.

Make the event unique without blocking valid refunds

Use a unique journal key such as provider account, economic event type, and economic event ID. Capture C-100 posts once. Refund R-1 and refund R-2 each post once. A unique key on payment ID plus the word refund would block the second valid partial refund. A webhook delivery ID alone is also too weak when several deliveries describe the same refund.

Link each refund to its capture, then lock or serialize the capture's refund budget while checking completed and reserved refunds. Two distinct refund IDs must not each claim the same remaining value. Validate the merchant and permitted account pair before posting. A balanced journal credited to Merchant B still harms Merchant A.

Check balance across the whole journal

A row-level CHECK can enforce a positive amount and valid direction. It cannot prove that all rows of a journal balance. PostgreSQL warns against CHECK constraints that depend on other rows. Use a controlled posting procedure or a correctly designed deferred constraint trigger to check journal totals and completeness before a journal becomes posted.

In one database transaction, claim the economic event, validate accounts and refund budget, write all journal rows, check debit and credit totals, and mark the journal posted. Restrict direct writes so callers cannot bypass that path. A failed check rolls back the whole transaction. Keep draft journals out of balance views. If you cache account balances, update them in that same commit or use a rebuildable projection with a clear version.

Keep corrections as new journals

Do not edit a posted debit or credit. In a separate fixture with no refunds, a full reversal of J-1 creates a new journal linked to J-1: debit Merchant A payable and credit cash for 1,000,000 kobo. The fixture with R-1 and R-2 has only ₦4,000 left; it cannot reverse the full ₦10,000 again. A partial refund uses its own refund ID and amount, as shown above. Record a reason, source evidence, and approval. Limit who can post adjustments and which accounts each adjustment type can touch.

Test both failure and concurrency

Force a crash after the first row is written but before commit. Neither row may become posted. Force another crash after commit but before the reply. The retry must return the existing journal. Run two workers for R-1 at once; one economic event and one journal must remain. Then submit distinct refunds of ₦7,000 and ₦6,000 against the ₦10,000 capture at once. Their total cannot be approved.

Also try a wrong merchant, an inactive account, a missing fee row, and mixed currencies. Check the rejection reason and rebuild account balances after each run. Case PAY-03 in the synthetic payment test pack covers atomic posting. PostgreSQL's retry guide explains why a serialization failure requires retrying the full transaction.

Reconcile beyond the internal books

A balanced ledger proves an internal accounting rule, not that the provider or bank moved money. Match journal events to provider records and bank movements. Retain journal rows, event keys, linked refunds, commit results, and rebuilt balances. Give every unmatched item an owner. See reconciliation exceptions and payment gateway testing for the wider review.