We have had a handful of customers double charged this week or had charges show as declined but then post to their banks as cleared.
We burned a lot of AI tokens digging into the v260814 code with an AI agent. After about 400,000 tokens of work analyzing our live site and the v260814 code, the agent found the following potential causes, which it separately confirmed in another deep pass through the code. Below is its report. We hope this is helpful in fixing the issue.
Bug Report — s2Member Pro: Stripe duplicate charges (new PaymentIntent minted on every retry)
Product: s2Member® Pro (Stripe integration)
Version tested: v260814 (stable tag 260814; “Tested up to” WP 7.1-RC3-63235; requires PHP 5.6.2)
Environment: WordPress + WooCommerce storefront, live-mode Stripe keys, card checkout via embedded Stripe.js Pro-Form
Severity: High — real customer funds being charged 2–3× per purchase; recurring, revenue + churn + refund/dispute impact
Summary
Customers purchasing a one-time product through the Stripe Pro-Form are being charged two or three times for a single purchase when the first attempt appears to fail or be slow. The root cause is the interaction of two design choices in the Pro Stripe gateway:
-
A new Stripe
PaymentIntentis created (and confirmed, i.e., charged) on every form submission that arrives with a payment method but no reusable intent ID. There is no server-side mechanism that ties one order to onePaymentIntentacross retries. - The Stripe idempotency key is computed from the per-attempt payment-method token, so it differs on every retry and therefore provides no deduplication.
Together, these mean that every customer retry after a visible failure (or a lost/timeout success response) produces a brand-new, independently confirmed charge. This is not a Stripe platform defect; Stripe behaves as documented.
This class of issue has been reported publicly since at least 2016 (forum thread 7002, forum thread 11034, GitHub issue #936), and the code path in question is unchanged in v260814.
Customer-visible symptom
Representative customer report (one-time $39.99 subscription purchase, new buyer):
“I tried buying my online subscription… it was saying my card declined twice and then it finally went through and I have the subscription but it charged me for the other two times that it said it declined.”
Net result: the account was created once, but the card statement shows three posted charges for one purchase. This is occurring for a meaningful share of signups, not an isolated case.
Honest scope note: A genuine hard card decline posts no charge (
requires_payment_method). Three posted charges are most consistent with the slow/lost-success variant: the server confirms (charges) the intent, but the success response is lost or never interpreted by the browser (timeout, slow network, 3DS round-trip), the customer retries, and each retry mints a fresh confirmed intent. The Stripe Dashboard check below disambiguates this definitively per customer.
Root cause — verified in v260814 source
Defect A — Idempotency key is per-attempt, so it cannot deduplicate
includes/classes/gateways/stripe/stripe-utilities.inc.php, create_payment_intent() (L1839–1883):
$intent = array(
'amount' => self::dollar_amount_to_cents($amount, $currency),
'currency' => $currency,
'customer' => $cus_id,
'payment_method' => $pm_id, // <-- a FRESH token on every submit
'confirmation_method' => 'manual',
'confirm' => true, // <-- creating the intent IS the charge
'description' => $description,
'metadata' => $metadata,
// ...
);
$intent = \Stripe\PaymentIntent::create($intent, array(
'idempotency_key' => md5(serialize($intent)) // hashes the whole array, INCLUDING $pm_id
));
Because $pm_id is part of the serialized input, and the browser mints a new payment-method token on every submit (see Defect C / stripe.js L893–916), the hash is different on every attempt. Stripe’s idempotency guarantee only replays a request for the same key; a guaranteed-unique key per attempt defeats it entirely. This is “idempotency keyed to a value that changes per attempt,” not missing idempotency.
Defect B — A new intent+charge is created whenever the POST lacks a reusable pi_id
includes/classes/gateways/stripe/stripe-checkout-in.inc.php. The dangerous branch is repeated across all three checkout paths (buy-now new user L892–903, buy-now existing user L776–779, subscription L197–207 / L460–465). Representative (new user):
if(!empty($post_vars['pi_id']))
$stripe_intent = ...::update_payment_intent($post_vars['pi_id'], array('payment_method'=>$payment_method->id));
if(empty($post_vars['pi_id']) || (!empty($stripe_intent) && !is_object($stripe_intent)))
$stripe_intent = ...::create_payment_intent(...); // <-- NEW intent + charge
So the flow is: pi_id empty + pm_id present → create_payment_intent → new confirmed charge.
Crucially, pi_id is never persisted server-side. In includes/templates/forms/stripe-billing-div.php (L22–28) the hidden fields are:
<input type="hidden" id="s2member-pro-stripe-form-pm-id" name="stripe_pm_id" value="" />
<input type="hidden" id="s2member-pro-stripe-form-pi-id" name="stripe_pi_id" value="" /> // hardcoded empty
<input type="hidden" id="s2member-pro-stripe-form-pi-secret" name="stripe_pi_secret" value="%%pi_secret%%" />
In includes/classes/gateways/stripe/stripe-form-in.inc.php (L409–414) only %%pi_secret%%, %%seti_secret%%, and %%sub_id%% receive server values; there is no %%pi_id%% placeholder anywhere in the codebase. Consequently pi_id survives a retry only via the client-side jQuery .val() in stripe.js — which dies on any full page reload, re-render, or error re-display. After a visible decline or a lost success, the re-rendered form comes back with pi_id empty, so the next retry always falls into create_payment_intent.
Defect C (contributing) — Submit button not locked during the async payment-method call
includes/separates/gateways/stripe/stripe.js. The submit handler (L860+) calls event.preventDefault() and then runs the async stripe.createPaymentMethod() (L893). It does not disable the submit button at the top of that path. Button-disable only occurs inside the 3DS branches (L753, L802, L815); the error path re-enables it (L908). A double-click during the multi-second createPaymentMethod round-trip therefore fires two independent submit cycles → two submissions → two charges. This is a real double-submit race (more likely on slow connections; on fast networks the second form.submit() can lose the race to navigation), compounding A+B.
Why the generic error text invites the retry that causes the duplicate
includes/classes/gateways/stripe/stripe-utilities.inc.php L2046–2047: on requires_payment_method the plugin returns “The payment failed, please try again with a different card.” The same string is shown to the customer on failure (S2MEMBER_PRO_STRIPE_PAYMENT_FAILED, stripe-css-js.inc.php L84). That instruction prompts exactly the retry that Defects A+B turn into a new charge.
Secondary observation (context, not the primary cause)
The pinned Stripe API version is 2019-10-08 (stripe.js L730; stripe-utilities.inc.php L53) with a manually-confirmed PaymentIntent flow. This predates current best practice and means the integration does not benefit from newer Stripe behaviors. This is supporting context for the fragility, not the root cause of the duplicates.
Why v260814 does not resolve this
The complete Stripe-relevant entries in the recent changelog are:
- v260814 — Fix: prevented Stripe processing from continuing after Pro-Form validation rejects a submission (fixes misleading card-field errors when other required fields are missing).
-
v260814 — Fix:
validate_zipcodeattribute handling on Pro-Forms. - v260805 — Improvement: rare subscription checkouts where the first payment stays pending now delay paid-access until Stripe confirms.
None of these touch intent creation, intent reuse across retries, or the idempotency key. Direct inspection of the v260814 source confirms Defects A and B are still present verbatim.
Prior public reports (long-standing, repeatedly mitigated, never root-caused)
- Forum thread 7002 — “Stripe causing double payments” (Jan 2020, 90 replies, 24.9k views): “If the first payment fails, the amount is being stacked.” A site owner’s redacted API request/response logs show 4 sequential payment transactions for one order; the maintainer’s analysis in-thread: “I believe the issue is that the server is sending 4 requests in a row… the question is who and why.” Another owner: “multiple payment intent failures, with each one adding one more multiple until a success happens with a multiplied payment.” That thread’s cases were specifically subscription-with-trial flows. Stripe causing double payments
- Forum thread 11034 — “Incorrect Double and Triple Charges from S2” (Oct 2023; single post, no maintainer reply in-thread): customers double-/triple-/quadruple-charged (~10% of signups) after 3DS “Action Required — Secure authorization” + “unable to authenticate” appear together; retries create multiple intents/subscriptions, all later charged. Describes the same retry-mints-new-intent failure shape (subscription flow). Incorrect Double and Triple Charges from S2
- GitHub issue #936 — “Customers being double-charged, no idea why” (Apr 2016, still open): random double-charging around trials (full amount charged before/after trial end; Stripe’s in-thread reply suspected the customer “stuck in a loop and being resubscribed”). https://github.com/wpsharks/s2member/issues/936
-
Release v201209-RC (published Dec 2020): “Stripe duplicate payments were happening randomly to a few site owners, apparently from bad communication between their server and Stripe’s. Added idempotency to prevent duplicates.” — consistent with the
md5(serialize($intent))key still present in v260814. That key covers only the narrow “server→Stripe network-timeout replay” case, not the retry-after-visible-failure case documented here.
Fair-scope note: the public reports above are on subscription/trial flows; this report documents the one-time buy-now path, where the same code-level defects (A and B) are verified directly in the source. The public history is offered as evidence that maintainers have long been aware of retry-induced duplicate intent/charge accumulation and have addressed its edges (trial accumulation, decline handling, webhook dedup) without changing the underlying one-intent-per-retry design.
Deterministic reproduction path (one-time buy, new user)
- Customer submits the Pro-Form.
- Browser:
stripe.createPaymentMethod()→ new tokenpm_A. Form POSTs withstripe_pm_id=pm_A,stripe_pi_id=(empty). - Server:
get_customer→attached_card_payment_method→pi_idempty ⇒create_payment_intent($cus, pm_A, …, confirm=true)⇒ Charge #1 (intentpi_1). - Success response is lost/unseen (timeout / slow network / 3DS), or the card genuinely declines and the customer retries.
- Re-rendered form has
stripe_pi_idempty (Defect B); browser mints a new tokenpm_B. - Server:
pi_idempty ⇒create_payment_intent($cus, pm_B, …)⇒ Charge #2 (intentpi_2, different idempotency key ⇒ accepted). - Repeat ⇒ Charge #3 (intent
pi_3) → success shown; account created once. - Result: three separate
PaymentIntents / three posted charges for one $39.99 purchase.
Dashboard confirmation (per affected customer): Stripe → Customer → Payments. 3 PaymentIntents / 3 Charges for one purchase = this bug, confirmed live. 1 PaymentIntent = not this path (would be bank-side pending holds instead). Also scan Stripe Reports for the same email + same amount within minutes to size the blast radius. s2 debug log: requires the “Gateway Debug Logs” option to be enabled (s2Member → Logs page in WP admin shows the exact storage path for the configured log directory, e.g., gateway-core-ipn.log-style files in the configured logs_dir). With it on, create_payment_intent entries logged with distinct pi_ IDs around the customer’s timestamps prove each retry minted a new intent.
Requested fix
We would like the plugin to enforce one PaymentIntent per order (Stripe’s own guidance: “create exactly one PaymentIntent for each order or customer session”):
-
Create once, re-confirm on retry. Mint the
PaymentIntenton the first attempt for the order and re-confirm the same intent on every subsequent retry, rather than creating a new one. Re-confirming an already-succeededPaymentIntentcannot double-charge. -
Stable idempotency key. Derive the
Idempotency-Keyfrom a stable order identifier (e.g., customer + form/level + amount + a server-stored per-order nonce), not from the per-attemptpayment_methodtoken. -
Persist the intent per order server-side (e.g., a transient/cookie/session keyed to the order), so a retry reuses the existing
pi_ideven after a page re-render — instead of relying on the client-side hidden field that resets to empty. -
Client-side double-submit guard. Disable the submit button at the top of the submit handler for the duration of the async
createPaymentMethod/confirm sequence (currently only disabled in the 3DS branches). - Please confirm whether v260814 or any upcoming release addresses the intent-reuse/idempotency design, and whether any configuration or workaround exists in the meantime.
Evidence index (exact file:line, v260814)
| Item | Location |
|---|---|
Per-attempt idempotency key (md5(serialize($intent)) incl. payment_method) |
stripe-utilities.inc.php L1856–1872 |
create_payment_intent = confirm: true (create = charge) |
stripe-utilities.inc.php L1860–1862 |
New-user buy-now: pi_id empty ⇒ create_payment_intent
|
stripe-checkout-in.inc.php L892–903 |
| Existing-user buy-now: same branch |
stripe-checkout-in.inc.php L776–779 |
pi_id read from $_POST['stripe_pi_id']
|
stripe-checkout-in.inc.php L72 |
Hidden stripe_pi_id hardcoded value=""; only %%pi_secret%%/%%sub_id%% filled |
stripe-billing-div.php L22–28 |
No %%pi_id%% server-side substitution (only secret/sub_id) |
stripe-form-in.inc.php L409–414 |
New createPaymentMethod token on every submit; no submit-button lock at handler top |
stripe.js L860–916 (disables only L753/802/815) |
| Generic “try again” decline text |
stripe-utilities.inc.php L2046–2047; stripe-css-js.inc.php L84 |
Pinned API version 2019-10-08
|
stripe.js L730; stripe-utilities.inc.php L53 |
SDK sends Idempotency-Key header from the provided key |
stripe-sdk/lib/Util/RequestOptions.php L88–89 |
Sources
- s2Member v260814 source distribution (inspected): the files above.
- s2Member official changelog — https://s2member.com/changelog/
- WP Sharks forum thread 7002 — Stripe causing double payments
- WP Sharks forum thread 11034 — Incorrect Double and Triple Charges from S2
- GitHub issue #936 — https://github.com/wpsharks/s2member/issues/936
- GitHub releases (v201209-RC idempotency note) — https://github.com/wpsharks/s2member/releases
- Stripe Docs — PaymentIntents (one intent per order/session) — https://docs.stripe.com/api/payment_intents
- Stripe status (no active incident) — https://status.stripe.com/