DEV Community

Anastand
Anastand

Posted on Edited on

How I made FoodIO's Razorpay payments not charge twice

I'm building FoodIO, a food-ordering app, to learn backend work that has real consequences. It's Next.js (App Router), TypeScript, Postgres on Neon and Drizzle ORM. Customers pay with Razorpay, in test mode for now.

Payments scared me most. A bug here is not a broken button. It's a customer charged twice, or a customer who paid and got nothing.

My goal was "exactly-once". Then I realised that over a network you can't promise a message arrives exactly once. Requests get retried, users double-click, tabs close halfway. So I aimed for each step being safe to repeat (idempotent). If a step runs twice, the effect still happens once.

Below are the four places my flow could break, and the guard I added for each one. All code is from the real repo. At the end is what I got wrong and what is still missing.

The flow

 Browser                      FoodIO server (Next.js)                Postgres          Razorpay
 ───────                      ───────────────────────                ────────          ────────
 1. Pay ──{checkoutId, dish IDs, qty}──▶ read real prices ──────────▶ menu_items
                                         save order + items (1 tx) ─▶ food_orders  ◀── guard A: UNIQUE
                              2.         claim gateway-order creation ▶ UPDATE ... WHERE ... IS NULL ◀── guard B
                                         create Razorpay order ─────────────────────────────▶ orders.create
 3. popup ◀──── razorpay_order_id ───────┘
    user pays in popup ───────────────────────────────────────────────────────────────────▶ (card / UPI)
 4. {payment_id, signature} ───▶ check HMAC (saved order id) ◀── guard C
                                 fetch payment ◀──────────────────────────────────────────── payments.fetch
                                 1 tx: lock row, record attempt,  ▶ SELECT ... FOR UPDATE ◀── guard D
                                       PAID only if PENDING
Enter fullscreen mode Exit fullscreen mode

Four steps, and each one could go wrong:

  1. The browser lies about the price.
  2. A double-click or retry creates two orders.
  3. Two requests race and create two Razorpay orders for one food order.
  4. The browser says "paid", but the money never arrived.

1. I let the browser send what, not how much

Anything the browser sends can be edited in DevTools. If my server trusted a price field, someone could pay ₹1 for a biryani. So I didn't allow it.

So I made the request carry only dish IDs and quantities. The schema is a z.strictObject (lib/payments/order-schema.ts), so a request that adds a price field is rejected, not ignored. The server reads priceInPaise from menu_items and does the math itself.

I store money as an integer in paise. Floats can't store most decimals exactly (0.1 + 0.2 !== 0.3), and Razorpay wants paise anyway.

Then I added a layer I did not expect to need: the database checks the bill itself.

lib/db/schema/schema.ts:343-350

check(
  "food_orders_total_matches",
  sql`${table.amountInPaise} =
    ${table.subtotalInPaise}
    + ${table.deliveryFeeInPaise}
    + ${table.taxInPaise}
    - ${table.discountInPaise}`
),
Enter fullscreen mode Exit fullscreen mode

My code already computes this total. I added the CHECK for the cases where code gets skipped: a new route, a bug, a manual script. A CHECK constraint runs on every INSERT and UPDATE, whoever wrote it. For money, I treat the database as the last line of defence.

2. I made one checkout create one order (an idempotency key)

My first idea for double-clicks was to disable the button. That helps, but two tabs or a network retry still get through. So I moved the fix to the server.

I made the checkout ID one random UUID, kept in sessionStorage (lib/checkout/checkout-key.ts:15-30). It's created on the first "Pay" click (components/checkout/payment-checkout.tsx:61). The cart only clears it when items or the address change (components/cart/cart-provider.tsx). So every "Pay" for the same cart sends the same ID.

The table has UNIQUE(customer_id, checkout_request_id) (schema.ts:325-328), and I let the insert do nothing on a conflict:

lib/payments/save-order.ts:34-40

const [createOrder] = await tx
  .insert(foodOrders)
  .values({ customerId, ...orderData })
  .onConflictDoNothing({
    target: [foodOrders.customerId, foodOrders.checkoutRequestId],
  })
  .returning()
Enter fullscreen mode Exit fullscreen mode

If no row comes back, this checkout already has an order, so I load and return it. One edge case: the same ID arrives with different items. That's not a retry, it's a changed cart. I compare items, restaurant and address, and throw "Checkout details have changed" (save-order.ts:66-96).

That is the idempotency key in practice: the browser names the operation, and the database refuses to do it twice.

3. I made one food order create one Razorpay order (an atomic claim)

Next, the server creates a Razorpay order. The obvious code is "check, then create", and I did not want that:

if (!order.razorpayOrderId) {
  createRazorpayOrder()   // two requests can both get here
}
Enter fullscreen mode Exit fullscreen mode

Two requests can both read "no Razorpay order yet", both call Razorpay, and then one food order has two gateway orders. This is a check-then-act race. JavaScript being single-threaded doesn't save me: there are many server instances and one database.

So I replaced "check" with a claim: one UPDATE that only succeeds if nobody claimed first.

lib/payments/claim-razorpay-order-creation.ts:11-27

const [foodOrderClaim] = await db
  .update(foodOrders)
  .set({
    razorpayOrderCreationStartedAt: sql`now()`,
  })
  .where(
    and(
      eq(foodOrders.customerId, customerId),
      eq(foodOrders.id, foodOrderId),
      isNull(foodOrders.razorpayOrderId),
      eq(foodOrders.paymentStatus, "PENDING"),
      isNull(foodOrders.razorpayOrderCreationStartedAt)
    )
  )
  .returning()

return foodOrderClaim ?? null
Enter fullscreen mode Exit fullscreen mode

In plain SQL:

UPDATE food_orders
SET razorpay_order_creation_started_at = now()
WHERE customer_id = $1 AND id = $2
  AND razorpay_order_id IS NULL
  AND payment_status = 'PENDING'
  AND razorpay_order_creation_started_at IS NULL
RETURNING *;
Enter fullscreen mode Exit fullscreen mode

Here is why it works: Postgres locks the row while it updates it. The second request waits, then re-checks the WHERE against the new row, sees the timestamp is no longer NULL, and updates 0 rows. Only the request that got a row back calls Razorpay (lib/payments/prepare-razorpay-payment.ts:50-74). I did not add an in-memory lock or Redis. The database decides.

4. I count an order as paid only when Razorpay says captured

After the popup, the browser posts razorpay_payment_id and razorpay_signature to /api/payments/verify. That is only a claim from the browser, so the server checks three things.

The signature. Razorpay signs order_id|payment_id with my key secret using HMAC-SHA256. I check that signature on the server.

lib/payments/verify-signature.ts:11-18

const expected = createHmac("sha256", secret).update(payload).digest("hex")

const expectedBuffer = Buffer.from(expected, "utf8")
const signatureBuffer = Buffer.from(signature, "utf8")

if (expectedBuffer.length !== signatureBuffer.length) return false

return timingSafeEqual(expectedBuffer, signatureBuffer)
Enter fullscreen mode Exit fullscreen mode

Two details I learned on the way. First, I build the payload from my saved order ID, not the one the browser sent (app/api/payments/verify/route.ts:112-117). Second, I used timingSafeEqual instead of ===. A normal compare stops at the first wrong character, so response time leaks how much of a guess was right. I added the length check because timingSafeEqual throws on different lengths. Leaking the length is fine here, since it's always 64 hex characters.

The facts. A valid signature proves Razorpay created this payment for this order. It does not prove the money was captured, or how much. So my server fetches the payment from Razorpay (verify/route.ts:126-127). I make this network call before opening a transaction, so no row lock waits on the internet.

The write. Then I do the write in one transaction:

lib/payments/record-payment.ts:35-51 (shortened)

const [order] = await tx
  .select({ id: foodOrders.id, amountInPaise: foodOrders.amountInPaise, currency: foodOrders.currency })
  .from(foodOrders)
  .where(eq(foodOrders.razorpayOrderId, payment.orderId))
  .for("update")

if (
  order.amountInPaise !== payment.amountInPaise ||
  order.currency !== payment.currency
) {
  throw new RecordPaymentError("Payment amount or currency does not match")
}
Enter fullscreen mode Exit fullscreen mode

SELECT ... FOR UPDATE makes two verify requests for the same order take turns. I wrote the payment attempt as an upsert on the unique razorpay_payment_id, so there's never a duplicate row. The order moves to PAID only WHERE payment_status = 'PENDING', and only when Razorpay says captured (record-payment.ts:71-81). A second run matches no PENDING row, so paid_at is still set once.

What I got wrong (and what's still missing)

This flow works in test mode. It's not finished, and I'd rather say that here than have someone find it.

1. My first plan compared the browser's price with the database price. I planned this flow with an AI assistant as a tutor. The first plan said: compare the browser's price with the database price, and reject if they differ. The AI review pointed out that a matching number from the browser is still a browser number, so I changed it to always charge the database price (docs/checkout-payment-learning.md, section 1).

2. There's no webhook yet. Payment confirmation depends on the browser reaching /verify. If the customer pays and closes the tab, the order stays PENDING even though money left their account. I have the payment_webhook_events table and the webhook secret, but no route yet. Next I'll verify the signature on the raw body, dedupe by event ID, and reuse recordPayment, which is already idempotent.

3. A claim never expires. If Razorpay times out after my claim, that order is stuck with "needs review" forever. I chose "stuck" over "charged twice", on purpose, but it still needs a fix. My plan is a small job that looks up Razorpay orders by receipt (my order ID), attaches one if it exists, or clears the claim after a few minutes.

4. A status can go backwards. Both verify requests fetch from Razorpay before the lock. If the older fetch says authorized and commits second, the attempt row goes from CAPTURED back to AUTHORIZED. The order stays PAID, but the attempt row is wrong. My fix: only let status move forward.

5. Fees are placeholders. I hard-coded delivery ₹90, tax ₹80 and discount ₹20 as constants (lib/payments/prepare-order.ts:92-94). The server decides them, and the CHECK keeps the sum honest. I have not built real GST or distance-based delivery.

6. No automated tests for any of this. The first tests I'll write: two parallel prepareRazorpayPayment calls with a fake Razorpay client, expecting orders.create once; and verify called twice, expecting one attempt row.

How I used AI here

I'll be direct about it. AI was my tutor and reviewer for this part: it helped me write the plan in docs/checkout-payment-learning.md, and I built the code step by step against Razorpay's own docs. Where they disagreed, the docs won. For example, Razorpay's docs recommend verifying the payment on the server, so I also fetch it. My rule for AI-assisted code is simple: if I can't explain a line, it doesn't ship.

What I took from it

  • I let the database enforce the rules that matter: UNIQUE for "only once", CHECK for "must add up", row locks for "take turns".
  • I replaced "check, then act" with one conditional write, and I read the result.
  • For me, a redirect or a browser callback is a claim. A signed message plus a server-side fetch is my evidence.
  • In this flow, the happy path was the easy part. Retries, double-clicks and lying clients were most of the work.

The code is on GitHub: github.com/knownIndie/FOODIO (start in lib/payments/).

TLDR: I stopped trusting anything that came from the browser, and I let the database enforce the rules that matter.

How would you handle a payment that succeeded but whose confirmation never reached your server?

Top comments (3)

Collapse
 
steven_mock_afbb801174fc3 profile image
Steven Mock •

This is a really clear walkthrough — especially the point that you can't promise exactly-once delivery, so you have to make each step safe to repeat. That mindset matters far beyond payments. In any business where money or a booking is on the line, one double charge does more damage to trust than a dozen small bugs. I also appreciated the honesty about what's still missing, especially the webhook. A customer who pays and closes the tab shouldn't be left in limbo, and it's smart to flag that before someone finds it the hard way. Solid work.

Collapse
 
bhardwaj03aryan profile image
Anastand •

thanks for the feedback , it was my first time with payments and it took a while for me to understand , even now i am only able to explain 70% of the things i did

Collapse
 
lycore profile image
Lycore Development •

The conditional claim is a strong way to avoid a check-then-act race without holding a transaction open across the Razorpay call. One failure window I'd test explicitly is a worker crash after the claim is written but before the gateway order ID is saved; a time-bounded claim plus the gateway's idempotency support and a reconciliation path can let a retry recover safely. Do you have a plan for reclaiming stale claims without creating a second order?