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.
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
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 |
previousBalance → newBalance |
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 |
previousInOrder → newInOrder |
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
-
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. -
Find the disputed window and check the arithmetic.
previousBalanceon a row should equalnewBalanceon the row before it for the same wallet. A gap means a movement that is not in this list — go to theWALLET_AUDITlog lines for that wallet. -
Read the operation, not the amount. A
HOLDfor the disputed amount means the money is still theirs and still there, locked against an open order. It is not a charge. -
Follow
transactionIdto the transaction row for the customer-facing story — type, status, description, reference and the operator's reason if one was stored. -
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.