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 // -------------------------------------------------------------------------