Why Does GPay Sometimes Charge You Twice? I Designed a Payment System to Find Out.
N I R A N J A N Developing in Web3 and AI
On this page
The Tap That Terrified Me
The Duplicate Payment Problem
Idempotency — The Fix
The Vanishing ₹500 Problem
Transactions — All or Nothing
But What About Two Different Banks?
Two-Phase Commit — The Distributed Safety Net
Audit Logs — The Paper Trail
The Full Picture
The Takeaway
I was paying for chai at a street stall. Tapped GPay. Network was bad. App froze. Tapped again.
Two notifications. Two deductions. ₹60 gone instead of ₹30.
I got the refund eventually. But it got me thinking — how does a payment system that moves billions of rupees daily let something like that happen? And more importantly, how do you build one that doesn't?
So I designed it. Here's everything I figured out.
The Tap That Terrified Me
You tap send ₹500 to a friend. Network cuts out at the exact millisecond the request hits the server. You panic. Tap again.
Now there are two requests for the same payment sitting on GPay's servers.
Does ₹500 go twice? Does it go at all? How does the system even know these are the same payment?
This is the first problem every payment system has to solve — before anything else.
The Duplicate Payment Problem
In most systems, duplicate requests are annoying. In payments, they're catastrophic.
Double-like a tweet — nobody cares. Double-charge someone ₹500 — you've got a legal problem, a trust problem, and an angry customer.
The naive approach — just process every request that comes in — fails immediately. Networks are unreliable. Users panic and retry. Mobile apps have retry logic built in. Duplicates aren't edge cases. They're guaranteed to happen.
So how do you tell two identical requests apart?
Idempotency — The Fix
When you tap send, GPay generates a unique payment ID for that specific payment attempt — before even sending it to the bank.
First tap → Payment ID: PAY_xk291a → Server processes ✅
Second tap → Payment ID: PAY_xk291a → Server says "already done, ignore" ✅
Same ID. Same payment. Processed exactly once.
This is idempotency — no matter how many times you send the same request, the result is the same. One payment, not two.
The unique payment ID is what makes it work. Without it, the server has no way to recognize duplicates. With it, retries become completely safe — the server just deduplicates and moves on.
Idempotency is the first rule of payment systems. Everything else builds on top of it.
The Vanishing ₹500 Problem
Okay so duplicates are solved. But there's a scarier problem.
You send ₹500. The system needs to do two things:
Deduct ₹500 from your account
Add ₹500 to your friend's account
What if step 1 succeeds — money leaves your account — and then the server crashes before step 2 happens?
₹500 deducted. Nothing added. Server dead. Nobody home to trigger a refund.
The money just vanished.
Transactions — All or Nothing
The solution is a concept as old as databases themselves — a transaction.
A transaction means all steps succeed together or all steps fail together. Never a partial state.
BEGIN TRANSACTION
deduct ₹500 from your account
add ₹500 to friend's account
COMMIT
If anything fails between BEGIN and COMMIT — the whole thing rolls back. Your ₹500 returns automatically. Your friend gets nothing. Clean state. No money lost.
This property is called atomicity — the transaction is one atomic unit. It either completes fully or disappears entirely.
Simple. Elegant. Works perfectly — as long as everything lives in one database.
But What About Two Different Banks?
Here's where payments get genuinely hard.
When you pay someone on UPI, you're moving money between two completely separate banks — HDFC and SBI, running on their own servers, their own databases, completely isolated infrastructure.
A SQL transaction only works within one database. You can't run a single transaction across HDFC's database and SBI's database simultaneously. They don't share a connection.
So how do you guarantee atomicity across two separate systems that don't talk to each other directly?
Two-Phase Commit — The Distributed Safety Net
The solution: Two-Phase Commit (2PC).
Instead of one atomic transaction, you coordinate across both banks in two phases — with UPI acting as the coordinator in the middle.
Phase 1 — Prepare: UPI asks both banks: "Can you commit to your part of this transaction?"
UPI → HDFC: "Can you deduct ₹500 from this account?"
HDFC → UPI: "Yes, I've reserved it. Ready to commit." ✅
UPI → SBI: "Can you add ₹500 to this account?"
SBI → UPI: "Yes, I'm ready to commit." ✅
Both said yes. Nobody has moved money yet — they've just reserved and prepared.
Phase 2 — Commit: Both banks confirmed. UPI gives the signal: "Do it."
UPI → HDFC: "Commit." → HDFC deducts ₹500 ✅
UPI → SBI: "Commit." → SBI adds ₹500 ✅
Transaction complete. Money moved safely across two separate systems.
What if something goes wrong?
If either bank says "no" in Phase 1 — UPI sends a rollback signal to everyone. No money moves. Clean state.
If a bank crashes after saying "yes" in Phase 1 but before Phase 2 — UPI detects the timeout, triggers rollback across all participants. Money returns.
UPI Coordinator
↓
Phase 1: "Can you do it?"
HDFC → Yes | SBI → Yes
↓
Phase 2: "Do it!"
HDFC deducts | SBI adds
↓
Both confirm → Done ✅
If anyone fails → Rollback → Clean state ✅
Two-Phase Commit is why UPI works reliably across 300+ banks with different infrastructure. The coordinator handles everything. Individual banks just need to say yes or no.
Audit Logs — The Paper Trail
One more layer. Every payment event — initiated, prepared, committed, failed, rolled back — gets written permanently to an audit log.
Nobody can delete it. Nobody can edit it. It's an immutable record of exactly what happened, when, and why.
Three reasons this matters:
Transparency — every rupee is accounted for at every step.
Dispute resolution — when a customer calls saying "I was charged twice," the audit log shows the exact sequence of events. Was it a duplicate? A network retry? A genuine double charge? The log knows.
Regulatory compliance — financial systems are required by law to maintain transaction records. The audit log is that record.
The Full Picture
Here's the complete payment system end to end:
User taps send
↓
Generate unique Payment ID (idempotency key)
↓
Check if Payment ID already processed → Yes? Return cached result
↓ No
Two-Phase Commit across banks:
Phase 1: Both banks prepare and confirm
Phase 2: Both banks commit simultaneously
↓
If any failure → Rollback all participants
↓
Write every event to Audit Log (immutable)
↓
Return success to user
Four layers of safety:
Idempotency → no duplicate payments
2PC → no partial transactions across banks
Rollback → automatic recovery from crashes
Audit Log → permanent record of everything
The Takeaway
Payment systems look simple from the outside — money goes from A to B. Underneath they're solving some of the hardest problems in distributed systems.
Four things I now actually understand:
Idempotency — unique payment ID makes retries safe
Atomicity — all steps succeed or none do
Two-Phase Commit — coordinates atomicity across separate systems
Audit Log — immutable paper trail for every event
The scary chai stall incident? That was a missing idempotency key on a retry. A unique payment ID per tap would have caught it.
One concept, one bug fixed, ₹30 saved.
Next up — Fraud Detection System. Event streaming, ML pipelines, real-time anomaly detection. Gets more interesting from here.
Follow me on X @nirxnjxn7 where I build stuff, break stuff, and write about both.
More posts like this at nirxnjxn-tech.hashnode.dev
