arrow_backBACK TO PAYMENTS ENGINEERING IN INDIA — FROM UPI TO LEDGERS
Lesson 08Payments Engineering in India — From UPI to Ledgers9 min read

Refunds, Chargebacks and Disputes

October 27, 2026

TL;DR

A refund is a small payment in reverse, with its own states and failure cases. Calculate the refundable amount as captured minus refunded minus refunds in progress, under a row lock, so two people can't over-refund. Call the gateway with an idempotent reference, record the gateway refund id and the bank reference (ARN for cards, RRN for UPI), and post ledger entries. Refund to the original method by default; use verified payouts for COD. Chargebacks come from the customer's bank with a deadline: track them as cases, gather evidence automatically, and record held, won and lost amounts in the ledger. Watch your dispute rate; networks penalise high ratios.

A support lead at an online electronics store once told me, “Refunds are where our money leaks.” She was right, and it wasn’t fraud by customers. It was their own system.

Two support agents could open the same ticket and both click “Refund ₹14,999”. It happened about twice a month. A refund job retried after a timeout and created a second refund at the gateway. A partial refund of ₹2,000 on a ₹14,999 order was later followed by a “full refund” of ₹14,999, because the full refund button didn’t subtract what was already refunded. Each of these was a real loss, and the gateway happily processed every one of them, because to the gateway they all looked like valid requests.

This lesson covers money going back to customers: refunds you start, and chargebacks and disputes that customers start through their banks.

Refunds Are Payments in Reverse

A refund has the same problems as a payment: it can be slow, it can fail, it can be sent twice, and the customer doesn’t see it immediately.

Refund flow in five steps: request from customer, ops or automation with reason and amount; policy checks that the amount is within the refundable amount and approval above limits; gateway refund API call with an idempotency or reference id returning pending; processed with a bank reference such as ARN or RRN and a refund processed webhook; and reaching the customer's bank, usually fast for UPI and 5 to 7 working days for cards. Panels list what to store for every refund and what to tell the customer at each stage

The stages:

  1. Request: from the customer (cancel order), an ops agent, or an automatic job (extra payment, late success, stock-out).
  2. Policy checks: is this amount refundable? Does it need approval?
  3. Gateway refund API: you create the refund. Status is usually pending or created at first.
  4. Processed: the gateway and banks process it. You get a webhook, and a bank reference number:
    • For cards, an ARN (Acquirer Reference Number).
    • For UPI, an RRN (Retrieval Reference Number) or UTR.
  5. Customer’s bank: the money reaches the customer’s account. UPI refunds are usually quick; card refunds can take 5-7 working days, sometimes more, depending on the issuing bank.

The refund table

CREATE TABLE refunds (
    id                 BIGSERIAL PRIMARY KEY,
    refund_ref         TEXT NOT NULL UNIQUE,      -- your idempotent reference
    payment_id         BIGINT NOT NULL REFERENCES payments(id),
    order_id           TEXT NOT NULL,
    amount_paise       BIGINT NOT NULL CHECK (amount_paise > 0),
    reason_code        TEXT NOT NULL,             -- 'customer_cancel', 'extra_payment', 'damaged', ...
    reason_note        TEXT,
    requested_by       TEXT NOT NULL,             -- 'customer', 'ops:priya', 'system:extra_capture'
    speed              TEXT NOT NULL DEFAULT 'normal',   -- 'normal' | 'instant'
    status             TEXT NOT NULL,             -- 'requested','pending','processed','failed'
    gateway_refund_id  TEXT UNIQUE,
    bank_reference     TEXT,                      -- ARN / RRN once known
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

Never Over-Refund

The refundable amount for a payment is:

refundable = captured - already_refunded - refunds_in_progress

That last term is the one people forget. If a ₹2,000 refund is pending at the gateway, it’s not yet “refunded”, but it will be. If you don’t count it, a second refund can take the total above what was captured.

And the check must happen under a lock, so two agents clicking at the same time can’t both pass it:

class RefundTooLarge(Exception): ...

def request_refund(conn, *, payment_id: int, amount: int, reason_code: str,
                   requested_by: str, refund_ref: str, note: str = "") -> int:
    with conn.transaction():
        p = conn.fetchrow("""
            SELECT id, order_id, status, captured_paise, refunded_paise
              FROM payments WHERE id = $1 FOR UPDATE
        """, payment_id)
        if p["status"] not in ("captured", "partially_refunded", "dispute_won"):
            raise ValueError(f"payment in status {p['status']} can't be refunded")

        in_progress = conn.fetchval("""
            SELECT COALESCE(SUM(amount_paise), 0) FROM refunds
             WHERE payment_id = $1 AND status IN ('requested', 'pending')
        """, payment_id)
        refundable = p["captured_paise"] - p["refunded_paise"] - in_progress
        if amount > refundable:
            raise RefundTooLarge(f"asked {amount}, refundable {refundable}")

        row = conn.fetchrow("""
            INSERT INTO refunds (refund_ref, payment_id, order_id, amount_paise,
                                 reason_code, reason_note, requested_by, status)
            VALUES ($1, $2, $3, $4, $5, $6, $7, 'requested')
            ON CONFLICT (refund_ref) DO NOTHING
            RETURNING id
        """, refund_ref, payment_id, p["order_id"], amount, reason_code, note, requested_by)

    if row is None:
        return conn.fetchval("SELECT id FROM refunds WHERE refund_ref=$1", refund_ref)
    queue.enqueue("submit_refund_to_gateway", refund_id=row["id"])
    return row["id"]

Two things make this safe:

  • FOR UPDATE on the payment row means two refund requests for the same payment run one after the other, and the second one sees the first one’s requested row.
  • refund_ref is unique. For an ops click, the frontend sends a UUID created when the refund dialog opened. For automatic refunds, derive it from the business reason, like extra_capture:{payment_id}. A retry with the same reference never creates a second refund.

Submitting to the gateway

def submit_refund_to_gateway(refund_id: int):
    r = db.refunds.get(refund_id)
    if r.status != "requested":
        return                                          # already submitted

    payment = db.payments.get(r.payment_id)
    try:
        g = pg.create_refund(
            payment_id=payment.gateway_payment_id,
            amount=r.amount_paise,
            speed=r.speed,
            notes={"refund_ref": r.refund_ref, "order_id": r.order_id},
            idempotency_key=r.refund_ref,               # if your gateway supports it
        )
    except TimeoutError:
        # Don't blindly retry. Did the gateway create it?
        g = find_gateway_refund_by_ref(payment.gateway_payment_id, r.refund_ref)
        if g is None:
            raise                                       # safe to retry later (job retry)

    db.refunds.update(refund_id, status="pending", gateway_refund_id=g["id"])
    ledger.on_refund_created(r, g)                      # see ledger section below

Look at the timeout handling. If the request timed out, we look for a refund with our reference in its notes before trying again. This is the “check before retrying a write” rule from Lesson 5, applied to a real case.

Refund Timelines and Customer Messages

The biggest source of refund complaints isn’t failed refunds. It’s customers not knowing where their money is.

  • Tell them the realistic time. “5-7 working days for cards” sets the right expectation. “Refund processed!” on day one, followed by nothing in their account, causes calls.
  • Share the bank reference (ARN for cards, RRN/UTR for UPI) as soon as you have it. When a customer calls their bank and says “I didn’t get my refund”, the bank can trace it with this number. Without it, the bank says “we don’t see anything”, and the customer blames you.
  • RBI’s turnaround time rules apply to failed transactions and certain refunds, with compensation if banks are late. Your support team should know the current rules for each payment method.

Refund Options

Refund to the original payment method by default. Other options exist for specific cases.

Four refund options: to the original method, the default, via the gateway refund API taking from same day to 7 days; instant refund, a paid feature where the gateway pays out right away for an extra fee; store credit or wallet, by clear customer choice, instant and tracked as a liability; and bank or UPI payout for cash on delivery orders and failed refunds, where the customer gives a UPI ID or account which is validated before paying. Cash on delivery orders have no original method, so collect and verify details then send a payout

Instant refunds. Many gateways offer instant refunds for an extra fee: they pay the customer immediately via IMPS/UPI and recover the money through the normal refund later. Useful for high-value customers or when trust matters a lot.

Store credit. Instant and keeps money in your business, which is why many apps push it. It must be a genuine, clear choice by the customer, not the default hidden behind small print. In the ledger, store credit is a liability (Lesson 7).

COD refunds. Cash-on-delivery orders (still very common in India) have no original method to refund to. You collect the customer’s UPI ID or bank account and send a payout. Always validate the account first: most payout providers offer a penny-drop or name-match check. Money sent to a wrong account is very hard to get back.

Failed refunds. Sometimes a refund fails: the card was closed, the bank account was closed, the UPI handle no longer exists. Your system must notice the refund.failed webhook and move the case to the payout path, asking the customer for current details. A failed refund nobody follows up is a customer who never got their money.

Refund Fraud and Controls

Refunds are money going out, so they need controls:

  • Refund only to the source. Never let anyone (customer or agent) refund a card payment to “this other UPI ID, please”. The only exception is the verified COD and failed-refund payout path above, with strong checks.
  • Limits per agent. An agent can refund up to ₹5,000 alone; above that needs a second person (maker-checker). Thresholds depend on your business.
  • Reason codes are required. And track refunds by reason, by agent, by customer. One customer with 14 “item not received” refunds in two months is a pattern worth looking at.
  • Audit everything. Who requested, who approved, when, why. This is also what auditors will ask for.

Chargebacks: When the Customer Goes to Their Bank

A chargeback happens when a cardholder disputes a charge with their bank instead of asking you. Common reasons:

  • “I didn’t make this payment” (fraud, or a family member used the card)
  • “I didn’t receive the item”
  • “The item was not as described”
  • “I was charged twice”
  • “I cancelled the subscription but was still charged”
Chargeback lifecycle across five lanes: the customer says they did not get it or it was not them, the issuer bank raises a dispute, the network passes a chargeback, the gateway notifies you and holds the amount, you accept or send evidence, the gateway submits it, and the issuer decides. Panels: the deadline is often 7 to 10 days to respond, winning evidence includes delivery proof, OTP or 3DS logs, usage logs, invoice and chat history, and if won the held amount is released, if lost the amount and fee are gone

What happens:

  1. The customer complains to their bank.
  2. The bank raises a dispute through the card network.
  3. Your gateway notifies you (dashboard, email, webhook) and usually holds or debits the disputed amount from your settlement.
  4. You have a deadline (often around a week; check your gateway’s notice) to either accept the chargeback or contest it with evidence.
  5. The issuing bank decides. Some cases go through more rounds.
  6. If you win, the held amount comes back. If you lose, you lose the amount, and often a chargeback fee too.

If you miss the deadline, you lose by default. Many companies lose chargebacks they could have won simply because the notice email went to a shared inbox nobody watched.

Build chargebacks as cases

Treat each chargeback as a case with an owner and a due date:

CREATE TABLE disputes (
    id                 BIGSERIAL PRIMARY KEY,
    gateway_dispute_id TEXT NOT NULL UNIQUE,
    payment_id         BIGINT NOT NULL REFERENCES payments(id),
    amount_paise       BIGINT NOT NULL,
    reason_code        TEXT NOT NULL,
    phase              TEXT NOT NULL,     -- 'open','under_review','won','lost','accepted'
    respond_by         TIMESTAMPTZ NOT NULL,
    owner              TEXT,
    evidence           JSONB,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • When the dispute webhook arrives, create the case, move the payment to disputed (Lesson 6), and alert the owner team with the deadline.
  • Collect evidence automatically. Your system already has most of it: the order, invoice, delivery proof (courier tracking with delivery timestamp, OTP-based delivery confirmation, photo), 3DS authentication result, IP and device details, customer’s usage logs for digital products, chat history. Build a function that assembles this into a PDF or the gateway’s format. Manual evidence collection is why teams don’t bother contesting.
  • Escalate as the deadline approaches. Alerts at 3 days, 1 day.

Some cases you should just accept: genuine duplicate charges, undelivered items, real fraud. Contesting them wastes time and annoys customers and banks.

Watch your dispute rate

Card networks monitor merchants’ chargeback ratios. Merchants with high ratios can be put into monitoring programs with extra fees, and in bad cases lose the ability to accept cards. Track chargebacks as a percentage of card transactions, by month, and investigate any rise. A sudden jump often means a fraud attack (stolen cards being used on your site), which Lesson 11 covers.

UPI Disputes

UPI has its own dispute system. Customers can raise complaints directly in their UPI app (“money debited, merchant didn’t receive”, “I paid but didn’t get the product”). These go through NPCI’s online dispute resolution system, called UDIR (Unified Dispute and Issue Resolution), to the banks and then to your gateway.

For you, this mainly means:

  • Your gateway will forward UPI complaints with a deadline, similar to chargebacks.
  • Many UPI complaints are really “pending” or “debited but failed” cases from Lesson 2. If your system handles those well (auto-refunds for failed orders, clear status for pending), you’ll get far fewer.
  • Respond quickly with the transaction status and, if you refunded, the refund reference.

The Ledger Side

Every refund and dispute movement goes into the ledger (Lesson 7):

Refund created at gateway (gateway will deduct it from settlement):

Dr sales_returns                 +30000
Cr gateway_receivable:razorpay   -30000

Refund failed (reverse it, and owe the customer until you pay out):

Dr gateway_receivable:razorpay   +30000
Cr customer_refunds_payable      -30000

Chargeback opened, amount held:

Dr disputes_held                 +149900
Cr gateway_receivable:razorpay   -149900

Chargeback won (released):

Dr gateway_receivable:razorpay   +149900
Cr disputes_held                 -149900

Chargeback lost (plus a fee):

Dr chargeback_losses (expense)   +149900
Cr disputes_held                 -149900

Dr chargeback_fees (expense)     +XXX
Cr gateway_receivable:razorpay   -XXX

With these entries, at any moment you can answer: how much is currently held in disputes, how much did we lose to chargebacks this quarter, and how much refund money are we still holding for customers.

Pitfalls to Avoid

  1. Forgetting in-progress refunds when calculating the refundable amount.
  2. No lock on the payment during the refund check. Two clicks, two refunds.
  3. Refund retries without a reference. Use an idempotent refund_ref and check the gateway before retrying a timed-out call.
  4. “Refund processed” on day one. Tell customers the real timeline and give them the ARN or RRN.
  5. Ignoring failed refunds. Watch the webhook and move to a verified payout.
  6. Refunds to a different account than the source. Only through the verified COD / failed-refund payout path.
  7. No approval limits or reason codes. Use maker-checker above a threshold and audit everything.
  8. Chargeback notices to an unwatched inbox. Webhooks, cases, owners, deadlines and alerts.
  9. Manual evidence collection. Automate it from data you already have.
  10. Not tracking your dispute rate. High ratios bring penalties; a sudden rise often means fraud.

Quick Recap

  • Refunds have states, failures and delays like payments. Store them with references and history.
  • Refundable = captured − refunded − in progress, checked under a lock, with idempotent references.
  • Give customers realistic timelines and the bank reference number.
  • Original method by default; verified payouts for COD and failed refunds.
  • Chargebacks and UPI disputes are cases with deadlines and evidence. Track your dispute rate.
  • Every refund and dispute movement has ledger entries.

Next lesson: settlement and reconciliation. Your records, the gateway’s records, and the bank’s records will never match on their own. Here’s how to make them match every single day.