The wallet balance ledger

The append-only record of every balance mutation — its columns, the eight operations, how to query it for one wallet or one customer, and how it differs from the admin audit trail.

6 min readUpdated 11 August 2026audit, wallets, balances, forensics, disputes

Every time the wallet service changes a balance, it writes a row to wallet_audit_log recording the balance before and after, the amount that caused the change, and the idempotency key the operation ran under.

This is the correct answer to "my balance is wrong" and to any disputed withdrawal. It is the only record in the platform that can distinguish a customer who was credited once from one who was credited twice, and it is the strongest compliance story the product has.

It is also easy to miss: it is surfaced as an unlabelled panel on the Audit Trail tab of four detail screens, and nowhere else.

The endpoint

The balance ledger. Read-only by design — there is no write endpoint.

Nothing in the product writes to this table through an API. The wallet service is the only producer, the table is timestamps: false with a manual createdAt and no updatedAt, and there is no soft-delete column. Append-only is a property of the schema, not a convention.

Querying it

The route takes the standard CRUD parameters: page, perPage, sortField, sortOrder and a JSON-encoded filter.

GET /api/admin/finance/wallet/audit?filter={"walletId":"<uuid>"}
GET /api/admin/finance/wallet/audit?filter={"userId":"<uuid>"}
  • walletId — one wallet's history: one currency, one type, one customer.
  • userId — that customer's entire trail across every wallet they hold.

Default sort is createdAt. The wallet is joined in, so each row carries the currency and type of the wallet it moved — which is what makes a userId query readable when the customer holds six currencies.

The five money columns are declared numeric on the route so they sort and filter as numbers. Without that they would sort lexicographically and "9" would come after "10".

The columns

Column What it is Why it matters
operation One of the eight values below Says what kind of movement this was, independent of the transaction's type
amount The magnitude of the movement Always positive; direction comes from operation
previousBalancenewBalance Free balance either side of the operation The arithmetic is the whole point. If these two do not differ by amount, something else moved the balance in between
previousInOrdernewInOrder Held balance either side Only moves on HOLD, RELEASE and EXECUTE_FROM_HOLD; the panel shows a dash when it did not change
transactionId The transaction row this operation belongs to The bridge to the transaction ledger. Nullable — a WALLET_CREATED row has no transaction
idempotencyKey The de-duplication key the operation ran under Uniquely indexed. See below
createdAt When The ordering column
userId · walletId Who and which wallet The two filter keys
metadata Operation context — operationType, fee, reference id Free-form; not queryable

All the money columns are DECIMAL(30,18) and arrive from the driver as strings. The panel coerces every one before formatting; so must anything you write against the API.

The eight operations

Operation Direction Moves
WALLET_CREATED The wallet came into existence. No transaction attached
CREDIT In Balance up
DEBIT Out Balance down
TRANSFER_IN In The recipient's leg of an internal transfer
TRANSFER_OUT Out The sender's leg
HOLD Neither Balance → inOrder. The customer still owns it; they cannot spend it
RELEASE Neither inOrder → balance
EXECUTE_FROM_HOLD Out Held funds actually spent — an order filling against its own hold

HOLD and RELEASE are deliberately shown as neutral rather than as a credit or a debit. They move money between two columns of the same wallet and change nothing about what the customer owns. Reading a HOLD as a debit is the most common misread of this table.

The idempotency key is the proof

Every wallet operation runs under a key, and the table carries a unique index on it. That is what makes this ledger able to answer the question a transaction list cannot:

The customer says they were charged twice. Were they?

If two operations shared a key, only the first is here — the second was rejected as a duplicate. Two rows with different keys and identical amounts is a genuine double-movement. One row is one movement, always.

The keys are readable and name their origin: admin_deposit_approve_<txId>, platform_fee_<TYPE>_<referenceId>, admin_transfer_approve_<txId>, transfer_reject_<txId>, admin_wallet_adjust_<walletId>_<type>_<amount>_nonce_<nonce>. The panel shows both the key and the transaction id in the tooltip on the When column.

Persistence is best-effort. The wallet service writes the audit row inside the caller's own database transaction, and a failure there — a transient error, or a duplicate key on a retry — is logged as a warning and swallowed rather than failing the customer's operation. Blocking a payout because an audit insert failed would be the wrong trade.

Every entry is also written to the backend log under the WALLET_AUDIT module as a single JSON line, so a row that never reached the table is usually still recoverable from the log. If you find balance movement with no ledger row, search the log for the wallet id before concluding anything.

Where it appears in the admin panel

The panel is composed once and mounted on five detail pages, on the tab labelled Audit Trail:

Screen Ledger scope
/admin/finance/wallet/[id] That one wallet — opened from the row's View action on Wallet Management
/admin/finance/deposit/log/[id] The wallet the deposit credited
/admin/finance/withdraw/log/[id] The wallet the withdrawal debited
/admin/finance/transfer/[id] The wallet the transfer moved
/admin/crm/user/[id] The customer's whole trail, across every wallet

The wallet screen is the one to reach for when you already know which wallet is in question: it puts the Adjust balance control and the ledger that control writes to on the same page, so you can make a correction and read back what it did without leaving for the customer's record.

On every screen except the customer record the tab shows two panels stacked: the administrative actions taken against that record, and beneath it the balance ledger for the wallet it moved. The first tells you who decided; the second tells you what the decision did to the balance. That pairing is the point of the tab.

The panel loads ten rows at a time and accumulates them behind a Load more button rather than paging, because an audit trail is read as a continuous history. The count in the footer reads Showing n of <total>.

It renders as a plain table rather than the platform's data table, because the value is in the arithmetic lining up: previous → new beside the amount that caused it. Wide content scrolls inside the panel; the page does not scroll sideways.

How it differs from the admin audit trail

The platform has two append-only audit tables and they answer different questions. Mixing them up costs an investigation an hour.

Balance ledger Admin audit trail
Table wallet_audit_log admin_audit_log
Endpoint /api/admin/finance/wallet/audit /api/admin/system/audit
Screen The Audit Trail tab on five detail pages System → Audit Trail (/admin/system/audit)
Permission access.wallet access.admin.audit
Records Balance deltas — every credit, debit, hold and release the wallet service performed Admin actions — every administrative mutation that went through the request pipeline
Written by The wallet service The request pipeline, on any mutating admin route
Answers "Did this balance move, by how much, and was the retry de-duplicated?" "Who did this, when, from what IP, and did it succeed?"

A customer's own trading, deposits and withdrawals produce no admin audit rows — nobody administrative acted. They produce balance-ledger rows for every movement. Conversely, an admin action that changed no balance (editing a gateway, approving a KYC application) appears only in the admin trail.

One semantic that is easy to get backwards on the admin trail: userId is the admin who acted and targetId is the record acted upon. Filtering a customer's page by userId shows what that customer did as an admin, which for a customer is nothing at all — the panel looks empty rather than wrong.

Using it on a dispute

  1. Start from the customer, not the transaction. Open /admin/crm/user/<id>, go to Audit Trail, and read the balance ledger. You get every movement across every wallet in one chronological list.

    Once you know which wallet is wrong, open that wallet (/admin/finance/wallet/<id>) and work from its own Audit Trail tab instead — same ledger, narrowed to one currency, with the correction control on the same page.

  2. Find the disputed window and check the arithmetic. previousBalance on a row should equal newBalance on the row before it for the same wallet. A gap means a movement that is not in this list — go to the WALLET_AUDIT log lines for that wallet.

  3. Read the operation, not the amount. A HOLD for the disputed amount means the money is still theirs and still there, locked against an open order. It is not a charge.

  4. Follow transactionId to the transaction row for the customer-facing story — type, status, description, reference and the operator's reason if one was stored.

  5. For "I was charged twice", compare idempotency keys. Same key cannot appear twice. Different keys with the same amount and the same minute is a real duplicate, and now you have both transaction ids to reverse against.

Related: Customer wallets and balance adjustments for what a hand adjustment writes here, and Transaction types and statuses for the row on the other end of transactionId.