— Article — № 079

079 —WordPress

WooCommerce double charges: tracing a duplicated payment_complete hook

A WooCommerce store charges every card twice between 14:00 and 16:00. The culprit was not Stripe. It was a payment_complete hook firing from two places at once.

Overhead photo of paper order ledger, Stripe receipt, hook trace diagram, manila folder, brass plate, red wax seal.
Hero · staged still№ 079

At 14:12 on a Tuesday a Dutch agency we work with pinged us with a short Loom. Their client, a mid-size shop running WooCommerce 8.4 on PHP 8.1, had taken seven orders since lunch. Every single one of those orders had two Stripe charges against it, ninety seconds apart, same amount, same card, both captured. The customer service inbox was already six emails deep. The agency lead had twenty minutes before a call and asked the only useful question: is it Stripe or is it us.

It was them. It almost always is. A payment processor that runs nine million businesses does not start double-charging cards on a random Tuesday afternoon; a legacy site that has had four freelancers and three plugin updates in eighteen months does. This is the walkthrough of the next two hours: how we narrowed a WooCommerce double charge down to a duplicated payment_complete hook, what the database told us before the logs did, and the small .htaccess and PHP changes that stopped the bleed before the afternoon ended.

The first ninety seconds: confirming the shape of the bug

Before touching code, we wanted to know whether this was a Stripe-side retry or a WooCommerce-side double execution. The difference matters. A Stripe retry would show two PaymentIntents with different IDs only in pathological cases; most retries reuse the same pi_ ID by design, because the whole point of an idempotency key is to survive transient network failures without charging twice. A WooCommerce double execution would show two PaymentIntents tied to the same order, each captured separately. Same symptom; entirely different root cause, and entirely different blast radius.

We opened the wpdb-backed wp_woocommerce_order_items and wp_postmeta tables and ran a quick check against the last hour of orders. We did not bother with SSH or with WooCommerce's analytics; the MySQL editor goes straight at the data:

SELECT post_id AS order_id,
       meta_value AS stripe_charge_id,
       COUNT(*)   AS hits
FROM   wp_postmeta
WHERE  meta_key = '_stripe_charge_id'
  AND  post_id IN (
    SELECT ID FROM wp_posts
    WHERE  post_type = 'shop_order'
      AND  post_date > NOW() - INTERVAL 2 HOUR
  )
GROUP BY post_id, meta_value
HAVING hits > 1;

Zero rows. Which was already informative. The charge IDs were distinct per attempt; no single charge ID was being recorded twice. So we widened the lens and asked the table to show us every charge-related row on the most recent affected order, in insertion order:

SELECT post_id, meta_key, meta_value
FROM   wp_postmeta
WHERE  meta_key IN ('_stripe_charge_id', '_transaction_id', '_paid_date')
  AND  post_id = 48217
ORDER BY meta_id;

Order 48217 had two _stripe_charge_id rows and two _paid_date rows, ninety-two seconds apart. Two separate PaymentIntents, both captured, both attached to the same WooCommerce order. That ruled out a Stripe network retry; their idempotency model would have collapsed two identical attempts into one. Something inside the site was processing the same checkout twice and asking Stripe to charge for it both times.

Reading the order notes like a flight recorder

WooCommerce writes an order note on almost every state transition. The notes table is the closest thing the platform has to a flight recorder, and it is criminally underused during incidents. Most agencies reach for the access log or the PHP error log first; both tell you the page was requested, neither tells you what the order object did inside that request. The order notes do. We pulled them for 48217:

SELECT comment_date, comment_content
FROM   wp_comments
WHERE  comment_post_ID = 48217
  AND  comment_type   = 'order_note'
ORDER BY comment_date;

The output told the whole story in eight rows. Paraphrased:

  • 14:03:11 — Order status changed from pending to processing.
  • 14:03:11 — Stripe charge complete (Charge ID: ch_3PA...XQ).
  • 14:03:12 — Reducing stock for product #882.
  • 14:03:12 — Order status changed from processing to processing (no change).
  • 14:04:43 — Stripe charge complete (Charge ID: ch_3PA...ZK).
  • 14:04:43 — Reducing stock for product #882.

Two "Stripe charge complete" notes ninety-two seconds apart. Two stock reductions. And in between, a status transition from processing to processing that should not have done anything. That is the signature of payment_complete() being called twice on an order that was already paid. WooCommerce's WC_Order::payment_complete() is supposed to be idempotent against the same order state, but only if the gateway respects the order's current status. If a second call comes in with a different transaction ID, it happily fires the woocommerce_payment_complete action again and runs every hook attached to it, gateway captures included.

The double stock reduction in the notes is the smoking gun. A single legitimate transition from pending to processing reduces stock once. A second pass through the same hook reduces it again, which on a low-stock SKU is how a healthy product ends up oversold by lunchtime. Three days from now the agency would have been on a different call about inventory drift; we caught it on the way in.

And the notes are easy to forget because they live in the wp_comments table, not in any obvious order-related namespace. WordPress reuses wp_comments for product reviews, order notes and ordinary blog comments; tools that index only wp_posts and wp_postmeta miss them. WP-CLI's wc shop_order get skips them too. The fastest way to read them on a live site is the SQL above or the order edit screen, and the SQL wins every time you need to compare two orders side by side.

Finding the second caller

Two callers, both running payment_complete. The first is the Stripe webhook handler, which is the canonical and supported path. So what is the second? We grepped the active theme, the mu-plugins folder, and any drop-ins for the obvious suspects, excluding the gateway plugin itself so we could focus on the client code:

grep -rn "payment_complete" wp-content/ \
  --include="*.php" \
  --exclude-dir=woocommerce \
  --exclude-dir=woocommerce-gateway-stripe

Three matches. Two were ours to worry about:

// wp-content/plugins/woocommerce-gateway-stripe/includes/class-wc-stripe-webhook-handler.php
$order->payment_complete( $intent->id );

// wp-content/mu-plugins/abandoned-cart-recovery.php
$order->payment_complete( $charge_id );

// wp-content/themes/clientchild/inc/checkout-confirm.php
WC()->cart->empty_cart();
$order->payment_complete( $txn );

Two callers in client code. Both written by a previous freelancer. The mu-plugin was harmless on its own; it was guarded by an order status check (if ( $order->has_status( 'pending' ) )) which short-circuited the second call once the webhook had already marked the order paid. The theme file was not. checkout-confirm.php was being included by a thank-you page template that ran on every visit to /checkout/order-received/. If a customer refreshed the page, or if Stripe's redirect arrived a heartbeat before the webhook, the second payment_complete() fired with a new transaction ID and the gateway dutifully captured a fresh PaymentIntent.

A git blame on the offending line gave us the responsible commit, the PR description and the date the call was added: eleven months prior, by a freelancer who had since left the agency. The PR title was the giveaway: "fix order received page sometimes blank". Whoever wrote that line had a thank-you page that did not render and reached for payment_complete() because they had seen it work elsewhere in the codebase. It would have been better to debug why the page was blank, but the freelancer was out by Friday and the line stayed.

The fingerprint was the time gap. Ninety seconds is about how long it takes a distracted shopper to refresh the confirmation page after wondering if their order went through. Long enough that the webhook has landed and marked the order paid; short enough that the customer is still on the tab when their second charge clears. Mobile users were worse; on a phone the gap was tighter, because Safari's back-button restore re-fires the request without a refresh prompt.

The stopgap and the real fix

The bleed had to stop before we discussed architecture. The smallest safe change was to short-circuit the duplicate path. We did not want to disable the theme file outright in case some downstream code in the same include did legitimate work after the payment_complete line, and a hard removal would have meant a full theme deploy at a moment when every change carried risk. We saved a backup of the theme file, added a guard at the top, and pushed it:

// wp-content/themes/clientchild/inc/checkout-confirm.php
if ( $order->is_paid() || $order->get_date_paid() ) {
    return;
}

// Original line, now unreachable for paid orders.
$order->payment_complete( $txn );

The is_paid() check covers the documented WooCommerce paid statuses (processing, completed and any custom statuses registered via the woocommerce_order_is_paid_statuses filter). The get_date_paid() check is the belt to the suspenders, because some Stripe-driven flows mark the order paid before the status transition fully commits and there is a brief window where is_paid() reports false while _paid_date is already populated. The two checks together close the race.

We considered patching the Stripe gateway plugin instead, to make it refuse a second payment_complete on an already-paid order regardless of the transaction ID. That is the architecturally correct fix and it would have closed every caller in one move. We did not do it. Patching a maintained plugin file means living with a merge conflict every release, and the agency was three Stripe-gateway versions behind already. The local guard was reversible, contained and fixable by the next person who looked at the file.

Next, we added an .htaccess rule so the confirmation page could not be POSTed to by a stale form resubmit, which was the third theoretical path to a double fire:

<LocationMatch "^/checkout/order-received/">
    <LimitExcept GET HEAD>
        Require all denied
    </LimitExcept>
</LocationMatch>

The Apache documentation on LimitExcept is worth a re-read every couple of years; it is one of the few directives that does exactly what its name says. Anything not in the allowed verb list is denied with a 403, which the order-received page does not need to handle, because nothing legitimate should POST to it after the initial gateway round-trip.

With the stopgap live, we ran a test checkout. Single charge. Refreshed the confirmation page eight times. Single charge. Pressed the back button to the form and resubmitted; Apache returned 403 and the order stayed clean. We tailed the order notes for ten minutes across nine real customer checkouts. Single charge per order. The incident was over at 16:09.

What we refunded, and how we found everyone

The afternoon's damage was bounded by the SQL we ran first. We pulled every order from the last forty-eight hours that had more than one _stripe_charge_id, cross-referenced against Stripe's dashboard via the order IDs in the description field, and refunded the second capture on each. Twenty-three orders. We picked the later of the two charges to refund (rather than the earlier) so the customer's bank statement showed an immediate refund of the most recent line rather than a delayed refund of something they had already mentally accepted.

The owner emailed each customer personally before they had to ask. Two replied with thanks. The other twenty-one had not noticed yet, because the second charge had not posted to their statement; pending authorisations on most issuers settle within forty-eight hours, and we beat the clock by the width of a coffee break. That is the only way to come out of a double-charge incident with goodwill intact: refund before the chargeback, email before the complaint.

For the longer-term cleanup we turned the diagnostic SQL into a five-minute cron that pings the agency's #incidents channel on any order with more than one _stripe_charge_id. It has fired zero times in the eight weeks since. The theme file was eventually rewritten to remove payment_complete() entirely; the confirmation page now reads order state, it does not write to it. But the guard stays in, because the next freelancer might re-add the line and we would rather they hit a no-op than a charge.

The shape that keeps coming back

When we built Pier we ran into this exact shape of bug on three different client sites in the same quarter, which is why the database and file editor sit side by side in one window, with every save tagged in the version history for one-click rollback. The two questions you ask during a charge incident, what does the order table say and who is calling this function, should not require two SSH sessions and a coffee.

If you run any WooCommerce site you did not write from scratch, grep your theme and mu-plugins folder for payment_complete today. Anything outside the gateway plugin needs a paid-status guard or it needs to go. That is the ten-minute job that will save you the bad afternoon.

— Questions —

Why did WooCommerce capture two separate PaymentIntents instead of refusing the second?

Because the second payment_complete call arrived with a different transaction ID, the Stripe gateway treated it as a fresh capture rather than a duplicate of the already-paid order.

Is WC_Order::payment_complete() supposed to be idempotent?

It is idempotent against the same order state, but only if the calling code respects the order's current status. A second call with a new transaction ID will still fire the woocommerce_payment_complete action.

Could a Stripe webhook retry cause the same symptom?

No. A retry reuses the same PaymentIntent ID. The fingerprint of a duplicated payment_complete hook is two distinct charge IDs against one WooCommerce order.

What is the fastest way to spot this on a site you just inherited?

Grep the theme and mu-plugins folder for payment_complete. Any call outside the gateway plugin needs an is_paid() guard or it should be removed.