<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anastand</title>
    <description>The latest articles on DEV Community by Anastand (@bhardwaj03aryan).</description>
    <link>https://dev.to/bhardwaj03aryan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1323553%2F0088650d-23a5-4200-be02-9ae49042b1c9.jpg</url>
      <title>DEV Community: Anastand</title>
      <link>https://dev.to/bhardwaj03aryan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bhardwaj03aryan"/>
    <language>en</language>
    <item>
      <title>How I made FoodIO's Razorpay payments not charge twice</title>
      <dc:creator>Anastand</dc:creator>
      <pubDate>Sat, 07 Nov 2026 06:13:31 +0000</pubDate>
      <link>https://dev.to/bhardwaj03aryan/how-i-made-foodios-razorpay-payments-not-charge-twice-31o9</link>
      <guid>https://dev.to/bhardwaj03aryan/how-i-made-foodios-razorpay-payments-not-charge-twice-31o9</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;safe to repeat&lt;/strong&gt; (idempotent). If a step runs twice, the effect still happens once.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four steps, and each one could go wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser lies about the price.&lt;/li&gt;
&lt;li&gt;A double-click or retry creates two orders.&lt;/li&gt;
&lt;li&gt;Two requests race and create two Razorpay orders for one food order.&lt;/li&gt;
&lt;li&gt;The browser says "paid", but the money never arrived.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. I let the browser send &lt;em&gt;what&lt;/em&gt;, not &lt;em&gt;how much&lt;/em&gt;
&lt;/h2&gt;

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

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

&lt;p&gt;I store money as an &lt;strong&gt;integer in paise&lt;/strong&gt;. Floats can't store most decimals exactly (&lt;code&gt;0.1 + 0.2 !== 0.3&lt;/code&gt;), and Razorpay wants paise anyway.&lt;/p&gt;

&lt;p&gt;Then I added a layer I did not expect to need: the database checks the bill itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lib/db/schema/schema.ts:343-350&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;food_orders_total_matches&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountInPaise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; =
    &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtotalInPaise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    + &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryFeeInPaise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    + &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxInPaise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    - &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;discountInPaise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. I made one checkout create one order (an idempotency key)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;The table has &lt;code&gt;UNIQUE(customer_id, checkout_request_id)&lt;/code&gt; (&lt;code&gt;schema.ts:325-328&lt;/code&gt;), and I let the insert do nothing on a conflict:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lib/payments/save-order.ts:34-40&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;orderData&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoNothing&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkoutRequestId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;em&gt;different&lt;/em&gt; items. That's not a retry, it's a changed cart. I compare items, restaurant and address, and throw &lt;code&gt;"Checkout details have changed"&lt;/code&gt; (&lt;code&gt;save-order.ts:66-96&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;That is the idempotency key in practice: the browser names the operation, and the database refuses to do it twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. I made one food order create one Razorpay order (an atomic claim)
&lt;/h2&gt;

&lt;p&gt;Next, the server creates a Razorpay order. The obvious code is "check, then create", and I did not want that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;razorpayOrderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;createRazorpayOrder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// two requests can both get here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;So I replaced "check" with a &lt;strong&gt;claim&lt;/strong&gt;: one UPDATE that only succeeds if nobody claimed first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lib/payments/claim-razorpay-order-creation.ts:11-27&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;foodOrderClaim&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;razorpayOrderCreationStartedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`now()`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;foodOrderId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;razorpayOrderId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PENDING&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;razorpayOrderCreationStartedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;foodOrderClaim&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In plain SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;food_orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;razorpay_order_creation_started_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;razorpay_order_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;razorpay_order_creation_started_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is why it works: Postgres locks the row while it updates it. The second request waits, then re-checks the &lt;code&gt;WHERE&lt;/code&gt; 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 (&lt;code&gt;lib/payments/prepare-razorpay-payment.ts:50-74&lt;/code&gt;). I did not add an in-memory lock or Redis. The database decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. I count an order as paid only when Razorpay says captured
&lt;/h2&gt;

&lt;p&gt;After the popup, the browser posts &lt;code&gt;razorpay_payment_id&lt;/code&gt; and &lt;code&gt;razorpay_signature&lt;/code&gt; to &lt;code&gt;/api/payments/verify&lt;/code&gt;. That is only a &lt;em&gt;claim&lt;/em&gt; from the browser, so the server checks three things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The signature.&lt;/strong&gt; Razorpay signs &lt;code&gt;order_id|payment_id&lt;/code&gt; with my key secret using HMAC-SHA256. I check that signature on the server.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lib/payments/verify-signature.ts:11-18&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signatureBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;signatureBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signatureBuffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details I learned on the way. First, I build the payload from &lt;strong&gt;my saved&lt;/strong&gt; order ID, not the one the browser sent (&lt;code&gt;app/api/payments/verify/route.ts:112-117&lt;/code&gt;). Second, I used &lt;code&gt;timingSafeEqual&lt;/code&gt; instead of &lt;code&gt;===&lt;/code&gt;. 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 &lt;code&gt;timingSafeEqual&lt;/code&gt; throws on different lengths. Leaking the length is fine here, since it's always 64 hex characters.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The write.&lt;/strong&gt; Then I do the write in one transaction:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lib/payments/record-payment.ts:35-51&lt;/code&gt; (shortened)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amountInPaise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountInPaise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foodOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;razorpayOrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountInPaise&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountInPaise&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RecordPaymentError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Payment amount or currency does not match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  What I got wrong (and what's still missing)
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;1. My first plan compared the browser's price with the database price.&lt;/strong&gt; 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 &lt;em&gt;always charge the database price&lt;/em&gt; (&lt;code&gt;docs/checkout-payment-learning.md&lt;/code&gt;, section 1).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. There's no webhook yet.&lt;/strong&gt; Payment confirmation depends on the browser reaching &lt;code&gt;/verify&lt;/code&gt;. If the customer pays and closes the tab, the order stays PENDING even though money left their account. I have the &lt;code&gt;payment_webhook_events&lt;/code&gt; 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 &lt;code&gt;recordPayment&lt;/code&gt;, which is already idempotent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A claim never expires.&lt;/strong&gt; If Razorpay times out &lt;em&gt;after&lt;/em&gt; 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 &lt;code&gt;receipt&lt;/code&gt; (my order ID), attaches one if it exists, or clears the claim after a few minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A status can go backwards.&lt;/strong&gt; Both verify requests fetch from Razorpay &lt;em&gt;before&lt;/em&gt; the lock. If the older fetch says &lt;code&gt;authorized&lt;/code&gt; 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.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  How I used AI here
&lt;/h2&gt;

&lt;p&gt;I'll be direct about it. AI was my tutor and reviewer for this part: it helped me write the plan in &lt;code&gt;docs/checkout-payment-learning.md&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

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

&lt;p&gt;The code is on GitHub: github.com/knownIndie/FOODIO (start in &lt;code&gt;lib/payments/&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;TLDR: I stopped trusting anything that came from the browser, and I let the database enforce the rules that matter.&lt;/p&gt;

&lt;p&gt;How would you handle a payment that succeeded but whose confirmation never reached your server?&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>postgres</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
