<?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: Feezan Khattak</title>
    <description>The latest articles on DEV Community by Feezan Khattak (@feezan_khattak).</description>
    <link>https://dev.to/feezan_khattak</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%2F4122820%2F5f19fab6-d709-445a-940c-e5e026178bb7.png</url>
      <title>DEV Community: Feezan Khattak</title>
      <link>https://dev.to/feezan_khattak</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/feezan_khattak"/>
    <language>en</language>
    <item>
      <title>Your Payment Code Can Still Charge Twice (Even With Idempotency Keys)</title>
      <dc:creator>Feezan Khattak</dc:creator>
      <pubDate>Sun, 13 Sep 2026 05:55:46 +0000</pubDate>
      <link>https://dev.to/feezan_khattak/your-payment-code-can-still-charge-twice-even-with-idempotency-keys-2hk1</link>
      <guid>https://dev.to/feezan_khattak/your-payment-code-can-still-charge-twice-even-with-idempotency-keys-2hk1</guid>
      <description>&lt;p&gt;A double charge is the payment bug customers actually notice. Two lines on a bank statement, an angry support ticket, and if it's handled badly, a dispute — which costs you a fee whether you win or not.&lt;/p&gt;

&lt;p&gt;Most engineers reach for &lt;strong&gt;idempotency keys&lt;/strong&gt;, and they should. But idempotency keys stop &lt;em&gt;duplicate requests&lt;/em&gt;. They don't stop your own system making &lt;em&gt;two legitimately different requests&lt;/em&gt; for the same order. That second category is where double charges that survive code review come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Idempotency keys stop &lt;strong&gt;duplicate requests&lt;/strong&gt;, not &lt;strong&gt;duplicate intents&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Disabling the Pay button is a courtesy, &lt;strong&gt;not a control&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Most remaining causes are &lt;strong&gt;races&lt;/strong&gt;, and read-then-write checks lose races.&lt;/li&gt;
&lt;li&gt;The reliable fix is a &lt;strong&gt;partial unique index&lt;/strong&gt; — and &lt;em&gt;which states it covers&lt;/em&gt; decides whether it works at all.&lt;/li&gt;
&lt;li&gt;Never hold a database transaction open across the call to your payment provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Eight ways a customer gets charged twice
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Customer double-clicks Pay&lt;/td&gt;
&lt;td&gt;UI guard + server idempotency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Retry sent with &lt;strong&gt;no&lt;/strong&gt; idempotency key&lt;/td&gt;
&lt;td&gt;Always send one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Retry sent with a &lt;strong&gt;new&lt;/strong&gt; key&lt;/td&gt;
&lt;td&gt;Generate before attempt 1, persist it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Two browser tabs / two devices&lt;/td&gt;
&lt;td&gt;Server-side unique constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Mobile app retries on resume&lt;/td&gt;
&lt;td&gt;Key survives app restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Webhook handler charges again&lt;/td&gt;
&lt;td&gt;Handlers record state, never charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Two concurrent requests race a check&lt;/td&gt;
&lt;td&gt;DB constraint, not &lt;code&gt;if (exists)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Dunning job races a manual retry&lt;/td&gt;
&lt;td&gt;Guard scoped to the billing period&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Causes 1–3 and 5 are solved by doing idempotency properly. &lt;strong&gt;4, 6, 7 and 8 are not&lt;/strong&gt; — they're your system charging twice with two different, valid requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race that careful-looking code loses
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG: two concurrent requests can both pass this check&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByOrderIdAndStateIn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SUCCESSFUL_STATES&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&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="nf"&gt;AlreadyPaidException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;doCharge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// both threads arrive here&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both requests run &lt;code&gt;existsBy...&lt;/code&gt; before either commits. Both see nothing. Both charge. You'll never see it in local testing; you will see it during a retry storm or a double-tap on a slow mobile connection.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; doesn't save you either — two instances behind a load balancer don't share a lock. &lt;strong&gt;Only the database can win this race.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The index that looks right and isn't
&lt;/h2&gt;

&lt;p&gt;The obvious fix is a partial unique index allowing one successful payment per order:&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="c1"&gt;-- Looks right. Doesn't prevent the double charge.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ux_payments_order_successful&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AUTHORIZED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CAPTURED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SETTLED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walk through the race with it in place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request A inserts its payment as &lt;code&gt;INITIATED&lt;/code&gt;. The index ignores that state.&lt;/li&gt;
&lt;li&gt;Request B inserts its payment as &lt;code&gt;INITIATED&lt;/code&gt;. Also ignored — &lt;strong&gt;both inserts succeed&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Both call the payment provider. &lt;strong&gt;Both charges go through.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Both try to mark themselves &lt;code&gt;AUTHORIZED&lt;/code&gt;. Only now does the constraint fire — on the second one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The index stopped you &lt;em&gt;recording&lt;/em&gt; a double charge. It didn't stop you &lt;em&gt;making&lt;/em&gt; one. The customer's card was already charged twice by step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index that actually works
&lt;/h2&gt;

&lt;p&gt;Invert it. Cover every state &lt;strong&gt;except&lt;/strong&gt; the ones where no money is held:&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="c1"&gt;-- At most one payment per order that is in flight OR holds money.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ux_payments_order_active&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DECLINED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'VOIDED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'REFUNDED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now request B fails &lt;strong&gt;at the INSERT&lt;/strong&gt;, before anything leaves your system. And you get two properties for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A payment stuck in &lt;code&gt;UNKNOWN&lt;/code&gt; (a timeout — the money may or may not have moved) &lt;strong&gt;blocks a second attempt until it's resolved&lt;/strong&gt;. That's exactly the situation behind most real-world double charges.&lt;/li&gt;
&lt;li&gt;A genuinely &lt;strong&gt;declined&lt;/strong&gt; payment doesn't block a retry, so the customer can try another card, and failed attempts stay in the table as history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both indexes were tested against PostgreSQL for this post: the first accepts both &lt;code&gt;INITIATED&lt;/code&gt; rows, the second rejects the second one at insert, blocks on &lt;code&gt;UNKNOWN&lt;/code&gt;, and still allows a retry after &lt;code&gt;DECLINED&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it up in Spring
&lt;/h2&gt;

&lt;p&gt;The losing request needs to fail &lt;em&gt;inside&lt;/em&gt; your code, before the provider call, and the provider call must not run inside a transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Short transaction: the INSERT is where the losing request fails.&lt;/span&gt;
        &lt;span class="c1"&gt;// saveAndFlush makes the constraint fire here, not later at commit.&lt;/span&gt;
        &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAndFlush&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initiated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataIntegrityViolationException&lt;/span&gt; &lt;span class="n"&gt;alreadyInFlight&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Lost the race, and no provider call was made.&lt;/span&gt;
        &lt;span class="c1"&gt;// Return the payment that won instead of a 500.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findActiveByOrderId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;alreadyInFlight&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// External call runs with no database transaction held open.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;saveAndFlush&lt;/code&gt;, not &lt;code&gt;save&lt;/code&gt;.&lt;/strong&gt; With a plain &lt;code&gt;save&lt;/code&gt;, Hibernate may defer the insert until commit — so the violation surfaces somewhere your &lt;code&gt;catch&lt;/code&gt; isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TransactionTemplate&lt;/code&gt; (&lt;code&gt;tx&lt;/code&gt;), not &lt;code&gt;@Transactional&lt;/code&gt; on a private helper.&lt;/strong&gt; Spring's transaction proxy doesn't intercept a class calling its own methods, so the annotation would silently do nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No transaction around &lt;code&gt;execute()&lt;/code&gt;.&lt;/strong&gt; Holding one open across a multi-second HTTP call pins a connection and a row lock for the whole duration. Under load, that alone takes you down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Never charge from a webhook handler
&lt;/h2&gt;

&lt;p&gt;Webhooks are delivered &lt;strong&gt;at least once&lt;/strong&gt;, so duplicates are guaranteed, not hypothetical. A handler that initiates a charge will eventually charge twice.&lt;/p&gt;

&lt;p&gt;Handlers should only &lt;strong&gt;record&lt;/strong&gt; what already happened. If an event genuinely needs to trigger a charge, enqueue a job that goes through the same guarded path as above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recurring billing needs a period-scoped guard
&lt;/h2&gt;

&lt;p&gt;Cause 8 is subtle. A dunning job retries March's failed renewal. Meanwhile, support manually retries it. Both correctly use &lt;em&gt;fresh&lt;/em&gt; idempotency keys — the provider's key retention expired days ago — so keys can't protect you. Two charges for one month.&lt;/p&gt;

&lt;p&gt;The guard has to live in your data, scoped to the &lt;strong&gt;billing period&lt;/strong&gt;, with the same index shape:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ux_sub_payments_period_active&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscription_payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscription_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DECLINED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'VOIDED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'REFUNDED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Idempotency keys protect a request. Your schema protects a business outcome.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When it happens anyway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Refund first, investigate second.&lt;/strong&gt; A fast refund usually prevents a dispute, and a dispute costs you a fee regardless of outcome.&lt;/p&gt;

&lt;p&gt;Then run this on a schedule — with the index in place it should always return zero rows, which makes it an excellent canary:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AUTHORIZED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CAPTURED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SETTLED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it ever returns a row, either the index is missing in some environment or something is writing around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Partial unique index per order covering &lt;strong&gt;in-flight&lt;/strong&gt; states, not just successful ones&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;saveAndFlush&lt;/code&gt; in its own short transaction, violation caught there&lt;/li&gt;
&lt;li&gt;[ ] No transaction held open across the provider call&lt;/li&gt;
&lt;li&gt;[ ] Webhook handlers record state and never charge&lt;/li&gt;
&lt;li&gt;[ ] Recurring payments guarded per billing period&lt;/li&gt;
&lt;li&gt;[ ] Duplicate-charge canary query running on a schedule&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This is part 1 of a series on building payment systems as a backend engineer. The &lt;a href="https://feezankhattak.com/blog/prevent-double-charge" rel="noopener noreferrer"&gt;full version on my site&lt;/a&gt; adds the attempt-log schema for diagnosing the double charges that do slip through. I also build free, in-browser &lt;a href="https://feezankhattak.com/tools" rel="noopener noreferrer"&gt;payment engineering tools&lt;/a&gt; — including a &lt;a href="https://feezankhattak.com/tools/decline-code-lookup" rel="noopener noreferrer"&gt;card decline code lookup&lt;/a&gt; and a &lt;a href="https://feezankhattak.com/tools/reconciliation-diff" rel="noopener noreferrer"&gt;settlement reconciliation diff&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>backend</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
