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

The Payment State Machine — Modelling Status and Handling Pending

October 27, 2026

TL;DR

Model payments as a state machine with an explicit list of allowed transitions, and reject or alert on anything else. Never just overwrite a status: record every change in an append-only payment_events table. Keep orders (your business object) separate from payments (attempts), since one order can have many attempts and occasionally more than one success. Map each gateway's statuses into your own states in one place. Handle out-of-order webhooks by checking transitions and re-fetching the gateway's current status. Handle pending with a schedule of checks, clear customer messages, holds that expire, and a defined path for late successes.

On a payments team I worked with, the orders table had a column called payment_status. Over three years, 14 different values had appeared in it: success, SUCCESS, paid, captured, completed, done, failed, failure, cancelled, canceled, pending, in_progress, refunded, and my favourite, success_but_refunded. Different developers, different gateways, different years.

Nobody could answer a simple question like “how many orders were paid last month” without a 40-line SQL query full of CASE statements. And worse, some orders were in states that made no sense: refunded with no refund record, failed with a captured payment attached.

The fix was not more careful coding. It was a proper state machine. This lesson shows how to design one.

Why a State Machine?

A payment moves through a small number of states, and only some moves make sense. A payment can go from authorized to captured. It can’t go from refunded back to created. If you write these rules down in one place and enforce them, a whole class of bugs disappears:

  • No impossible states.
  • Every status change is visible and auditable.
  • Out-of-order or duplicate events can’t corrupt your data.
  • Reports become simple queries.

The Payment States

Payment state machine diagram: created can go to pending, authorized, failed or captured; pending can go to failed or captured; authorized can go to captured or voided; failed can go to captured only on a late success; captured can go to partially refunded, refunded, or disputed; partially refunded can go to refunded; disputed can go to dispute won or dispute lost. Rules: terminal states never go back to captured except failed on late success, every transition is a row in payment events, and refunded amount must not exceed captured amount
State Meaning
created We created a gateway order; customer hasn’t paid yet
pending Customer tried to pay; outcome not known yet (common with UPI)
authorized Card approved and amount blocked; not yet charged
captured Money taken. This is “paid”
failed Payment did not go through
voided Authorisation released without capture
partially_refunded Some of the captured amount refunded
refunded Full captured amount refunded
disputed Customer raised a chargeback or dispute
dispute_won / dispute_lost Outcome of the dispute

Notice the special arrow from failed to captured. This is the late success case from Lesson 2. A payment the gateway reported as failed (or that you gave up on) can later turn out to be successful. Your state machine must allow this one “backwards” move, or you’ll reject real money.

Enforcing Transitions in Code

Write the allowed moves as data, in one place:

ALLOWED = {
    "created":            {"pending", "authorized", "captured", "failed"},
    "pending":            {"authorized", "captured", "failed"},
    "authorized":         {"captured", "voided", "failed"},
    "failed":             {"captured"},                       # late success only
    "captured":           {"partially_refunded", "refunded", "disputed"},
    "partially_refunded": {"partially_refunded", "refunded", "disputed"},
    "disputed":           {"dispute_won", "dispute_lost"},
    "dispute_won":        {"partially_refunded", "refunded"},
    "voided":             set(),
    "refunded":           set(),
    "dispute_lost":       set(),
}

class IllegalTransition(Exception): ...

Then a single function changes status. It locks the row, checks the move, writes the history, and updates the current status, all in one transaction:

def transition(conn, payment_id: int, to_state: str, *, source: str,
               source_event_id: str | None = None, details: dict | None = None) -> bool:
    with conn.transaction():
        p = conn.fetchrow("SELECT id, status FROM payments WHERE id=$1 FOR UPDATE", payment_id)
        frm = p["status"]

        if frm == to_state and to_state != "partially_refunded":
            return False                                   # duplicate event, nothing to do

        if to_state not in ALLOWED[frm]:
            conn.execute("""
                INSERT INTO payment_anomalies (payment_id, from_state, to_state, source, details)
                VALUES ($1, $2, $3, $4, $5)
            """, payment_id, frm, to_state, source, json.dumps(details or {}))
            alert("illegal payment transition", payment_id=payment_id, frm=frm, to=to_state)
            raise IllegalTransition(f"{frm} -> {to_state}")

        conn.execute("""
            INSERT INTO payment_events (payment_id, from_state, to_state, source,
                                        source_event_id, details)
            VALUES ($1, $2, $3, $4, $5, $6)
        """, payment_id, frm, to_state, source, source_event_id, json.dumps(details or {}))
        conn.execute("UPDATE payments SET status=$2, updated_at=now() WHERE id=$1",
                     payment_id, to_state)
    return True

And the history table:

CREATE TABLE payment_events (
    id               BIGSERIAL PRIMARY KEY,
    payment_id       BIGINT NOT NULL REFERENCES payments(id),
    from_state       TEXT NOT NULL,
    to_state         TEXT NOT NULL,
    source           TEXT NOT NULL,       -- 'webhook', 'status_poll', 'checkout_verify', 'ops:ravi'
    source_event_id  TEXT,                -- gateway event id, if any
    details          JSONB,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (payment_id, source_event_id)
);

This table is append-only. You never update or delete rows in it. When someone asks “why is this payment refunded?”, you look at its events: created by checkout at 9:41, pending from status poll at 9:42, captured by webhook at 9:44, refunded by ops:priya at 11:15 with reason “customer cancelled”. Support, finance and auditors will love you for this.

A few details worth noting:

  • Illegal transitions are recorded, not silently ignored. If the gateway sends something you didn’t expect, you want to know. Often it means a new gateway behaviour, or a bug in your code.
  • Same-state transitions are no-ops, which makes duplicate events harmless (except partially_refunded, which can happen several times with different amounts).
  • source includes humans. Manual changes by ops staff go through the same function with source="ops:<name>", never through direct SQL.

Amounts are part of the state too

Refunds come in amounts, not just states. Keep amounts on the payment and check them in the same transaction:

ALTER TABLE payments
  ADD COLUMN captured_paise BIGINT NOT NULL DEFAULT 0,
  ADD COLUMN refunded_paise BIGINT NOT NULL DEFAULT 0,
  ADD CONSTRAINT refund_not_more_than_captured CHECK (refunded_paise <= captured_paise);

That CHECK constraint is a safety net. Even if some code path has a bug, the database will refuse to record refunding more than you captured.

Orders and Payments Are Different Things

This is the most common modelling mistake I see: putting payment status directly on the order, with one payment_id column.

One order ord_812 for 1,299 rupees with status paid has four payment attempts: pay_A by UPI failed with wrong PIN, pay_B by UPI pending then failed, pay_C by card captured and wins, and pay_D by UPI captured late as an extra payment which is auto-refunded because the order was already paid by pay_C. Order status comes from the first captured attempt, decided under a row lock

An order is your business object: what the customer is buying. A payment is an attempt to pay for it. One order can have:

  • Several failed attempts (wrong PIN, then insufficient balance, then success by card).
  • A pending attempt that resolves later.
  • Occasionally two successful attempts (Lesson 2), where one must be refunded.

So keep them in separate tables, as we did in Lesson 4, and give the order its own, simpler state machine:

ORDER_ALLOWED = {
    "pending_payment":  {"payment_pending", "paid", "cancelled", "expired"},
    "payment_pending":  {"paid", "failed_final", "pending_payment"},   # back to pending_payment = customer can retry
    "failed_final":     {"paid", "cancelled"},                          # 'paid' = late success
    "expired":          {"paid"},                                       # late success on an expired order
    "paid":             {"fulfilled", "cancelled_after_payment"},
    "fulfilled":        {"returned"},
    "cancelled":        set(),
}

The order moves to paid when the first payment for it is captured (the conditional update from Lesson 5). Every later capture for the same order goes to the extra-payment refund path. The order doesn’t care about the details of each attempt; that’s what the payments table is for.

Your business code (shipping, enrolment, access to a course) should look at the order status only. Your finance code (ledger, reconciliation) looks at payments.

Mapping Gateway Statuses to Yours

Each gateway has its own status names and quirks. One might say captured, another SUCCESS, a third PAID with a separate settled flag. If these leak into your database, you get the 14-status mess from my story.

Write one mapping function per gateway, and only your own states go past it:

RAZORPAY_MAP = {
    "created": "created",
    "authorized": "authorized",
    "captured": "captured",
    "failed": "failed",
    "refunded": "refunded",       # full refund; partial refunds come from refund entities
}

CASHFREE_MAP = {
    "ACTIVE": "created",
    "PENDING": "pending",
    "SUCCESS": "captured",
    "FAILED": "failed",
    "USER_DROPPED": "failed",
    "CANCELLED": "failed",
}

def to_internal_status(gateway: str, raw_status: str) -> str:
    mapping = {"razorpay": RAZORPAY_MAP, "cashfree": CASHFREE_MAP}[gateway]
    if raw_status not in mapping:
        alert("unknown gateway status", gateway=gateway, status=raw_status)
        return "pending"            # safest default: don't fail, don't fulfil, keep checking
    return mapping[raw_status]

(Status names above are examples. Check your gateway’s current docs; they do add new ones.)

Notice the default for an unknown status: pending. Not failed (you might be rejecting real money) and not captured (you might ship without payment). Pending means “keep checking”, which is the safe choice when you don’t know.

This mapping layer also makes it much easier to add a second gateway later, which many companies do for redundancy or better pricing.

Out-of-Order Events

Webhooks can arrive in any order. Some examples:

  • refund.processed arrives before payment.captured (because the capture webhook failed the first time and is being retried).
  • payment.failed for attempt A arrives after payment.captured for attempt B on the same order.
  • An old, delayed webhook says pending after you’ve already marked the payment captured.

Two rules handle almost all of this:

  1. The state machine rejects moves that don’t make sense. captured → pending isn’t allowed, so the old pending event is dropped (and recorded as an anomaly if you want visibility).
  2. When in doubt, fetch the current status from the gateway. The webhook tells you something changed. The status API tells you what is true now. For important transitions, many teams always re-fetch before applying.

For the refund-before-capture case, if the event refers to a payment you don’t have in captured state yet, put the event back in the queue with a delay, or first fetch and apply the payment’s current status, then process the refund.

Handling Pending Over Time

We talked about pending in Lesson 2. Here’s the full plan as a timeline:

Timeline for handling a pending payment: created at 0, status checks at 30 seconds, 2 minutes, 10 minutes with a confirming message, 30 minutes, 1 hour with an optional stock hold release, give up and mark failed final at 4 hours, and a late webhook success at 6 hours. During pending the order holds stock and tells the customer not to pay again. On late success after give-up, fulfil if possible otherwise auto-refund, always with a ledger entry and customer message

The exact times are up to you and your business. Some guidelines:

  • Check frequently at first. Most pending payments resolve within a few minutes.
  • Decide how long to hold stock. For limited stock (flash sales, event tickets), you may need to release it sooner and handle late success with a refund. For normal products, holding for an hour is fine.
  • “Give up” is not the end. failed_final just means you stop actively checking and tell the customer. You still accept late webhooks, and you can run a daily reconciliation that catches any capture you missed (Lesson 9).
  • Show the status to support. When a customer calls, the support agent should see: payment pending since 9:42, last checked 10:12, next check 10:42. That answers 80% of calls in one look.

Abandoned orders

Lots of orders never get paid: the customer closed the app, changed their mind. You’ll want a job that expires them (say, after 24 hours) to release stock and clean up dashboards.

Be careful: an expired order can still receive a late payment. That’s why expired → paid is allowed in the order state machine above. If you can’t fulfil it any more, the late payment goes to the auto-refund path. Never make an order state that silently swallows payments.

Making It Visible

A state machine is only useful if people can see it. Build a simple internal page for each order showing:

  • The order’s state history.
  • Each payment attempt with its state history (from payment_events).
  • Webhooks received for it (from webhook_events).
  • Refunds and their status.
  • Ledger entries (next lesson).

This one page will save your support and engineering teams hours every week.

Pitfalls to Avoid

  1. Free-text status columns. Define a fixed set of states and allowed transitions in code.
  2. Overwriting status without history. Record every change in an append-only events table.
  3. Payment status on the order. Separate orders from payment attempts.
  4. Not allowing failed → captured. Late successes are real; rejecting them means keeping money you shouldn’t.
  5. Gateway status names in your database. Map them to your own states in one function per gateway.
  6. Defaulting unknown statuses to failed or success. Default to pending and alert.
  7. Trusting webhook order. Validate transitions and re-fetch the current status when in doubt.
  8. Manual SQL fixes by ops. All changes go through the same transition function, with the person’s name as the source.
  9. Expiry jobs that swallow late payments. Expired orders must still accept payments, then fulfil or refund.
  10. No internal timeline view. Support will keep asking engineers to “check the DB”.

Quick Recap

  • Payments are a state machine: explicit states, explicit allowed moves, one function to change state.
  • Every change is recorded in an append-only history. Illegal moves are recorded and alerted.
  • Orders and payments are separate. The first capture pays the order; extra captures are refunded.
  • Map each gateway’s statuses into yours; unknown means pending.
  • Handle out-of-order events with transition checks and fresh status fetches.
  • Plan pending over time, with holds, messages, a give-up point, and a late-success path.

We now know when money moves and how to track its status. Next lesson is about how much and where: designing a double-entry ledger, so that every paisa in your system can be explained.