Repository navigation
Description
Summary
When Stripe answers a retry with resource_missing — the subscription no longer exists, or was created under the other API mode — the retry path logs the error and returns. Nothing marks the row as unrecoverable, so the daily payment-retry cron asks Stripe for the same impossible object again the next day, and every day after that, forever.
Evidence from a live customer site
Found while investigating an unrelated support ticket. urm-pg-stripe-*.log on that site (URM Pro 6.2.7, live keys):
1596 x ERROR Stripe subscription retry failed
13 x ERROR Stripe subscription ID not found for retry
10 x ERROR Subscription cannot be recovered.
Every one of the 1596 is the same row:
{
"error_code": "resource_missing",
"error_message": "No such subscription: 'sub_1TtKb3HfxGcS6BDDLKVdGcIl'; a similar object exists in test mode, but a live mode key was used to make this request.",
"subscription_id": "65",
"user_id": "unknown"
}Local subscription #65 is holding a test-mode Stripe subscription ID, almost certainly left behind when the site was tested with test keys before going live. The site runs live keys now, so the lookup can never succeed. First occurrence 4 July, peak ~490/day on 6–7 July, still firing once a day on 24 August — 51 days of guaranteed-useless API calls.
The log is 1.45 MB, most of it this one loop.
Root cause
modules/membership/includes/Admin/Services/Stripe/StripeService.php, in retry_subscription():
} catch ( ApiErrorException $e ) {
PaymentGatewayLogging::log_error( 'stripe', 'Stripe subscription retry failed' . ... );
$response['message'] = $e->getMessage();
return $response;
}A permanent failure (resource_missing) is handled identically to a transient one (network blip, rate limit). There is no give-up state, and the cron's only bound is a date window in MembersSubscriptionRepository::get_subscriptions_to_retry(), not the failure kind.
Why this needs attention now
PR #1398 (sync of pro #1396) changes failed_payment_retry_callback() to stop discarding the gateway's return value: on failure it now records an order note and emails the member.
Today this loop is invisible — it only writes to a log file. Once that lands, the same dead subscription ID produces a failed-order note and a customer-facing email every single day. On the site above that would have been ~1596 order rows and ~1596 emails to one member.
Proposed fix
Treat resource_missing as terminal and stop retrying that gateway subscription:
- In the
catch, when$e->getStripeCode() === 'resource_missing', record a marker keyed by the gateway subscription ID. - At the top of
retry_subscription(), next to the existing empty-sub_idguard, return early when the marker is present.
Keying the marker by the gateway ID rather than the local row ID makes it self-invalidating: if an admin corrects the subscription ID on the row, the new ID is not blocked and retries resume. No cleanup code required.
The guard belongs in retry_subscription() because every retry caller routes through it.
Out of scope, filed separately
user_registration_payment_retry_countis not an attempt cap. It is only multiplied into a date window (retry_count * retry_intervaldays) inget_subscriptions_to_retry();urm_is_payment_retryingis incremented but never compared against the configured maximum. So "retry 3 times, every 3 days" behaves as "retry daily for 9 days."- PayPal has no equivalent give-up path in
NewPaypalService::retry_subscription(). Not seen looping in the wild yet, and the marking needs a gateway-specific error code.
Activity
Fix - Stop retrying a Stripe subscription that no longer exists (#1421)