From 769351067f30b7062be6c05d1a4b4ea1258e3a53 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 3 Aug 2026 13:46:40 -0600 Subject: [PATCH 1/3] fix(checkout): prevent epoch membership expiration --- inc/checkout/class-checkout.php | 23 ++++--- tests/WP_Ultimo/Checkout/Checkout_Test.php | 80 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 29eb0028..ce274507 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -1455,16 +1455,23 @@ protected function maybe_create_membership() { /* * Important dates. * - * For free, non-recurring products the billing start date is null, - * meaning there is no next charge — the membership should be - * treated as lifetime. Passing null into gmdate() silently uses - * the current timestamp, which sets the expiration to *today* - * and causes the membership to expire within hours/days. + * A null billing start date represents a lifetime membership. A zero + * billing start date means a recurring product has no trial and billing + * starts immediately; zero must not be formatted as a Unix timestamp or + * the persisted membership expiration becomes January 1970. Use the next + * charge date as the first cycle expiration in that case. */ - $billing_start_date = $this->order->get_billing_start_date(); + $billing_start_date = $this->order->get_billing_start_date(); + $expiration_timestamp = $billing_start_date; - $membership_data['date_expiration'] = null !== $billing_start_date - ? gmdate('Y-m-d 23:59:59', (int) $billing_start_date) + if (0 === $expiration_timestamp) { + $expiration_timestamp = $this->order->has_recurring() + ? $this->order->get_billing_next_charge_date() + : null; + } + + $membership_data['date_expiration'] = null !== $expiration_timestamp + ? gmdate('Y-m-d 23:59:59', (int) $expiration_timestamp) : null; $membership = wu_create_membership($membership_data); diff --git a/tests/WP_Ultimo/Checkout/Checkout_Test.php b/tests/WP_Ultimo/Checkout/Checkout_Test.php index 63ae2636..db97cefd 100644 --- a/tests/WP_Ultimo/Checkout/Checkout_Test.php +++ b/tests/WP_Ultimo/Checkout/Checkout_Test.php @@ -4024,6 +4024,86 @@ public function test_maybe_create_membership_free_product_has_null_expiration(): $order_prop->setValue($checkout, null); } + /** + * Test maybe_create_membership uses the next charge for recurring products without trials. + * + * Cart::get_billing_start_date() returns zero when billing starts immediately. + * Passing that value directly to gmdate() stores a Unix epoch expiration, + * which is then exposed in customer views and email placeholders. + */ + public function test_maybe_create_membership_recurring_product_without_trial_uses_next_charge_date(): void { + + $customer = self::$customer; + + $recurring_plan = wu_create_product([ + 'name' => 'Recurring Test Plan', + 'slug' => 'recurring-test-plan-' . wp_rand(1000, 9999), + 'amount' => 25, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'plan', + 'pricing_type' => 'paid', + 'active' => true, + ]); + + if (is_wp_error($recurring_plan)) { + $this->markTestSkipped('Product creation failed: ' . $recurring_plan->get_error_message()); + } + + $checkout = Checkout::get_instance(); + $reflection = new \ReflectionClass($checkout); + $method = $reflection->getMethod('maybe_create_membership'); + + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $cart = new Cart(['products' => [$recurring_plan->get_id()]]); + + $this->assertTrue($cart->has_recurring()); + $this->assertFalse($cart->has_trial()); + $this->assertSame(0, $cart->get_billing_start_date()); + + $expected_expiration = gmdate('Y-m-d 23:59:59', $cart->get_billing_next_charge_date()); + + $order_prop = $this->get_order_prop($reflection); + $order_prop->setValue($checkout, $cart); + + $customer_prop = $reflection->getProperty('customer'); + if (PHP_VERSION_ID < 80100) { + $customer_prop->setAccessible(true); + } + $customer_prop->setValue($checkout, $customer); + + $gateway_prop = $reflection->getProperty('gateway_id'); + if (PHP_VERSION_ID < 80100) { + $gateway_prop->setAccessible(true); + } + $gateway_prop->setValue($checkout, 'manual'); + + $result = $method->invoke($checkout); + + if (is_wp_error($result)) { + $this->markTestSkipped('Membership creation failed: ' . $result->get_error_message()); + } + + $this->assertInstanceOf(\WP_Ultimo\Models\Membership::class, $result); + $this->assertSame($expected_expiration, $result->get_date_expiration()); + $this->assertStringStartsNotWith('1970-', $result->get_date_expiration()); + + $event_payload = wu_generate_event_payload('membership', $result); + $this->assertSame($expected_expiration, $event_payload['membership_date_expiration']); + $this->assertSame( + date_i18n(get_option('date_format'), wu_date($expected_expiration)->format('U')), + $result->get_formatted_date('date_expiration') + ); + + $result->delete(); + $recurring_plan->delete(); + $order_prop->setValue($checkout, null); + } + // ------------------------------------------------------------------------- // maybe_create_payment — create new payment path // ------------------------------------------------------------------------- From 0719f58df6237de8614b9bad21623878617af5e1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 4 Aug 2026 11:32:15 -0600 Subject: [PATCH 2/3] fix(checkout): enforce pending signup timeout --- inc/checkout/class-checkout.php | 26 ++++---- tests/WP_Ultimo/Checkout/Checkout_Test.php | 72 ++++++++++++++++++---- 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index ce274507..0bad74a7 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -3537,7 +3537,7 @@ public function maybe_display_checkout_errors(): void { } /** - * Cleans up expired draft and pending payments (older than 30 days). + * Cleans up expired draft and pending payments (older than two hours). * * When a pending payment is cancelled, the associated membership is also * cancelled if it is still in the `pending` state. This ensures that any @@ -3551,21 +3551,23 @@ public function maybe_display_checkout_errors(): void { */ public function cleanup_expired_drafts(): void { - global $wpdb; - - $expired_date = gmdate('Y-m-d H:i:s', strtotime('-30 days')); + $expired_date_query = [ + 'column' => 'date_created', + 'before' => '-2 hours', + 'inclusive' => false, + ]; $expired_drafts = wu_get_payments( [ - 'status' => Payment_Status::DRAFT, - 'date_created__lt' => $expired_date, + 'status' => Payment_Status::DRAFT, + 'date_query' => $expired_date_query, ] ); $expired_pendings = wu_get_payments( [ - 'status' => Payment_Status::PENDING, - 'date_created__lt' => $expired_date, + 'status' => Payment_Status::PENDING, + 'date_query' => $expired_date_query, ] ); @@ -3576,10 +3578,10 @@ public function cleanup_expired_drafts(): void { /* * Also cancel the associated membership if it is still in - * `pending` state. A 30-day-old unconfirmed payment means the - * customer never completed the signup; keeping the membership - * in `pending` would leave any pending_site meta orphaned - * because no active membership owns it. Cancelling via + * `pending` state. An unconfirmed payment older than two hours + * means the customer did not complete the signup; keeping the + * membership in `pending` would leave any pending_site meta + * orphaned because no active membership owns it. Cancelling via * cancel() fires wu_transition_membership_status, which * invokes handle_pending_site_on_cancellation() to move the * pending_site to a 24-hour transient for potential reclaim diff --git a/tests/WP_Ultimo/Checkout/Checkout_Test.php b/tests/WP_Ultimo/Checkout/Checkout_Test.php index db97cefd..f43b282d 100644 --- a/tests/WP_Ultimo/Checkout/Checkout_Test.php +++ b/tests/WP_Ultimo/Checkout/Checkout_Test.php @@ -1418,11 +1418,56 @@ public function test_cleanup_expired_drafts_cancels_pending_payments(): void { $membership->delete(); } + /** + * Test cleanup_expired_drafts leaves recent pending payments untouched. + */ + public function test_cleanup_expired_drafts_leaves_recent_pending_payment_untouched(): void { + + $checkout = Checkout::get_instance(); + + $customer = self::$customer; + + $membership = wu_create_membership([ + 'customer_id' => $customer->get_id(), + 'plan_id' => 0, + 'status' => Membership_Status::PENDING, + ]); + + $this->assertNotWPError($membership); + + $payment = wu_create_payment([ + 'customer_id' => $customer->get_id(), + 'membership_id' => $membership->get_id(), + 'status' => Payment_Status::PENDING, + 'total' => 10, + ]); + + $this->assertNotWPError($payment); + + global $wpdb; + $recent_date = gmdate('Y-m-d H:i:s', strtotime('-1 hour')); + $wpdb->update( + "{$wpdb->prefix}wu_payments", + ['date_created' => $recent_date], + ['id' => $payment->get_id()] + ); + + $checkout->cleanup_expired_drafts(); + + $found_payment = wu_get_payment($payment->get_id()); + $this->assertNotFalse($found_payment, 'Recent pending payment should still exist after cleanup.'); + $this->assertSame(Payment_Status::PENDING, $found_payment->get_status(), 'Recent pending payment should remain pending.'); + + $found_membership = wu_get_membership($membership->get_id()); + $this->assertNotFalse($found_membership, 'Recent pending membership should still exist after cleanup.'); + $this->assertSame(Membership_Status::PENDING, $found_membership->get_status(), 'Recent pending membership should remain pending.'); + + $found_payment->delete(); + $found_membership->delete(); + } + /** * Test cleanup_expired_drafts runs without throwing exceptions. - * - * Note: The date_created__lt filter behaviour depends on BerlinDB query support. - * This test verifies the method completes without errors. */ public function test_cleanup_expired_drafts_completes_without_exception(): void { @@ -5796,9 +5841,9 @@ public function test_cleanup_expired_drafts_does_not_cancel_active_membership(): } /** - * GH#982: when cleanup_expired_drafts cancels a pending membership that has - * a pending_site, the pending_site must be removed from membership meta - * (via the wu_transition_membership_status hook chain). + * GH#982: when cleanup_expired_drafts cancels a pending payment older than + * two hours, its pending membership must be cancelled and pending_site must + * be removed through the wu_transition_membership_status hook chain. */ public function test_cleanup_expired_drafts_cleans_up_pending_site(): void { @@ -5832,7 +5877,7 @@ public function test_cleanup_expired_drafts_cleans_up_pending_site(): void { $this->assertNotWPError($payment); global $wpdb; - $old_date = gmdate('Y-m-d H:i:s', strtotime('-31 days')); + $old_date = gmdate('Y-m-d H:i:s', strtotime('-3 hours')); $wpdb->update( "{$wpdb->prefix}wu_payments", ['date_created' => $old_date], @@ -5841,18 +5886,19 @@ public function test_cleanup_expired_drafts_cleans_up_pending_site(): void { $checkout->cleanup_expired_drafts(); - // pending_site must be gone from the membership meta after cancellation. + $found_payment = wu_get_payment($payment->get_id()); + $this->assertNotFalse($found_payment, 'Expired pending payment should still exist after cleanup.'); + $this->assertSame(Payment_Status::CANCELLED, $found_payment->get_status(), 'Expired pending payment should be cancelled.'); + $found_membership = wu_get_membership($membership->get_id()); - $this->assertNotFalse($found_membership); + $this->assertNotFalse($found_membership, 'Membership should still exist after cleanup.'); + $this->assertSame(Membership_Status::CANCELLED, $found_membership->get_status(), 'Pending membership should be cancelled.'); $this->assertFalse( $found_membership->get_pending_site(), 'pending_site must be removed from membership meta after cleanup_expired_drafts cancels the membership (GH#982).' ); - $found_payment = wu_get_payment($payment->get_id()); - if ($found_payment) { - $found_payment->delete(); - } + $found_payment->delete(); $found_membership->delete(); } From 7e50d35a93238cf4e5ce1925da934cd2479d2082 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 4 Aug 2026 11:47:26 -0600 Subject: [PATCH 3/3] fix(checkout): retain core cleanup window --- inc/checkout/class-checkout.php | 6 +++--- tests/WP_Ultimo/Checkout/Checkout_Test.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 0bad74a7..e7cbcc37 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -3537,7 +3537,7 @@ public function maybe_display_checkout_errors(): void { } /** - * Cleans up expired draft and pending payments (older than two hours). + * Cleans up draft and pending payments strictly older than 30 days. * * When a pending payment is cancelled, the associated membership is also * cancelled if it is still in the `pending` state. This ensures that any @@ -3553,7 +3553,7 @@ public function cleanup_expired_drafts(): void { $expired_date_query = [ 'column' => 'date_created', - 'before' => '-2 hours', + 'before' => '-30 days', 'inclusive' => false, ]; @@ -3578,7 +3578,7 @@ public function cleanup_expired_drafts(): void { /* * Also cancel the associated membership if it is still in - * `pending` state. An unconfirmed payment older than two hours + * `pending` state. An unconfirmed payment older than 30 days * means the customer did not complete the signup; keeping the * membership in `pending` would leave any pending_site meta * orphaned because no active membership owns it. Cancelling via diff --git a/tests/WP_Ultimo/Checkout/Checkout_Test.php b/tests/WP_Ultimo/Checkout/Checkout_Test.php index f43b282d..5cac1ea0 100644 --- a/tests/WP_Ultimo/Checkout/Checkout_Test.php +++ b/tests/WP_Ultimo/Checkout/Checkout_Test.php @@ -5842,7 +5842,7 @@ public function test_cleanup_expired_drafts_does_not_cancel_active_membership(): /** * GH#982: when cleanup_expired_drafts cancels a pending payment older than - * two hours, its pending membership must be cancelled and pending_site must + * 30 days, its pending membership must be cancelled and pending_site must * be removed through the wu_transition_membership_status hook chain. */ public function test_cleanup_expired_drafts_cleans_up_pending_site(): void { @@ -5877,7 +5877,7 @@ public function test_cleanup_expired_drafts_cleans_up_pending_site(): void { $this->assertNotWPError($payment); global $wpdb; - $old_date = gmdate('Y-m-d H:i:s', strtotime('-3 hours')); + $old_date = gmdate('Y-m-d H:i:s', strtotime('-31 days')); $wpdb->update( "{$wpdb->prefix}wu_payments", ['date_created' => $old_date],