Case Study

Payment Ledger, Balance Management and Reconciliation

This payment ledger system case study covers transfers, held funds and the matching of bank statements.

An accepted payment request is not yet a posted transfer. The ledger is built to record exactly that distinction. Its API can save queued work and reply, and a worker updates the balances afterward.

The ledger handles transfers and held funds. It also covers refunds and scheduled transactions. The accounting product around it owns the chart of accounts and the reporting.

The challenge

Two valid transfers may compete for one balance. A duplicate request is a different problem. Processing has to reject a repeated reference while it coordinates new work. It also has to leave enough history to recover a transaction that stopped before posting.

What we built

A transfer names a source and a destination. It also sets the amount to move and a reference that identifies the request. A dry run previews the calculation first. Each balance holds credit, debit and inflight totals. A hold placed on funds can expire on its own once a set time passes.

Statement files can be imported and matched against ledger transactions using configurable rules. Selected balances can also trace where their funds came from. That lineage ties incoming money to later spending. The split can follow an oldest-first order, a newest-first order or a proportional share.

Check the balance again at the write

Source-based hashing routes related work to one queue. Each worker then takes Redis locks in a consistent key order. At the database write, a version check rejects a balance that changed since the worker read it. That check still matters if the distributed lock expires part-way through processing.

Compatible transfers can share one SQL transaction. When they cannot, each one is processed on its own. Split and bulk transfers behave differently, because their legs post one at a time. A later failure then voids or refunds the earlier legs, rather than rolling the whole operation back at once.

Keep duplicate checks separate from recovery

References are checked before processing and protected by a unique database index. A task identifier adds a second check at the scheduling level. An ordinary repeated reference simply returns an error, so a caller should not expect the original response. Recovery of transactions left queued runs on its own separate path.

Posted rows are protected against change by PostgreSQL triggers. A later sealing process joins batches into a SHA-256 hash chain, and a verification command confirms it. New rows enter that chain the next time sealing runs.

Fund-lineage work relies on an outbox saved alongside the transfer. Webhooks and other post-actions run outside that transaction, so they need their own delivery handling wherever a notification has to be guaranteed.

Project success

Operators can tell queued, posted and compensated work apart. In an investigation they rely on references and the immutable posted records, and reconciliation ties those transfers to imported statement entries. The record shows the processing state as well as the balance movement. That matters most when acceptance and completion happen at different times.

Technology stack

API

Go, Gin

Database

PostgreSQL

Queueing and locks

Redis, Asynq

Observability

OpenTelemetry

Optional search

Typesense

Recognize the pattern?

If one of these looks like your situation, the discovery call is where we find out how close.