Reading the revenue screen

Why platform revenue is the Super Admin's own wallet balances joined to adminProfit rows, what each tile on /admin/finance/profit measures, and the log lines that mean fees are being dropped or a payout went unfunded.

11 min readUpdated 12 August 2026revenue, profit, fees, super-admin, reporting

Finance → Revenue Analytics (/admin/finance/profit) is the platform's P&L. It is not a report computed over transactions. It is two different things joined on one screen:

  • What you hold — the balances on the Super Admin's own wallets, read live, per wallet type and per currency.
  • What you booked — rows in the admin_profit table, one per fee the engine captured or payout it funded.

Everything on the page follows from that. Revenue is zero on a busy platform whenever the first half has nobody to point at, and the screen will not tell you so — it renders a clean, empty dashboard.

The page needs access.admin.profit; the data behind it needs view.admin.profit.

Everything above the table, in one call.
The profit ledger itself — the table below the summary.

Where revenue is actually held

There is no treasury account, no house ledger and no separate revenue table of balances. When the fee engine collects, it credits a wallet belonging to a real user account — the Super Admin.

That account is resolved in two steps, and both can fail:

  1. Find the role whose name is exactly Super Admin.
  2. Find the oldest user (createdAt ascending) holding that role.

If either step comes back empty, collectPlatformFee logs and returns. It never throws — a fee-collection failure must not roll back a customer's deposit — so the customer's money moves correctly and yours simply does not arrive. Nothing on the admin panel goes red.

Two lines in the backend log under the PLATFORM_FEE module are the only warning you get, and they are logged at error level for that reason:

[CRITICAL] No Super Admin role configured — platform fees are being dropped!
[CRITICAL] No Super Admin user configured — platform fees are being dropped!

The second one is the trap. The role existing is not enough — a role with no user on it drops fees exactly as thoroughly as no role at all. Renaming the role, deleting the original superadmin account, or migrating that account onto a different role each produce this state.

Every dropped fee is also logged individually with its amount, currency, type and reference, so a window of lost revenue can be reconciled from the log after you fix the cause.

The lookup is cached for five minutes. A role change, a new Super Admin account or a repaired configuration is not reflected until that cache expires, so do not judge a fix by the next fee — wait, or restart the backend.

One deliberate exclusion: when the acting user is the Super Admin, fee collection is skipped entirely. Charging yourself a fee and crediting it back is a circular no-op that inflates the report. Your own test trades therefore book no revenue.

The wallet balances — totalsByWalletType

The Admin wallet balances section reads the Super Admin's wallet rows directly, grouped by type and then by currency. Under the platform's HOLD model each currency line carries three figures:

Figure Formula What it is
available balance (floored at zero) Free — you can withdraw or spend this
total balance + inOrder Everything the wallet holds
walletBalance balance The raw free figure, unfloored

The card prints available / total and shows the second half only when they differ, so a line with one number is a wallet with nothing on hold. inOrder is money held against an open order or an escrow — it is yours, it is not spendable yet.

Wallet types whose every currency is empty are dropped from the section rather than rendered as a row of zeroes, so a type disappearing from this list means it holds nothing, not that it broke.

The Super Admin's wallets appear on the wallet screen like anyone else's. Freezing one makes every fee credit into it fail. Deleting one is refused while it holds funds, and would be catastrophic if it were not.

The booked figures — the three breakdowns

All three come from admin_profit, all three are all-time with no date filter, and none of them is a wallet balance.

Key Grouped by Rendered as
totalsByType type Top fee sources — one ranked bar per revenue line, with a row count in brackets
totalsByCurrency currency Top currencies
recentFees The 20 newest rows, returned by the API; the table below the summary is where you read them

Both breakdown cards rank and scale on magnitude, not signed value. A large payout is a large flow and belongs at the top; sorting by the signed number would bury the only rows that need attention. A negative row draws in the loss colour and always carries its minus sign.

trackingSince is the createdAt of the earliest admin_profit row and is printed as the footnote under the summary. On an install that has never booked a fee there is no such row, and the footnote is absent.

Totals are per currency, and are never added together

This is the single most important thing to know about the page.

admin_profit.currency is a per-row column. A platform running USD, NGN and BTC fees has rows in all three, and adding them produces a number that means nothing: 0.0004 + 5 + 12000 is not 12005.0004 of anything.

So periodComparison — the Today, This week, This month and Last month tiles — is grouped by currency by construction. Each tile shows the currency with the largest magnitude as its headline figure, with the rest listed beneath it and a Show all (n) disclosure when there are more than three.

The % vs last month chip under the This month tile compares that tile's lead currency only, against the same currency last month, and names the currency beside the percentage. It is not a platform-wide growth figure. If your lead currency changes between months, the comparison changes what it is comparing — read the currency label, not just the arrow.

Period boundaries are computed in the server's local time, and the week starts on Sunday. A currency whose total for a window is exactly zero is omitted from that window rather than shown as 0.00.

The tab switcher in the page header swaps the summary for a chart view. The summary panel is all-time and per-currency; the analytics cards are scoped to the selected date window and price each row into USD at its own rate. Both are correct; they answer "what have we earned" and "what earned this period". Where the analytics tab shows a single ranked bar across currencies, its title says so.

The revenue-line vocabulary

Every row carries a type from a fixed list. This is what you will see on the badges, in the filter dropdown and in the Top fee sources card.

Type Where it comes from
DEPOSIT Fee on a deposit, booked at approval
WITHDRAW Fee on a withdrawal, booked at settlement
TRANSFER Wallet-transfer fee — see Internal transfers
BINARY_ORDER The house's side of a binary option
EXCHANGE_ORDER Spot order fee
TRADE Generic trade fee
INVESTMENT · AI_INVESTMENT The house's side of an investment settlement
FOREX_DEPOSIT · FOREX_WITHDRAW · FOREX_INVESTMENT Forex addon
ICO_CONTRIBUTION Token-sale contribution fee
STAKING Staking fee
P2P_TRADE P2P trade fee
GATEWAY_PAYMENT Merchant gateway checkout
NFT_SALE · NFT_AUCTION · NFT_OFFER NFT marketplace
REFERRAL_REWARD A platform payout — always negative
DEX_SWAP Swap fee, credited only by the on-chain fee sweep
DEX_LP_FEE Realised liquidity-provider fees from a V3 collect()
DEX_LISTING A listing or promotion fee invoiced to a project

Two of these behave differently from the rest.

REFERRAL_REWARD is negative. It is written by the platform-loss path, not the fee path, so the report nets out instead of presenting gross fees captured as if they were profit. The same mechanism records the house's losses on binary payouts and winning investments. A negative row is money you paid; the view dialog labels it Platform payout rather than Fee collected.

DEX_LP_FEE is yield on your own capital, not a fee charged to a customer. It sits beside an impermanent loss that is never in this table. Do not read it as revenue from trading activity.

When the platform pays out, and the treasury is short

The loss path is not the fee path run backwards. It debits the Super Admin's wallet best effort, capped at what that wallet actually holds — the debit is the smaller of the payout and the wallet's free balance in that currency — and it never blocks the payout. By the time it runs the customer has already been credited, and a house with no reserves must not be the reason their winnings fail.

The negative admin_profit row is written anyway, and it carries the full amount rather than the part that was funded. That is the point: an unfunded payout appears here as a platform loss instead of being recorded nowhere, which would leave the matching fee row on the report looking like pure profit. When nothing was debited the row has no transactionId — there is no transaction to open from the view dialog, and the row itself is the only evidence the payout happened.

Three lines under the same PLATFORM_FEE module report it. They are logged at warn, not [CRITICAL], because nothing was lost and nothing was mis-recorded — the customer was paid and the books net out. It is the treasury that is behind.

Treasury short on <TYPE> payout: owed <amount> <currency>, debited <debited>. The full amount is still recorded against admin profit. referenceId=<ref>
Recorded <TYPE> loss of <amount> <currency> with NO treasury debit (treasury empty). referenceId=<ref>
Treasury debit REFUSED on <TYPE> payout of <amount> <currency>: <reason>. Booking the loss unfunded. referenceId=<ref>

The first two are the same condition at two depths. Treasury short means the wallet held less than the payout and was drained to whatever it had; the second line appears when it held nothing at all and no debit was attempted. Neither needs anything doing to the books — fund the Super Admin's wallet in that currency and wallet type, and the next payout debits normally. The gap is visible on the page itself: the loss rows are there while the wallet balance above them is not falling.

Treasury debit REFUSED is different, and it is the one to read the reason on. The balance was there when the amount was decided and the debit was still turned away; the line quotes why. Most often the wallet was drained between the two reads, or it is frozen — and a frozen Super Admin wallet refuses every fee credit into it as well, so this line is often the first symptom of a much wider problem. Check it on the wallet screen.

A fourth line is logged at error level, and it is the exception to everything above:

[CRITICAL] Unrecorded platform loss — no Super Admin configured. type=<TYPE> amount=<amount> currency=<currency> walletType=<type> referenceId=<ref>

This is the same missing-Super-Admin fault as above, on the payout side. There is no wallet to debit and no row is written — so the fee legs stay on the report with nothing offsetting them and the platform reads as more profitable than it is, in the exact way the loss path exists to prevent. The line carries the amount, currency, type and reference of every payout it lost, so the window can be reconciled by hand once you have fixed the role or the account.

Accrued on chain, and why it is fenced off

If the Swap addon is installed, a warning-toned card appears above the wallet balances headed Accrued on-chain (not yet swept).

That money is not yours yet. A swap fee lands at a fee-recipient address the platform holds no key for; it becomes platform money only when you move it and the sweep observes the transfer and credits a wallet. Until then it is a claim, not a balance, which is why it is deliberately kept out of every figure above and rendered separately.

The card sums dexFeeAccrual rows whose sweepStatus is ACCRUED or SWEEP_SUBMITTED, grouped by token and chain. A token the platform could not price shows an em dash in the USD column rather than $0.00 — an unpriceable token is not a worthless one.

Installs without the Swap addon still receive the key. The summary route always returns pendingOnchainRevenue — an empty array when the addon's model is not present — because an absent key renders as a permanently loading card on the client. The card itself draws only when the array has rows, so those installs never see it.

Editing the books by hand

Three permissions exist beyond reading, and the screen exposes only one of them.

Create a profit row by hand.
Edit one.
Delete one.
Delete a selection.

The table on /admin/finance/profit is configured create-off, edit-off, delete-on. So deletion is a click; creating or editing a row means calling the API. All four are written to the admin audit trail under the module ADMIN_FIN.

adminProfit is not a soft-delete model — there is no deletedAt column and the table is configured non-paranoid. Delete is permanent, and there is no restore. The table's own delete action is the destructive one.

The manual-entry form's type dropdown offers fourteen of the model's twenty-two types: DEPOSIT, WITHDRAW, TRANSFER, BINARY_ORDER, EXCHANGE_ORDER, INVESTMENT, AI_INVESTMENT, FOREX_DEPOSIT, FOREX_WITHDRAW, FOREX_INVESTMENT, ICO_CONTRIBUTION, STAKING, P2P_TRADE and DEX_LISTING.

The omissions are the point. Of the three DEX types, only DEX_LISTING is offered:

  • A listing fee is legitimately hand-entered — you invoiced a project and booked the charge. Nothing observed it on chain and nothing needed to.
  • DEX_SWAP and DEX_LP_FEE are excluded and must not be added. Each is an observation of an on-chain fact — a swept fee transfer, or the fee half of a V3 collect() receipt. A hand-entered one is a fabricated realisation with no accrual and no pool event behind it, which makes the books unreconcilable against the chain.

Nothing is required by the create route itself: POST validates against a store schema that declares no required list at all. The model is the only gate — type, amount and currency are NOT NULL, and transactionId is explicitly nullable, widened so the platform-loss path can book a loss with no transaction to name. (The required list naming all four fields belongs to the update schema, and binds only PUT.)

Fill transactionId anyway on any row you enter by hand. It is the link back to the PLATFORM_FEE transaction the fee was taken from — the view dialog turns it into a link to /admin/finance/transaction/<id>, and it is the only field an operator ever navigates from.

When the screen says zero

Work down this list in order.

  1. Is there a Super Admin user? Check the backend log for the two [CRITICAL] lines above. If either appears, no fee since that moment was collected. Fix the role or the account, then wait five minutes for the cached lookup to expire.

  2. Are the fees configured at all? A fee of zero collects nothing and logs nothing — collectPlatformFee returns early on a non-positive amount. Check the fee settings for the flow you expected revenue from.

  3. Was the operation performed by the Super Admin? Fees are skipped for that account by design.

  4. Is the flow one that books at settlement? Deposits book their fee at approval, withdrawals at settlement. A queue full of PENDING rows has earned you nothing yet, and rejecting them never will.

  5. Are you reading the right half of the screen? The wallet cards are live balances; the breakdowns are booked history. A wallet you withdrew from shows a smaller balance while the booked total is unchanged — that is correct, not a discrepancy.

If revenue is present but the wallet is empty, you have simply spent it: the admin_profit row records that a fee was captured, and the wallet records what is left of it. They are not supposed to agree forever.