From 8d70da97664f7ff9df5f9850d9ce348397eb455b Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 27 Apr 2026 11:42:45 -0600 Subject: [PATCH] fix: prevent free memberships from expiring by treating them as lifetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When signing up for a free product, Cart::get_billing_start_date() correctly returns null (no billing needed). However, Checkout::maybe_create_membership() passed that null directly into gmdate(), which silently treated it as the current timestamp — setting date_expiration to today at 23:59:59. This caused free memberships to be picked up by the expiration cron and marked as expired within days of signup. The fix checks whether the billing start date is null before calling gmdate(). When null (free/non-recurring product), date_expiration is set to null, which Membership::is_lifetime() correctly identifies as a lifetime membership. The cron's expired-check query already excludes null expiration dates. Adds test: test_maybe_create_membership_free_product_has_null_expiration --- inc/checkout/class-checkout.php | 12 +++- tests/WP_Ultimo/Checkout/Checkout_Test.php | 84 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 610fc546..d27d46cc 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -1312,8 +1312,18 @@ 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. */ - $membership_data['date_expiration'] = gmdate('Y-m-d 23:59:59', $this->order->get_billing_start_date()); + $billing_start_date = $this->order->get_billing_start_date(); + + $membership_data['date_expiration'] = $billing_start_date + ? gmdate('Y-m-d 23:59:59', $billing_start_date) + : 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 98d2b13a..8e49af21 100644 --- a/tests/WP_Ultimo/Checkout/Checkout_Test.php +++ b/tests/WP_Ultimo/Checkout/Checkout_Test.php @@ -3510,6 +3510,90 @@ public function test_maybe_create_membership_creates_new(): void { $order_prop->setValue($checkout, null); } + /** + * Test maybe_create_membership sets null expiration for free products. + * + * Free, non-recurring products should produce a lifetime membership + * (date_expiration = null). Before the fix, gmdate() was called with + * null which resolved to "today 23:59:59", causing the membership to + * expire at end-of-day. + */ + public function test_maybe_create_membership_free_product_has_null_expiration(): void { + + $customer = self::$customer; + + $free_plan = wu_create_product([ + 'name' => 'Free Test Plan', + 'slug' => 'free-test-plan-' . wp_rand(1000, 9999), + 'amount' => 0, + 'recurring' => false, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'plan', + 'pricing_type' => 'free', + 'active' => true, + ]); + + if (is_wp_error($free_plan)) { + $this->markTestSkipped('Product creation failed: ' . $free_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' => [$free_plan->get_id()]]); + + // Verify the cart recognises this as free + $this->assertTrue($cart->is_free(), 'Cart should be free for a zero-cost plan'); + $this->assertNull($cart->get_billing_start_date(), 'Billing start date should be null for free plan'); + + $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, 'free'); + + $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); + + // The critical assertion: free membership must NOT have an expiration date + $this->assertNull( + $result->get_date_expiration(), + 'Free membership must have null date_expiration (lifetime). ' . + 'Got: ' . var_export($result->get_date_expiration(), true) + ); + + // Consequently, the membership should be identified as lifetime + $this->assertTrue( + $result->is_lifetime(), + 'Free membership must be recognised as lifetime' + ); + + // Cleanup + $result->delete(); + $free_plan->delete(); + $order_prop->setValue($checkout, null); + } + // ------------------------------------------------------------------------- // maybe_create_payment — create new payment path // -------------------------------------------------------------------------