Every fee the platform charges, and where each one is set
The six places fees are configured, the order they compose in, whether each is added on top or taken out, and how every collected fee is booked to admin profit.
"Why did the customer receive less than they asked for" is a weekly ticket, and the answer is never in one place. Fees on this platform are configured in six unrelated screens, stored in four different shapes, and compose in an order that is not obvious — and for the same nominal fee, a deposit and a withdrawal can behave in opposite directions.
This page is the map. Read the arithmetic section before you change any number, because "2.9% + 0.30" means something different on each of these screens.
The six places
| # | What | Where | Stored as |
|---|---|---|---|
| 1 | Deposit gateway fees and limits | Finance → Payment Systems → Payment Gateways → a gateway → the money tab | JSON on deposit_gateway, either a scalar or a per-currency map |
| 2 | Deposit method fees and limits | Finance → Payment Systems → Payment Methods | Plain DOUBLE columns on deposit_method |
| 3 | Withdrawal method fees and limits | Finance → Withdrawal Management → Withdrawal Methods | Plain DOUBLE columns on withdraw_method |
| 4 | Transfer fee, transfer spread, spot withdraw fee | System → Platform Settings → Wallet → Fees | Text rows in settings |
| 5 | Spot per-currency fee and per-network fee | exchangeCurrency.fee and the exchange's own network metadata |
Imported from the provider — not typed by you |
| 6 | Ecosystem token withdrawal fee | Ecosystem admin → Trading → Tokens | JSON { min, percentage } on ecosystem_token |
There is a seventh input with no screen at all: the withdrawChainFee
setting, which decides who bears the blockchain network fee on a spot
withdrawal. It has its own section below.
The two shapes of "fee"
Every fee on this platform is one of two things, and the two behave differently:
- Added on top. The customer asks for 100 and 103.20 leaves their balance, or they are billed 103.20 at the checkout. They receive exactly what they asked for.
- Taken out. The customer asks for 100, 100 leaves their balance, and 96.80 arrives. They receive less than they asked for.
Which one applies is decided by the route, not by the setting. There is no switch.
| Money movement | Platform fee is | Customer asks for 100, gets |
|---|---|---|
| Hosted-checkout deposit (Stripe, PayPal, dLocal, …) | added on top — billed as a second "Tax" line item | 100 credited, 103.20 charged to the card |
| Manual deposit against a method or gateway, approved by an admin | taken out | 96.80 credited |
| Fiat withdrawal via a withdrawal method | taken out | 100 debited, 96.80 paid out |
| Spot (exchange) withdrawal | added on top | 102.90 debited, 100 sent — minus the network fee, see below |
| Ecosystem (on-chain) withdrawal | added on top | 100 + fee debited, 100 sent |
| Wallet-to-wallet transfer | taken out | 100 debited, 99 credited to the destination |
POST /api/finance/deposit/fiat/<alias> — the hosted checkout — bills the
customer the deposit plus the fee as two separate line items, then credits
the deposit. POST /api/finance/deposit/fiat — the manual/approval path, which
also accepts a gateway id — records the requested amount with the fee attached
and credits amount minus fee when an admin approves it.
Both read the same fixedFee and percentageFee off the same row. So a
customer who deposits 100 through the checkout gets 100, and a customer who
deposits 100 through the manual form gets 96.80. If your support desk sees both
answers for the same gateway, this is why.
The arithmetic, once
Every percentage-plus-fixed pair on this platform composes the same way:
fee = (gross × percentageFee) / 100 + fixedFeePercentage on the gross first, then the fixed fee added. Not the other way round, and the percentage is never applied to the amount after the fixed fee.
Worked example, on a gateway at 2.9% + 0.30 with a 100 USD deposit:
| Step | Figure |
|---|---|
| Gross | 100.00 |
| Percentage part — 2.9% of 100 | 2.90 |
| Fixed fee | 0.30 |
| Total fee | 3.20 |
| Hosted checkout: card is charged | 103.20, wallet credited 100.00 |
| Manual path: wallet credited | 96.80, adminProfit booked 3.20 |
The money tab on the gateway editor renders exactly this preview, per currency, against an amount you type. Use it — it is computed from the same expression the routes use.
On the deducting paths, a deposit is entirely consumed by the fee at
fixedFee / (1 − percentageFee/100) — 0.31 on the 2.9% + 0.30 example above.
Approving such a deposit used to credit nothing and book the whole fee as
profit, on a COMPLETED transaction. The approval route now refuses it with
"Cannot approve: the fee … is not less than the deposit amount … so the
customer would receive nothing", leaving the row PENDING so you can fix the
fee. That refusal is the symptom of a minimum set below the break-even. Set the
method's or gateway's minAmount comfortably above it.
1. Deposit gateways
Finance → Payment Systems → Payment Gateways, open a gateway, money tab.
Editing needs edit.deposit.gateway.
Four fields — fixedFee, percentageFee, minAmount, maxAmount — and each
is a JSON column that holds either a single number for every currency, or a
map keyed by currency code:
"percentageFee": 2.9
"percentageFee": { "USD": 2.9, "EUR": 2.4, "GBP": 2.4 }Both shapes are real and both are in the shipped seed data, which is why the editor makes you choose rather than inferring it. When the column holds a map, a currency missing from the map resolves to 0 — the accessor returns null for an absent or unparseable entry and the caller defaults to zero. Adding a currency to a gateway therefore silently gives it a free deposit until you add it to each fee map as well; the editor flags the missing keys.
Values are coerced with Number() on read, because a hand-edited row or a form
post can store the entry as a string — and a string there would concatenate
instead of adding.
The shared manual path enforces both. Among the hosted-checkout routes, PayFast, Paystack, Mollie, Paysafe, PayU, Paytm and AuthorizeNet check them; Stripe, PayPal, Adyen, dLocal, 2Checkout and Klarna do not read them at all. On that second group the limits are advisory — the vendor's own minimum is what actually applies.
Two of the enforcing routes also apply a fallback when the currency is
missing from the map, rather than treating an absent limit as no limit: Paytm
falls back to 1.00 / 10,000,000.00 and AuthorizeNet to 1 / 10,000. So on
those two an unconfigured currency is still capped, at a figure you did not
choose.
Do not rely on a gateway maxAmount as a risk control.
alias is the join between the row and its payment handler, and it is
deliberately not editable: renaming it saves cleanly and then 404s every payment
through that gateway. See
Connecting a fiat deposit gateway.
2 and 3. Deposit and withdrawal methods
Finance → Payment Systems → Payment Methods (create|edit|delete.deposit.method)
and Finance → Withdrawal Management → Withdrawal Methods
(create|edit|delete.withdraw.method).
Both carry the same four numeric columns as plain DOUBLEs — no per-currency
map, no JSON:
| Field | Notes |
|---|---|
fixedFee |
In the transaction's currency. Validated non-negative on the deposit method |
percentageFee |
Composes as above: percentage on the gross, then the fixed fee |
minAmount |
Enforced when greater than zero |
maxAmount |
Enforced when greater than zero |
A withdrawal method also has processingTime, which is display-only.
A fiat withdrawal debits the full requested amount and pays out
amount − fee. The refusal messages name the method, so
"Minimum withdrawal for Bank Transfer is 50 USD" points straight at the row to
edit.
4. The three settings
System → Platform Settings → Wallet → Fees. All three are range controls
clamped by the UI to 0–10%, and all three are stored as text.
Two of the three are Super Admin only to save: walletTransferFee and
walletTransferSpread are on the protected-settings list, along with the legacy
walletTransferFeePercentage name. spotWithdrawFee is not on that list —
any admin holding the settings edit permission can change the spot withdrawal
percentage, on a route that moves real money off the platform.
spotWithdrawFee is additive, and clamped
The spot withdrawal route computes:
combinedPercentageFee = exchangeCurrency.fee + spotWithdrawFee
percentageFeeAmount = totalWithdrawAmount × combinedPercentageFee / 100exchangeCurrency.fee is the currency's own percentage, imported from the
exchange provider — you do not type it. So a 1% setting on a coin the provider
already charges 0.5% for withdraws at 1.5%, not 1%.
The setting is read through CacheManager.toNumber with a 0–100 clamp, not
parseFloat, so a hand-edited row cannot charge an absurd fee. If the
combination still produces a non-finite result — a broken currency row, a broken
precision — the route refuses the withdrawal with "Withdrawal fees are
misconfigured for this currency" rather than processing it with the guards
open.
walletTransferFee, and the key the UI never writes
The transfer engine reads walletTransferFee and falls back to
walletTransferFeePercentage — a legacy key the settings screen has never
written. The engine used to read only the legacy key, so the configured fee
was silently never charged. If you find a walletTransferFeePercentage row in
your settings table, it was set by hand or inherited from an old install; it
still takes effect when the live key is absent.
The value is clamped to a maximum of 100, and anything non-finite or zero-or-below is treated as no fee.
walletTransferSpread is not a fee
It is a margin against the mid-market rate, applied only on a cross-currency transfer:
rate = midRate × (100 − spread) / 100midRate is fromPriceUSD / toPriceUSD. The spread is clamped to 0–100, so a
misconfigured value can never zero out or invert the rate. It is applied by the
same function that serves the quote the customer was shown, so the displayed
rate and the settled rate cannot disagree.
The transfer fee is taken first, and the conversion runs on what is left:
credited = (amount − transferFee) × rateNeither figure is a separately itemised charge to the customer; the spread shows up only as a slightly worse rate.
5. Who bears the network fee
The spot withdrawal route reads the network fee from the exchange's own
per-chain metadata (networks[chain].fee, falling back to
networks[chain].fees.withdraw) and then:
withdrawChainFee |
Effect |
|---|---|
"true" |
The platform absorbs it. externalFeeAmount is 0 and the provider is asked to send the gross amount, so the customer receives exactly what they asked for and the network fee comes out of your exchange balance |
"false" (default) |
It is deducted from what the customer receives. They are debited the amount plus your percentage fee, and the address receives the amount minus the network fee |
withdrawChainFee appears in the shipped defaults map and is read by the spot
withdrawal route, but there is no entry for it in the settings screen
definitions — so no admin, of any role, can change it from any page in the
product. The only way to create or flip the row is the settings PUT endpoint
directly, or a database write.
It is also not on the protected-settings list, so it does not need a Super
Admin — it needs an API call. Until the row exists, the platform behaves as
false: the customer bears the network fee.
The Super Admin exemption below does not cover this. Network fees apply to everyone, including you.
6. Ecosystem token withdrawals
Ecosystem admin → Trading → Tokens (/admin/ecosystem/token), the token's
fee object:
"fee": { "percentage": 0.5, "min": 0.001 }The charge is a floor, not a sum:
withdrawalFee = max(amount × percentage / 100, min)and the user is debited amount + withdrawalFee, on every chain. The address
receives the full amount.
The gas cost of the on-chain send is paid by the platform's master or gas wallet and recovered through this token-denominated fee. Network gas is never added to a token debit — an earlier version added a TRX gas estimate to a USDT withdrawal, which is where the phantom "required = amount + 28" refusals came from. So if this fee is set to zero on a chain with real gas costs, every withdrawal on it runs at a loss to your master wallet.
Super Admin pays no platform fee
Every internal fee path checks isSuperAdmin(userId) and zeroes the platform's
cut:
- fiat deposits credited through the shared processor,
- fiat withdrawals,
- the internal percentage on spot withdrawals,
- and
collectPlatformFeeitself, which skips outright when the acting user is the Super Admin, because charging and crediting the same wallet is a circular no-op that inflates the profit report.
Network fees still apply. On a spot withdrawal the external fee is computed before the exemption and is untouched by it.
The Super Admin is resolved as the oldest user holding the "Super Admin"
role, cached for five minutes. If that role, or a user holding it, does not
exist, every platform fee on the install is dropped on the floor — logged as
[CRITICAL] Dropped platform fee — no Super Admin configured, with the amount,
currency, type and reference so it can be reconciled later. Fee collection never
throws and never rolls back the customer's operation.
Where a collected fee goes
Every fee the platform keeps is booked by one helper, collectPlatformFee,
which writes two records:
| Record | Detail |
|---|---|
| A wallet credit to the Super Admin | operationType PLATFORM_FEE (or ECO_FEE for an ecosystem wallet, which still lands as a PLATFORM_FEE transaction), referenceId <reference>_fee, idempotency key platform_fee_<TYPE>_<reference> |
An adminProfit row |
Linked to that transaction, typed DEPOSIT, WITHDRAW, TRANSFER, INVESTMENT, P2P_TRADE, DEX_SWAP and so on |
adminProfit is what Finance → Revenue Analytics (/admin/finance/profit,
access.admin.profit) reports. A fee that was never booked there is invisible
to that screen even though the money moved.
Fees are booked when the money actually moves, not when it is requested
This is the rule that stops rejected payouts minting revenue:
-
A deposit's fee is booked at approval, in the deposit-log update route — not when the customer submits the request. Booking it up front credited the Super Admin and wrote an
adminProfitrow for deposits that were later rejected or never approved, so the platform reported profit on money it never received. -
A withdrawal's fee is booked at settlement, by
collectWithdrawalFeeOnSettlement. It readsmetadata.feeoff the withdrawal transaction (falling back totransaction.fee), finds the wallet, and callscollectPlatformFeewith typeWITHDRAWand the transaction id as the reference.Fiat and spot withdrawals used to collect at submission, while the payout was still
PENDING. When an admin later rejected it, the refund handed the customer back the full debit — amount and fee — and nothing reversed the Super Admin credit. Every rejected withdrawal minted the fee out of nothing. -
Transfer fees are booked inline, inside the transfer's own database transaction, because a transfer completes or does not; there is no pending state to be rejected out of.
collectWithdrawalFeeOnSettlement runs on every settlement door — the admin
approve endpoint, the withdraw-log update, the TransFi webhook, and the spot
route once the exchange accepts the payout. The idempotency key inside
collectPlatformFee is derived from the transaction id, so a retry, or an admin
completing a row twice, cannot double-collect.
The coercion trap: why these values are read defensively
The settings PUT used to run every submitted value through a validator that
could not describe the settings body. Every settings key was an undeclared
position, so the validator treated the value as untyped and coerced it: "1"
became boolean true, "0" became false. The handler then stringified that —
so an operator who saved a percentage of 1, the shipped default for
spotWithdrawFee and walletTransferFee, has the literal text "true" in that
row today.
parseFloat("true") is NaN, and NaN does not fail loudly. Measured on a live
install, on the spot withdrawal route:
combinedPercentageFee -> NaN
percentageFeeAmount -> NaN
totalDeductionAmount -> NaN
availableBalance < NaN -> false insufficient-funds guard skipped
newBalance < 0 -> false negative-balance guard skippedA cosmetic settings row silently disabled both balance guards on a money route.
The writer is fixed — that route now skips schema validation and does its own key, length and protected-key checks. The rows are still wrong on every install that saved settings before the fix.
Three defences are now in place, and they are why the numbers above are read the way they are:
CacheManager.toNumberusesNumber(), notparseFloat— becauseparseFloat("12abc")is 12, which turns a typo into a plausible-looking fee. Anything not a finite number yields the caller's stated fallback, an empty string means unset rather than zero, andmin/maxclamp a hand-edited row to the range the UI would have enforced.- The spot withdrawal route refuses outright when the computed fee is not finite.
- The deposit-gateway accessors coerce with
Number()and resolve anything unparseable to null, so the caller applies its default rather than concatenating a string into an amount.
Fix the rows. pnpm --filter backend repair:settings reports which numeric
settings hold booleans; --apply writes them back. It reads which keys are
numbers from the settings screen definition, maps "true" to "1" and
"false" to "0", leaves switch-typed keys alone, and writes through the cache
manager so no restart is needed. See
Repair, reconcile and diagnostic scripts.
A checklist before you change a fee
-
Decide which of the six places owns it. A per-gateway charge is not the same knob as
spotWithdrawFee, and neither is the currency's own fee. -
Check the shape. On a gateway, is the column a scalar or a per-currency map? A map missing your currency charges zero.
-
Check the direction. Added on top or taken out — the table at the top of this page. Announcing "a 1% withdrawal fee" means two different things on a fiat method and on a spot withdrawal.
-
Check the break-even against the minimum.
fixedFee / (1 − pct/100). A minimum below it produces deposits that credit nothing, or approvals that are refused. -
Run one transaction end to end with a real, small amount, and read the resulting rows: the customer's
transaction, the Super Admin'sPLATFORM_FEEcredit and theadminProfitentry. If the third is missing, the fee moved but your revenue reporting will never see it. -
Check the Super Admin exists — the role, and a user holding it. Without one, every platform fee on the install is dropped and only the backend log says so.
Related
- Connecting a fiat deposit gateway — the readiness console, credentials and the alias join.
- Withdrawal approval and two-factor policy — who decides a payout leaves, which is when its fee is booked.
- Settings reference — every switch on the settings screen.
- Repair, reconcile and diagnostic scripts —
repair:settings. - When money does not move — triage when a fee is not the problem.