Resolve orphaned incomplete orders superseded by a newer checkout - #3715
Resolve orphaned incomplete orders superseded by a newer checkout#3715kimcoleman wants to merge 1 commit into
Conversation
A pending/token/review order receives a subscription_transaction_id at checkout, but its PMPro_Subscription row is only persisted once the membership is activated. If a member checks out again before that order resolves, the cleanup in PMPro_Subscription::save() never runs (there is no subscription to cancel), leaving the original order pending forever. On pmpro_after_checkout, mark the member's older incomplete orders as error when a newer order supersedes them. Covers re-checking out for the same level and switching to another level in a group that only allows one level at a time. Orders tied to a still-active subscription are left alone so legitimate past-due renewals are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
flintfromthebasement
left a comment
There was a problem hiding this comment.
PR: #3715 — Resolve orphaned incomplete orders superseded by a newer checkout
kimcoleman → dev | 1 file, +48 -0 lines
#3715
Summary
Solid fix. The root cause analysis is correct and the implementation is tight. pmpro_error_orphaned_incomplete_orders() correctly fires on pmpro_after_checkout, scopes cleanup to the right level set (same level or the whole group for one-at-a-time groups), and guards against touching renewal orders that legitimately have an active subscription. Ready to merge.
Issues
- Minor
includes/checkout.php—MemberOrder::get_orders()defaults to a 100-row limit when nolimitarg is passed. The call at the new function doesn't override it. For the described scenario (one or a few stale pending orders) this is a non-issue in practice, but passing'limit' => 0would remove the implicit cap and be more correct. Fromclass.memberorder.php:577:$limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100;— and a zero value skips the LIMIT clause entirely.
Looks Good
- The
'success' !== $order->statusearly return is the right entry guard; the hook fires from bothcheckout.phpand the IPN handlers, so this prevents the cleanup from running on non-final orders regardless of call site. - The subscription check on the incomplete order is correct. For a
pendingPay-by-Check order with no subscription record,get_subscription()returns null (the order isn't success, so no subscription is materialized). The guard only preserves orders that have a real, active subscription row — exactly what "legitimate past-due renewal" means. add_order_note()mutates$this->notesin memory;saveOrder()persists both the status change and the updated notes in the same UPDATE query. Sequencing is correct.- Group logic mirrors
pmpro_changeMembershipLevel()faithfully: only expands$superseded_level_idsto the full group whenallow_multiple_selectionsis falsy, leaving multi-selection groups and ungrouped levels as same-level-only. - The
idcomparison (>= $order->id) is the right anchor. An orphaned order is definitionally older; this guards against accidentally touching the newly completed order or any future orders from a race.
|
Holding this back from 3.8.3. The intention behind this PR makes sense, but a core-wide sweep changing I agree that we need a solution around checkouts that are initiated while other checkouts are already in progress, but we may need to consider solutions warning users before they start another checkout or solutions per-gateway since only each gateway knows if it can stop an in-progress |
Problem
A member can be left with a stale
pendingorder that never resolves:pmpro_check_status_after_checkouttopending(withholding access until the check arrives). This creates apendingorder.successorder.pendingforever — the account page shows "Your latest payment for this membership is past due..." even though they have paid.Root cause
PMPro already cleans up incomplete orders:
PMPro_Subscription::save()marks a subscription'stoken/pending/revieworders aserrorwhen that subscription is cancelled, and a new checkout for the same level cancels the prior subscription.But the cleanup keys off a subscription, and the orphaned pending order has none. A
pendingorder receives asubscription_transaction_idat checkout, but thePMPro_Subscriptionrow is only persisted when the membership is activated. While the order ispending, no subscription record exists — so there is nothing to cancel, and the existing cleanup never reaches the orphaned order.Fix
On
pmpro_after_checkout, mark the member's older incomplete (token/pending/review) orders aserrorwhen a newer order supersedes them.pmpro_changeMembershipLevel(). Multi-selection groups and ungrouped levels stay same-level-only, so a member who can legitimately hold sibling levels keeps those pending orders.Guards:
idcomparison).An order note is added explaining the status change, mirroring the existing cleanup.
Testing notes
pendingcheck order for a level, then complete a successful Stripe checkout for the same level (or another level in a one-at-a-time group). The olderpendingorder should flip toerrorwith a note.success+ newerpending) are untouched.Not covered here
This resolves the orphan on the member's next checkout. A
pendingorder that is simply abandoned (never superseded) is out of scope — a cron sweep could be added separately to catch those.🤖 Generated with Claude Code