From d0c00e533261995022dbce320e963ab1e9bb94e8 Mon Sep 17 00:00:00 2001 From: Kim Coleman Date: Tue, 30 Jun 2026 08:46:42 -0400 Subject: [PATCH] Resolve orphaned incomplete orders superseded by a newer checkout 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) --- includes/checkout.php | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/includes/checkout.php b/includes/checkout.php index 793baa3faa..dae9f4b50f 100644 --- a/includes/checkout.php +++ b/includes/checkout.php @@ -317,6 +317,54 @@ function pmpro_complete_checkout( $order ) { } } +/** + * Mark a member's older incomplete orders as error when a newer order supersedes them. + * + * Covers re-checking out for the same level, or switching to another level in a group that only + * allows one level at a time. Incomplete orders tied to a still-active subscription are left alone. + * + * @since TBD + * + * @param int $user_id The ID of the user who completed checkout. + * @param MemberOrder $order The newly completed order. + */ +function pmpro_error_orphaned_incomplete_orders( $user_id, $order ) { + if ( empty( $order->id ) || 'success' !== $order->status || empty( $order->membership_id ) ) { + return; + } + + // Switching to a level in a one-at-a-time group deactivates sibling levels, so their orders are superseded too. + $superseded_level_ids = array( $order->membership_id ); + $level_group_id = pmpro_get_group_id_for_level( $order->membership_id ); + $level_group = empty( $level_group_id ) ? false : pmpro_get_level_group( $level_group_id ); + if ( ! empty( $level_group ) && empty( $level_group->allow_multiple_selections ) ) { + $superseded_level_ids = pmpro_get_level_ids_for_group( $level_group->id ); + } + + $incomplete_orders = MemberOrder::get_orders( array( + 'user_id' => $user_id, + 'membership_level_id' => $superseded_level_ids, + 'status' => array( 'token', 'pending', 'review' ), + ) ); + + foreach ( $incomplete_orders as $incomplete_order ) { + // Only resolve orders older than the one we just completed. + if ( (int) $incomplete_order->id >= (int) $order->id ) { + continue; + } + + $subscription = $incomplete_order->get_subscription(); + if ( ! empty( $subscription ) && 'active' === $subscription->get_status() ) { + continue; + } + + $incomplete_order->add_order_note( __( 'Order marked as error because it was superseded by a newer successful order.', 'paid-memberships-pro' ) ); + $incomplete_order->status = 'error'; + $incomplete_order->saveOrder(); + } +} +add_action( 'pmpro_after_checkout', 'pmpro_error_orphaned_incomplete_orders', 10, 2 ); + /** * Legacy function. *