A UPI payment moving from a phone through a gateway into a settlement ledger, with a thin fee slice split off before the merchant bank account

For six years I never had to build a fee line for UPI. Every card integration I have shipped carries a fee column in its ledger; every UPI one carried a zero, and most teams eventually dropped the column to tidy the schema. If the reporting is accurate, that tidy-up is about to cost somebody a bad month-end.

I will say my position up front. Most merchants will never compute this fee, because their gateway deducts it. The teams that must build real fee logic are PSPs, aggregators and platforms that settle to sub-merchants. Everyone else needs a place to store the fee and a reconciliation check that understands it. I would not build a full engine unless I sit on the settling side of the flow.

The first sections explain the concept; the later ones cover the design and code. Payment engineers can jump to Part 4.

What I am relying on, and what I have not seen

My inputs are press coverage of NPCI's 3 November 2026 submission to the Supreme Court and the RBI's 5 November 2026 cyber-resilience Master Directions for non-bank payment system operators. The affidavit itself I have not read. The Rs 2,000 threshold and the 0.4% ceiling are the reported numbers; confirm slabs and the effective date against primary documents before shipping. Coverage of the Vyapaar Saksham incentive scheme contradicted itself, so it stays out of every calculation below. It is also the reason the code keeps each number in configuration rather than constants.

Part 1: The plain-language version

UPI (Unified Payments Interface) is India's instant bank-to-bank payment system, used through QR scans or phone-number handles. NPCI (National Payments Corporation of India) is the non-profit entity that operates it.

MDR (Merchant Discount Rate) is the cut a merchant gives up on each electronic payment. Cards make the idea familiar: pay Rs 1,000 by card and the shop may receive about Rs 980, the remainder being split between the card network and the two banks. Cards have typically sat around 1.8-2%, depending on category and card type, which is why the proposed ceiling of 0.4% is a fifth of that.

UPI has charged merchants nothing since 2020. Small shops had no reason to refuse it, which is a big part of why it spread so fast. The running cost did not vanish, though; banks and the operator carried servers, fraud tooling and support, partly offset by government incentives.

As reported, NPCI told the court a revised MDR framework is needed for the system to stay financially sustainable. The proposal is narrow:

  • It applies only to P2M payments, i.e. person-to-merchant.
  • P2P, person-to-person, stays free.
  • It applies only above Rs 2,000 per transaction.
  • It is capped at up to 0.4%.
  • Merchants cannot pass it on to customers.

A Rs 1,500 grocery bill is untouched. A Rs 10,000 electronics bill at the full 0.4% costs the merchant Rs 40, and the customer still pays Rs 10,000.

Scale is what makes it matter: UPI has been running at well over 20 billion transactions a month, so even a fraction of them above the threshold adds up to a real revenue line. This would be the first time the rail has carried an explicit price.

Part 2: Why this is hard even though the rule sounds simple

The rule fits in five bullets, but each one conceals an engineering choice.

"Only person-to-merchant." The UPI protocol carries merchant category information, but not every payment arrives labelled cleanly. A neighbourhood tea stall may collect on a personal QR code. A freelancer may use a personal handle for business. Whether a payment is P2M depends on how the receiver is registered in your system, and you may have to treat the registration as the source of truth instead of the payer's app.

"Above Rs 2,000." Is that strictly above, or at or above? Is it measured on the transaction amount or on the order total when an order is split across payments? Does the fee apply to the entire amount or only to the portion above Rs 2,000? Reporting so far does not settle these questions, so I model them as policy options. Card pricing is the closest precedent: interchange is applied to the whole transaction amount, not an excess over a slab, and slab edges are defined per category in the network's own rules. My bet is the same here: full amount, strictly above the threshold, measured per transaction rather than per order. I would still keep the other combinations switchable, since a split cart paying Rs 1,200 twice is exactly the case someone will try to game.

"Up to 0.4%." The words "up to" mean the effective rate may vary by merchant category or by agreement. Your engine needs a rate lookup, not a global constant.

"Cannot be passed on." This is a checkout rule. If your platform has ever offered a "convenience fee" line for certain payment methods, that logic now becomes a compliance risk for UPI. You need a technical guard, not just a policy document.

Refunds and reversals. This is the case I would settle before any other, because it is where fee logic goes wrong most often. If a Rs 10,000 payment is refunded, is the Rs 40 returned to the merchant? Card networks usually return interchange on a full reversal, but nothing I have read says what UPI will do. Model it as a policy flag, and post the fee reversal as its own ledger line linked to the original, so a partial refund can prorate it. Dispute outcomes through NPCI's UDIR process need the same treatment.

On a payment integration I worked on earlier, fee rules ended up in three places: a spreadsheet finance maintained, a controller constant and a database trigger. Nobody planned that; each was a quick fix at the time. Within a few months they disagreed on edge cases, and we spent a painful stretch working out which of the three the bank's numbers actually matched. That is why I now insist on one fee engine, one source of truth and one audit trail. My wider background is in the experience section.

Part 3: The money flow, step by step

Follow a UPI merchant payment and note where the fee enters.

  1. The customer approves the payment in their UPI app. The customer's bank debits the full amount, with no deduction.
  2. NPCI routes the instruction and the merchant's acquiring side receives a credit notification. The acquirer is the bank or provider that onboards the merchant.
  3. Your payment backend, whether you are a PSP (payment service provider) or a merchant platform, records a successful collection for the gross amount.
  4. At settlement time, the money that reaches the merchant is the gross amount minus the MDR, and possibly minus tax on that fee. Taxes on fees are another reason to keep the fee as its own record.
  5. Reconciliation compares what your ledger expects against what the bank statement and the NPCI settlement file actually show.

Step 1 is identical to today: the customer sees the same debit. Everything new sits on the merchant side, in steps 3 to 5, so this is back-office work rather than anything a user would notice. Note too who does the deducting. If you collect through a payment aggregator or gateway, it takes the MDR before paying you out, and your system only needs to mirror what it was told. Settlement cycles are typically T+1 or similar, and the aggregator's settlement report, not your own estimate, is the authority on what was deducted. Only if you are the settling party do you compute the fee yourself.

A ledger entry for a Rs 10,000 payment should end up as three lines instead of one:

Account Debit Credit
Customer collection (gross) 10,000.00
Merchant payable 9,960.00
MDR income / expense 40.00

A single net number is not enough. If you store only Rs 9,960, you cannot later prove to the merchant or an auditor why it was not Rs 10,000.

Part 4: Architecture of a fee engine

For anyone who does settle, this is the design I would use: four small components, deliberately kept apart.

Classifier. Takes the payment event and decides P2M or P2P, with a reason string. The reason matters when a merchant disputes a charge six months later.

Policy resolver. Given the merchant, category and date, returns the active fee policy: threshold, rate, whether the fee applies to the full amount or the excess, rounding mode, and an effective-from date. Policies are versioned, never edited in place.

Calculator. A pure function. Amount plus policy in, fee out. No database calls, no clock reads. Pure functions are cheap to test with thousands of edge cases.

Ledger writer. Writes the gross, fee and net lines atomically with the policy version that produced them.

The pure calculator is what lets me sleep. In the market-data systems I run, I use the same discipline to make results replayable, which I wrote about in deterministic market data replay. For fees the payoff is the same: given the same inputs and the same policy version, you get the same paise every time, even years later.

Money rules I do not bend

  • Use BigDecimal, or integer paise. Never double.
  • Pick one rounding mode, record it in the policy, and apply it once per transaction.
  • Store the fee at the time of the transaction. Do not recompute it later from the current policy, or old data will change when rates change.
  • Make the write idempotent. UPI callbacks get retried, and a duplicate callback must not charge a duplicate fee. The pattern I use is the one in my post on the transactional outbox and idempotent consumers.

Part 5: A Spring Boot sketch of the fee engine

Treat this as a sketch to adapt, not a drop-in. Every threshold, rate and mode comes from a policy record, because the final slabs and effective date are not mine to hard-code.

public enum PayeeType { P2P, P2M } public enum FeeBasis { FULL_AMOUNT, EXCESS_OVER_THRESHOLD } public record FeePolicy( String version, BigDecimal threshold, // e.g. 2000.00, from config boolean thresholdInclusive, // is exactly 2000.00 charged? BigDecimal rate, // e.g. 0.004 for 0.4%, or lower per category FeeBasis basis, RoundingMode rounding, LocalDate effectiveFrom) {} public record FeeResult(BigDecimal fee, String policyVersion, String reason) {} public final class MdrCalculator { public FeeResult compute(PayeeType type, BigDecimal amount, FeePolicy p) { if (type == PayeeType.P2P) { return new FeeResult(BigDecimal.ZERO.setScale(2), p.version(), "P2P_EXEMPT"); } int cmp = amount.compareTo(p.threshold()); boolean charged = p.thresholdInclusive() ? cmp >= 0 : cmp > 0; if (!charged) { return new FeeResult(BigDecimal.ZERO.setScale(2), p.version(), "BELOW_THRESHOLD"); } BigDecimal base = p.basis() == FeeBasis.FULL_AMOUNT ? amount : amount.subtract(p.threshold()); BigDecimal fee = base.multiply(p.rate()).setScale(2, p.rounding()); return new FeeResult(fee, p.version(), "P2M_ABOVE_THRESHOLD"); } } 

The service around it loads the policy that was active on the transaction date, not today's, and posts the ledger lines in one database transaction:

@Service class SettlementPostingService { private final PolicyRepository policies; private final LedgerRepository ledger; private final MdrCalculator calculator = new MdrCalculator(); SettlementPostingService(PolicyRepository policies, LedgerRepository ledger) { this.policies = policies; this.ledger = ledger; } @Transactional public void post(UpiCollection c) { if (ledger.existsByUpiRef(c.upiRef())) { return; // retried callback, already posted } FeePolicy policy = policies.activeOn(c.merchantCategory(), c.txnDate()); FeeResult fee = calculator.compute(c.payeeType(), c.amount(), policy); BigDecimal net = c.amount().subtract(fee.fee()); ledger.save(LedgerEntry.collection(c.upiRef(), c.amount())); ledger.save(LedgerEntry.mdr(c.upiRef(), fee.fee(), fee.policyVersion(), fee.reason())); ledger.save(LedgerEntry.merchantPayable(c.upiRef(), net)); } } 

The unique constraint on upiRef in the database is the real idempotency guard. The existsBy check is a cheap shortcut, and the constraint catches the race when two callbacks arrive at once.

Blocking surcharges at checkout

The no-pass-through rule needs a guard at the point where the cart total is built. I prefer to make it structurally impossible instead of relying on code review:

public CheckoutQuote quote(Cart cart, PaymentMethod method) { BigDecimal base = cart.total(); BigDecimal methodFee = feeSchedule.customerFeeFor(method, base); if (method == PaymentMethod.UPI && methodFee.signum() != 0) { // The merchant bears MDR. A customer-facing fee on UPI must never exist. audit.record("UPI_SURCHARGE_BLOCKED", cart.id(), methodFee); methodFee = BigDecimal.ZERO; } return new CheckoutQuote(base, methodFee, base.add(methodFee)); } 

Silently zeroing the fee is a choice. In a stricter setup, I would throw and fail the quote so the misconfiguration is noticed in testing rather than papered over in production. Either way, the audit event is the part a regulator will ask for.

Part 6: Reconciliation, where fee bugs actually show up

Calculating the fee correctly is half the job. The other half is proving that what you calculated matches what actually arrived. A reconciliation check compares three sources: your ledger, the acquirer's settlement file, and the bank credit.

The check I would run nightly, per merchant per settlement cycle:

public ReconReport reconcile(String merchantId, LocalDate cycle, SettlementFile file) { BigDecimal expectedGross = ledger.sumCollections(merchantId, cycle); BigDecimal expectedFee = ledger.sumMdr(merchantId, cycle); BigDecimal expectedNet = expectedGross.subtract(expectedFee); BigDecimal reportedFee = file.totalFee(merchantId); BigDecimal reportedNet = file.totalNet(merchantId); List<Break> breaks = new ArrayList<>(); if (expectedFee.compareTo(reportedFee) != 0) { breaks.add(Break.feeMismatch(expectedFee, reportedFee)); } if (expectedNet.compareTo(reportedNet) != 0) { breaks.add(Break.netMismatch(expectedNet, reportedNet)); } // Transaction-level diff only when totals disagree, to keep the job cheap if (!breaks.isEmpty()) { breaks.addAll(txnLevelDiff(merchantId, cycle, file)); } return new ReconReport(merchantId, cycle, breaks); } 

Raw settlement data from NPCI and the acquirer arrives as files, and the column layout differs by bank, so write one adapter per source that maps into a common SettlementFile shape rather than scattering parsing through the job. Two practical points from experience. First, compare fee totals separately from net totals. If only the net differs, you cannot tell whether the cause is a wrong fee, a missing transaction or a timing cut-off. Splitting them narrows the search immediately. Second, a difference of a few paise across thousands of rows is almost always rounding. If the acquirer rounds per transaction and you round per batch, you will drift. Match their rule exactly and record it in the policy.

The threshold boundary deserves its own test. I would add test cases at 1,999.99, 2,000.00 and 2,000.01, with both inclusive and exclusive settings. That is where an off-by-one in a comparison quietly turns into a recurring mismatch on exactly the payments people round to, like a Rs 2,000 bill.

If you want to see how I approach large volumes with predictable behaviour, the projects section and my write-up on high-throughput Java under virtual threads cover the throughput side of reconciliation jobs.

Part 7: The second half of the news, RBI's cyber-resilience Master Directions

The same week brought a second development that affects the same operators. On 5 November 2026 the RBI issued cyber-resilience Master Directions for non-bank payment system operators, often shortened to PSOs. These are the non-bank companies that run wallets, payment gateways and similar services under RBI authorisation.

I have not memorised every clause, and you should read the text itself, but the direction of travel matches what I have seen in other regulated sectors: stronger security controls, incident reporting, and evidence that you can prove what happened. For a fee engine this matters in concrete ways:

  • Audit trails. Every fee decision should be reconstructible: which policy version, which inputs, which result. The reason and policyVersion fields above exist for this purpose.
  • Change control. A rate change is a financial change and a security-relevant one. Policies should be versioned, approved by two people and logged.
  • Access control. Who can edit fee policies, and who can view merchant settlement data? Keep those roles separate.
  • Resilience. If the fee engine is down, do you fail closed and hold settlement, or fail open and post later? Decide deliberately and document it.

I wrote about producing this kind of evidence automatically in DORA and NIS2 policy-as-code. The regulators differ, but the engineering habit is shared: make compliance a by-product of how the system runs, not a quarterly scramble.

What I would do this week

Rules like these tend to arrive with short notice and a long tail of clarifications. Rather than wait for every detail, I would prepare the parts that will not change:

  1. Put a fee field in your ledger. Gross, fee and net as separate lines, even if the fee is zero today.
  2. Tag every payee as P2P or P2M with a stored reason, and decide who owns corrections when the tag is wrong.
  3. Move all thresholds, rates and rounding rules into versioned policy records. No constants in code.
  4. Add the surcharge guard to checkout and alert on any attempt.
  5. Add the fee-versus-net split to nightly reconciliation.
  6. Read the primary texts. Confirm the slabs, the basis of calculation, the effective date and any carve-outs against the NPCI affidavit and the RBI Master Directions, and treat unconfirmed items such as the Vyapaar Saksham scheme as unknown until a primary source says otherwise.

My honest view is that the 0.4% itself is the least interesting number. What matters is that India's flagship public digital rail now has a price signal in it, and every system that touched it assumed that price was zero. Systems built on a hidden assumption break in surprising places, usually in a reconciliation report at month end.

If you are planning this change and want a second pair of eyes on a fee engine, ledger design or reconciliation job, reach me through the contact section. Before that, open your current UPI ledger schema today and check whether it has a place to store a fee next to the gross amount. If it does not, that is your first ticket.

Related Articles