TL;DR — If a gateway takes 2.36% of whatever it processes and you want a wallet to land on exactly ₹100, charging ₹102.36 credits ₹99.94. The fee is a percentage of the total charged, not of the amount you keep, so you solve for the payable —
charged = credit / (1 − rate)— instead of adding the rate back. Then two smaller things decide whether the ledger balances: derive the last component of the split as a remainder so the parts always sum to the total, and round the payable up, because every rounding error in the other direction is money the platform quietly absorbs. The credited amount is the client’s input; the amount charged is never the client’s to send.
Key takeaways
- A percentage fee is levied on the amount processed. Adding it to the amount you want to keep leaves a fee on the addition, uncovered, every single time.
charged = credit / (1 − effectiveRate)is the whole formula. It is a division, not a multiplication, and the difference is about 5.6 paise per ₹100 at a 2.36% rate — always in the same direction.- Tax on a payment fee is a fee on the fee. At 2% plus 18% on that 2%, the effective rate is 2.36% of the total, not 20% of anything.
- Compute the fee, compute the tax on it, then make the last component the remainder of the total. Rounding two components independently produces a split that misses the total by a paisa at some amounts, which surfaces months later as an unexplainable report.
- The request says how much balance the employer wants. The server decides what card gets charged. Reversing those two is how somebody pays ₹1 and credits ₹10,000.
The employer pays ₹102.36 and the wallet lands at ₹99.94
The feature is a pre-paid corporate wallet. An employer subsidises its employees’ meals — an employee orders ₹1,000 of food, the employer covers ₹300, the employee pays ₹700, and the restaurant still receives ₹1,000, because the restaurant never agreed to a discount. That part already existed. What did not exist was any mechanism for collecting the ₹300: the platform paid the restaurant in full out of its own pocket and chased the employer for the gap afterwards.
So the employer now tops up a wallet first, and every subsidised order spends money the platform is already holding. Which means the platform is suddenly in the business of charging cards, and a payment gateway takes a cut of everything it processes.
The rate card is the common one: 2% of the transaction, plus 18% tax on that 2%. The employer bears it — they are buying spendable balance, and the price of ₹100 of balance is whatever it costs to move ₹100. Reasonable. Then somebody has to write the function that turns “I want ₹100 in my wallet” into an amount to charge, and the obvious version of that function is wrong.
Before the details, the framing I owe you: this module is built and shipped to a staging branch, not carrying live payment volume. The arithmetic below is covered by offline tests and I have re-run every number in this post. What I do not have is production data — no top-up volume, no failure rates — so there are none quoted here.
Why adding the rate back always lands short
The gateway does not charge a fee on the amount you want to keep. It charges a fee on the amount that passes through it, and the amount that passes through it includes the fee.
what you charge the employer (the gateway's fee is a % of THIS)
┌──────────────────────────────────────────────┐
│ credit to the wallet │ fee + tax │
└──────────────────────────────────────────────┘
▲
add 2.36% of the credit ──────────┘ covers the fee on the credit,
but not the fee on itself
Add 2.36% and you have created a larger transaction, which attracts a larger fee, and the extra you added covers only the fee on the original amount. Cover that gap and the patch attracts its own fee. It is a geometric series, and the series converges exactly where the algebra says it does:
charged − fee(charged) = credit
charged − rate × charged = credit
charged × (1 − rate) = credit
charged = credit / (1 − rate)
One line of code, and it is a division. Here is what the two versions do at a 2.36% effective rate, assuming the gateway rounds its own cut to the nearest paisa:
| Balance wanted | Naive: credit × 1.0236 | Lands at | Short by | Correct: credit ÷ 0.9764 | Lands at |
|---|---|---|---|---|---|
| ₹100 | ₹102.36 | ₹99.94 | ₹0.06 | ₹102.42 | ₹100.00 |
| ₹500 | ₹511.80 | ₹499.72 | ₹0.28 | ₹512.09 | ₹500.00 |
| ₹5,000 | ₹5,118.00 | ₹4,997.22 | ₹2.78 | ₹5,120.86 | ₹5,000.01 |
| ₹25,000 | ₹25,590.00 | ₹24,986.08 | ₹13.92 | ₹25,604.27 | ₹25,000.01 |
| ₹1,00,000 | ₹1,02,360.00 | ₹99,944.30 | ₹55.70 | ₹1,02,417.05 | ₹1,00,000.01 |
The shortfall is 0.0557% of every top-up, it never lands in the employer’s favour, and it is the platform that makes up the difference — because the wallet has to hold what the employer was told it holds. Nothing about that is catastrophic at any single amount. That is exactly what makes it survive: it is never large enough for anyone to open a ticket about, and it runs on every top-up forever.
I checked the corrected version by brute force rather than by reading it, over every credit amount from 1 paisa to ₹20,000: the three components sum to the total for all two million of them, and the wallet is never credited less than the requested amount even under the pessimistic assumption that the gateway rounds its own cut up. The naive version comes up short for 1,999,103 of those two million amounts.
The tax on the fee is a fee, and it is not 20%
The gateway’s cut is 2%. Tax is charged at 18% — on the fee, not on the transaction. So the effective rate is:
effectiveRate = 0.02 × (1 + 0.18) = 0.0236
Worth writing out because the two wrong readings are both easy to produce. Treating the tax as applying to the top-up gives you something near 20% and an employer who thinks you are skimming. Ignoring the tax entirely gives you 2%, and a shortfall almost seven times the one in the table above.
There is a genuinely commercial question sitting next to this one — whether the employer can reclaim that tax as input credit — and I did not model it. What I did instead was store the fee and the tax on it as separate columns on the top-up record rather than one blended number, so whoever needs to answer that question later has the parts. Storing the blend would have made the decision for them, badly.
Derive the last component, don’t recompute it
The split has three parts — the credit, the base fee, the tax on the fee — and they have to add up to what the card was charged. The way to guarantee that is to compute two of them and take the third as what is left over.
// Illustrative. Everything in paise, as integers, so no intermediate step can
// produce a fraction of the smallest unit a card can actually be charged.
const FEE_BPS = 200; // 2% of the amount processed
const TAX_BPS = 1800; // 18%, levied on the fee — not on the top-up
// 200 bps grossed up by 18% tax = 236 bps of whatever is charged.
const EFFECTIVE_BPS = (FEE_BPS * (10_000 + TAX_BPS)) / 10_000; // 236
function chargeFor(creditPaise) {
// charged = credit / (1 − rate), kept in integers, rounded UP so the
// leftover fraction of a paisa is never the platform's to absorb.
const charged = Math.ceil((creditPaise * 10_000) / (10_000 - EFFECTIVE_BPS));
const feeTotal = charged - creditPaise;
const fee = Math.round((feeTotal * 10_000) / (10_000 + TAX_BPS));
const tax = feeTotal - fee; // the remainder, never recomputed
return { creditPaise, fee, tax, charged };
}
The last line of arithmetic is the one I would defend hardest, and it looks like nothing.
Computing tax = fee × 0.18 independently and rounding both components is the obvious
alternative, and at some amounts the two rounded numbers plus the credit miss the charged
total by a paisa. That paisa does not go anywhere. It sits in a report that does not
reconcile, six months later, with nobody able to say why — the class of bug where the
arithmetic is individually correct in three places and collectively wrong.
The test that matters is not a fixed example, it is the invariant across a range:
// The two properties worth asserting, over every amount you'd plausibly see.
for (let credit = 1; credit <= 2_000_000; credit++) {
const c = chargeFor(credit);
assert.equal(c.fee + c.tax + c.creditPaise, c.charged);
// Assume the counterparty rounds its own cut in its own favour.
const worstCaseFee = Math.ceil((c.charged * EFFECTIVE_BPS) / 10_000);
assert.ok(c.charged - worstCaseFee >= credit);
}
That second assertion is the one that caught my first attempt. I wrote the additive version,
and no amount of staring at credit * 1.0236 told me anything — it looks like what a fee
adjustment is supposed to look like. A loop that asserts the wallet is never short found it
on the first amount it tried.
Three ways a paisa disappears
- Floating point.
0.1 + 0.2 !== 0.3in every language with IEEE 754 doubles, and MDN spells out why. Money is either an integer count of minor units or an exact decimal type — in Postgres,NUMERIC, neverdouble precision. Payment providers store amounts as integers for the same reason; Stripe’s API takes amounts in the smallest currency unit, and having the boundary of your system agree with theirs removes a conversion. - Rounding the payable down.
roundsplits the difference between you and the employer;ceilputs the leftover fraction on the side of the party that asked for a round number. Sub-paisa either way, but the error has a direction, and it is always the same direction. - Assuming two decimal places. ISO 4217 gives the Japanese yen zero minor units and the Kuwaiti dinar three. A hardcoded 100 is fine right up to the first day you accept a second currency, which is also the day nobody remembers this function exists. Martin Fowler’s Money pattern is the tidy version of the fix: an amount and its currency travel together, and the currency knows its own scale.
The client renders the breakdown. The server decides the charge.
The checkout screen shows the employer the whole split before they pay: ₹100 of balance, ₹2.05 fee, ₹0.37 tax, ₹102.42 charged. Which means the formula exists in two places, and that is a real cost I accepted rather than solved.
What is not negotiable is which copy is authoritative. The request from the browser carries one number — how much balance the employer wants. The server computes the payable, creates the gateway order with its own figure, and records the split. If the amount to charge came from the client, the interesting request is not a malicious one, it is the honest one from a stale tab; and the malicious one credits ₹10,000 for ₹1. This is the same rule as moving order creation behind the payment webhook: the client says what it wants, the server decides what happens, and anything about money that the client can edit is a number you do not have.
The duplication is still a duplication. The honest fix is a quote endpoint — the client asks the server what ₹100 of balance costs and renders the answer, rather than reimplementing the formula in a second language where it can drift a paisa at a time. That is on the list below, not in the code.
The path where the fee does not exist
Some employers would rather move ₹1,00,000 by bank transfer than pay ₹2,417 to move it through a card network, and they are right. So there is a second path where an operator records a transfer that has already landed, and it deliberately has no gross-up at all: what the employer sent is what the wallet receives. It is recorded as a top-up like any other, so it rolls into the same totals and the same ledger rather than becoming a special case in every report.
online top-up ─────► gross-up ──┐
├──► one credit procedure ──► wallet balance
bank transfer ─────► as-is ─────┘ (locks the row, credits once)
The interesting part of that path is not the arithmetic, it is that the reference number is typed in by a human who may well submit the form twice. So the guard is a partial unique index on the organization plus the reference, not a query followed by an insert — the gap between checking and inserting is the bug, and this is the one place in the feature where the duplicate is a person rather than a retry. The general shape of that argument, and the three layers it took on the customer-facing side, are in the payment-as-source-of-truth piece; the wallet ledger itself is append-only for the same reason a stock ledger is — a balance you can edit is a balance nobody can audit.
What actually broke
I wrote the additive version first. Not as a typo — as a considered implementation, with a comment explaining that it covered the gateway’s fee. It shipped as far as the test suite and no further, because the invariant loop asserts the net credit is never short. Reviewing the line would not have caught it; the line reads correctly. Asserting the outcome caught it immediately, which is the argument for testing money code by property rather than by example.
The webhook endpoint exists and was never registered with the gateway. The design has three independent paths that can credit a top-up — the browser callback when the employer returns, the gateway’s server-to-server webhook, and a scheduled reconciliation sweep — all funnelling into one procedure that locks the top-up row so whichever arrives first wins and the others are no-ops. That is the right shape, and reconciliation as the safety net is a pattern I trust. But the endpoint has not been switched on in the gateway’s dashboard, so in practice it is a two-path flow, and an employer who closes the tab mid-payment leaves a top-up pending until something else picks it up. A deployed endpoint is not a wired endpoint, and for webhooks the deploy includes a step in somebody else’s console. I wrote it into the module’s setup document as a required step rather than leaving it to be discovered, which is the least I could do and less than fixing it.
The payable is rounded, not ceilinged, in what actually shipped. The version in this post is the version I would defend; the version on the branch rounds to two decimals at each step. The exposure is a fraction of a paisa per top-up and the fix is one word, but I would rather state it than let you assume the code matches the article.
The concurrency guarantees are reasoned about, not tested. Four separate replay risks — two orders spending the last of a balance, a callback racing a webhook, a retried bulk transfer, a resubmitted bank reference — are each handled by a lock plus a unique index in a stored procedure. The offline suite covers the arithmetic and the validators. It does not touch a database, so it does not cover any of the four. They were reviewed and reasoned about, and that is the weakest part of the verification story on this feature. It is why the next module I built was tested against a scratch database instead.
FAQ
What is the gross-up formula for a payment gateway fee?
Divide, don’t add: charged = credit / (1 − effectiveRate). If you want ₹100 to land after
a 2.36% fee, you charge ₹102.42, not ₹102.36. The fee is a percentage of the total processed,
so the amount you add to cover it is itself subject to the fee.
Why is adding the fee percentage wrong? Because the addition increases the amount processed, and the gateway charges its percentage on that larger number. Adding 2.36% covers the fee on the original amount and leaves the fee on the 2.36% uncovered. At that rate the gap is about 5.6 paise per ₹100 and it always falls on whoever promised the round number.
Do I gross up the tax on the fee as well? Yes, but by grossing up the fee rate first, not by treating the tax as applying to the whole transaction. A 2% fee taxed at 18% is an effective 2.36% of the amount charged. Applying 18% to the top-up instead produces roughly 20% and an invoice nobody will accept.
Who should bear the gateway fee? It is a commercial decision, not an engineering one — but whichever way it goes, decide it once and put the arithmetic on the server. If the payer bears it, gross up the payable. If you bear it, credit the full amount and record the fee as your cost. What breaks is leaving it ambiguous, so that the number shown at checkout and the number in the ledger were computed by two different rules.
How do I stop a fee split from drifting by a paisa? Compute all but one component, and take the last one as the remainder of the total. If you compute the fee and the tax independently and round each, their sum can miss the charged total at some amounts. Then assert that the components sum to the total across a wide range of inputs rather than for one example.
Should money be a float, a decimal, or an integer?
Never a binary float. Integer minor units — paise, cents — are the safest representation in
application code, and an exact decimal type such as Postgres NUMERIC is right in the
database. Both make rounding an explicit decision at a known boundary instead of an
accumulating error nobody chose.
Can the client send the amount to be charged? No. The client sends what the user asked for — the balance they want, the item they picked — and the server computes every money figure from its own configuration. A client-supplied charge amount means anyone who can edit a request can decide what they pay, and a stale tab gets the same power by accident.
What if the payment succeeds but the callback never arrives? Assume it will happen and design three ways to credit: the browser callback, the gateway’s webhook, and a scheduled sweep that asks the gateway what it captured that you have no record of. Make all three call one routine that locks the payment row, so whichever arrives first credits and the rest are no-ops.
What I’d still improve
- Round the payable up, in one place, and add the worst-case-fee assertion to the suite so the direction of the error is pinned by a test rather than by a comment.
- Serve the breakdown from a quote endpoint instead of reimplementing the formula in the frontend. Two implementations of one piece of arithmetic will disagree eventually, and the disagreement will be a paisa, which is the hardest size of bug to get anyone to look at.
- Register the webhook, then delete the sentence in the setup document that apologises for it, and let the reconciliation sweep go back to being a safety net rather than a load-bearing path.
- Test the four races against a real database. Two connections, one balance, and an assertion that exactly one of them wins. None of the locking is exotic; that is not the same as it being verified.
- Carry the currency with the amount rather than assuming two decimal places, before rather than after there is a second currency to support.
The one idea to take away
A percentage fee applies to the total, so an amount that has to survive a fee is solved for, never adjusted upward.
Every mistake in this post is a version of forgetting which number the percentage attaches to. Adding the rate back attaches it to the credit. Recomputing the tax instead of taking the remainder attaches rounding to a component rather than to the total. Letting the client send the payable attaches the whole calculation to the least trustworthy party in the exchange. Write down what the gateway’s cut is a percentage of, and the arithmetic falls out — one division, rounded in the direction that does not cost you.
I write about backend reliability, money movement and multi-tenant data modelling from production work on food-tech and healthcare platforms. More on what I build and how I work, the hospital meal system whose backend I own, and a companion piece on why the payment event should create the order. If you are wiring up a wallet or a settlement flow and want a second pair of eyes on the arithmetic, get in touch.