diff --git a/tests/WP_Ultimo/Checkout/Cart_Test.php b/tests/WP_Ultimo/Checkout/Cart_Test.php index 8aa4c1706..1c6b728f4 100644 --- a/tests/WP_Ultimo/Checkout/Cart_Test.php +++ b/tests/WP_Ultimo/Checkout/Cart_Test.php @@ -2638,6 +2638,227 @@ public function test_negative_setup_fee_creates_credit_line_item() { $this->assertEquals(40.00, $cart->get_total()); } + // ========================================================================= + // REACTIVATION CART TESTS (PR #751 / issue #814) + // ========================================================================= + + /** + * Helper: create a cancelled membership owned by self::$customer. + * + * @param array $overrides Optional attribute overrides. + * @return \WP_Ultimo\Models\Membership + */ + private function create_cancelled_membership($overrides = []) { + $plan = $this->create_plan( + [ + 'amount' => 50.00, + 'setup_fee' => 0, + ] + ); + + $defaults = [ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => $plan->get_id(), + 'status' => 'cancelled', + 'amount' => 50.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'date_cancellation' => wu_get_current_time('mysql'), + ]; + + return wu_create_membership(array_merge($defaults, $overrides)); + } + + /** + * Test that a cart with a cancelled membership gets cart_type = 'reactivation'. + * + * When build_from_membership() detects a cancelled/expired membership, it must + * override the default 'upgrade' cart_type to 'reactivation'. + */ + public function test_reactivation_cart_type_set_for_cancelled_membership() { + $customer = self::$customer; + $membership = $this->create_cancelled_membership(); + + wp_set_current_user($customer->get_user_id(), $customer->get_username()); + + $cart = new Cart([ + 'membership_id' => $membership->get_id(), + ]); + + $this->assertSame('reactivation', $cart->get_cart_type()); + + $membership->delete(); + } + + /** + * Test that reactivation carts do not include a signup fee line item. + * + * The signup fee should only apply to new subscriptions. Charging it again on + * reactivation would double-charge customers who previously paid it. + */ + public function test_reactivation_cart_no_signup_fee() { + $customer = self::$customer; + + $plan = $this->create_plan( + [ + 'amount' => 50.00, + 'setup_fee' => 25.00, + ] + ); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'plan_id' => $plan->get_id(), + 'status' => 'cancelled', + 'amount' => 50.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + ] + ); + + wp_set_current_user($customer->get_user_id(), $customer->get_username()); + + $cart = new Cart([ + 'membership_id' => $membership->get_id(), + ]); + + $this->assertSame('reactivation', $cart->get_cart_type()); + + $fee_items = $cart->get_line_items_by_type('fee'); + $this->assertEmpty($fee_items, 'Reactivation carts must not include a signup fee line item'); + + $membership->delete(); + } + + /** + * Test that has_trial() returns false for reactivation carts. + * + * A returning customer is not eligible for a trial on reactivation. Allowing + * a trial would let customers game the system by repeatedly cancelling and + * reactivating to receive free trial periods. + */ + public function test_reactivation_cart_has_trial_returns_false() { + $customer = self::$customer; + + $plan = $this->create_plan( + [ + 'amount' => 50.00, + 'trial_duration' => 14, + 'trial_duration_unit' => 'day', + ] + ); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'plan_id' => $plan->get_id(), + 'status' => 'cancelled', + 'amount' => 50.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + ] + ); + + wp_set_current_user($customer->get_user_id(), $customer->get_username()); + + $cart = new Cart([ + 'membership_id' => $membership->get_id(), + ]); + + $this->assertSame('reactivation', $cart->get_cart_type()); + $this->assertFalse($cart->has_trial(), 'has_trial() must return false for reactivation carts'); + + $membership->delete(); + } + + /** + * Test that get_billing_start_date() returns null for reactivation carts. + * + * Billing starts immediately on reactivation. A non-null billing start date + * would cause Stripe/PayPal to treat the subscription as starting with a + * trial period, which is incorrect for reactivations. + */ + public function test_reactivation_cart_billing_start_date_is_null() { + $customer = self::$customer; + + $plan = $this->create_plan( + [ + 'amount' => 50.00, + 'trial_duration' => 14, + 'trial_duration_unit' => 'day', + ] + ); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'plan_id' => $plan->get_id(), + 'status' => 'cancelled', + 'amount' => 50.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + ] + ); + + wp_set_current_user($customer->get_user_id(), $customer->get_username()); + + $cart = new Cart([ + 'membership_id' => $membership->get_id(), + ]); + + $this->assertSame('reactivation', $cart->get_cart_type()); + $this->assertNull($cart->get_billing_start_date(), 'get_billing_start_date() must return null for reactivation carts so no trial period is applied'); + + $membership->delete(); + } + + /** + * Test that reactivation carts rebuild products from the original membership. + * + * The reactivation path must always use the plan from the cancelled membership, + * ignoring any products supplied in the request. This prevents product injection. + */ + public function test_reactivation_cart_rebuilds_products_from_membership() { + $customer = self::$customer; + + $original_plan = $this->create_plan(['amount' => 50.00]); + $injected_plan = $this->create_plan(['amount' => 1.00]); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'plan_id' => $original_plan->get_id(), + 'status' => 'cancelled', + 'amount' => 50.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + ] + ); + + wp_set_current_user($customer->get_user_id(), $customer->get_username()); + + // Attempt to inject a different (cheaper) product via the request. + $cart = new Cart([ + 'membership_id' => $membership->get_id(), + 'products' => [$injected_plan->get_id()], + ]); + + $this->assertSame('reactivation', $cart->get_cart_type()); + + $plan = $cart->get_plan(); + $this->assertNotNull($plan, 'Reactivation cart must have a plan'); + $this->assertSame($original_plan->get_id(), $plan->get_id(), 'Reactivation cart must use the original membership plan, not a user-supplied product'); + + $membership->delete(); + $injected_plan->delete(); + } + public static function tear_down_after_class() { global $wpdb; self::$customer->delete(); diff --git a/tests/WP_Ultimo/Models/Membership_Test.php b/tests/WP_Ultimo/Models/Membership_Test.php index 261ccdd7f..6e3581053 100644 --- a/tests/WP_Ultimo/Models/Membership_Test.php +++ b/tests/WP_Ultimo/Models/Membership_Test.php @@ -1349,6 +1349,154 @@ public function test_renew_no_plan(): void { $this->assertFalse($result); } + // --------------------------------------------------------------- + // Reactivation Tests (PR #751 / issue #814) + // --------------------------------------------------------------- + + /** + * Test renew() does NOT clear date_cancellation on a regular active-to-active renewal. + * + * Guards against the regression where renew() unconditionally cleared date_cancellation + * on any renewal that resulted in active status, destroying historical cancellation records. + */ + public function test_renew_preserves_cancellation_date_on_active_renewal(): void { + $now = wu_get_current_time('mysql'); + + $this->membership->set_status(Membership_Status::ACTIVE); + $this->membership->set_date_cancellation($now); + $this->membership->set_recurring(true); + $this->membership->set_amount(29.99); + $this->membership->set_skip_validation(true); + + $result = $this->membership->renew(true, 'active'); + + $this->assertTrue($result); + $this->assertSame($now, $this->membership->get_date_cancellation(), 'date_cancellation must not be cleared when renewing an already-active membership'); + } + + /** + * Test renew() DOES clear date_cancellation when reactivating a cancelled membership. + * + * renew() is called by reactivate(), and also directly by gateways via IPN/webhook. + * It must clear the cancellation timestamp when the previous status was CANCELLED + * so that cancelled membership records are cleaned up in a single save. + */ + public function test_renew_clears_cancellation_date_for_cancelled_membership(): void { + $now = wu_get_current_time('mysql'); + + $this->membership->set_status(Membership_Status::CANCELLED); + $this->membership->set_date_cancellation($now); + $this->membership->set_recurring(true); + $this->membership->set_amount(29.99); + $this->membership->set_skip_validation(true); + + $result = $this->membership->renew(true, 'active'); + + $this->assertTrue($result); + $this->assertNull($this->membership->get_date_cancellation(), 'date_cancellation must be cleared when renewing a cancelled membership to active'); + } + + /** + * Test reactivate() clears date_cancellation and sets membership to active. + */ + public function test_reactivate_clears_cancellation_date(): void { + $now = wu_get_current_time('mysql'); + + $this->membership->set_status(Membership_Status::CANCELLED); + $this->membership->set_date_cancellation($now); + $this->membership->set_recurring(true); + $this->membership->set_amount(29.99); + $this->membership->set_skip_validation(true); + + $result = $this->membership->reactivate(true); + + $this->assertTrue($result); + $this->assertSame('active', $this->membership->get_status()); + $this->assertNull($this->membership->get_date_cancellation(), 'reactivate() must clear date_cancellation'); + } + + /** + * Test reactivate() fires wu_membership_pre_reactivate action. + */ + public function test_reactivate_fires_pre_reactivate_hook(): void { + $this->membership->set_status(Membership_Status::CANCELLED); + $this->membership->set_recurring(true); + $this->membership->set_amount(29.99); + $this->membership->set_skip_validation(true); + + $pre_fired = false; + $captured_id = 0; + + add_action( + 'wu_membership_pre_reactivate', + function($id) use (&$pre_fired, &$captured_id) { + $pre_fired = true; + $captured_id = $id; + } + ); + + $this->membership->reactivate(true); + + $this->assertTrue($pre_fired, 'wu_membership_pre_reactivate must fire before reactivation'); + $this->assertSame($this->membership->get_id(), $captured_id); + } + + /** + * Test reactivate() fires wu_membership_post_reactivate action on success. + */ + public function test_reactivate_fires_post_reactivate_hook_on_success(): void { + $this->membership->set_status(Membership_Status::CANCELLED); + $this->membership->set_recurring(true); + $this->membership->set_amount(29.99); + $this->membership->set_skip_validation(true); + + $post_fired = false; + $captured_id = 0; + + add_action( + 'wu_membership_post_reactivate', + function($id) use (&$post_fired, &$captured_id) { + $post_fired = true; + $captured_id = $id; + } + ); + + $result = $this->membership->reactivate(true); + + $this->assertTrue($result); + $this->assertTrue($post_fired, 'wu_membership_post_reactivate must fire after successful reactivation'); + $this->assertSame($this->membership->get_id(), $captured_id); + } + + /** + * Test reactivate() does not fire wu_membership_post_reactivate when renew() fails. + * + * Simulates a renew() failure by clearing plan_id (renew() returns false immediately + * when plan_id is empty). Verifies wu_membership_post_reactivate is NOT fired. + */ + public function test_reactivate_does_not_fire_post_hook_on_failure(): void { + // Deliberately remove the plan_id so renew() returns false. + $this->membership->set_plan_id(0); + $this->membership->set_status(Membership_Status::CANCELLED); + $this->membership->set_recurring(true); + $this->membership->set_amount(29.99); + $this->membership->set_skip_validation(true); + + $post_fired = false; + + add_action( + 'wu_membership_post_reactivate', + function() use (&$post_fired) { + $post_fired = true; + } + ); + + $result = $this->membership->reactivate(true); + + $this->assertFalse($result, 'reactivate() must return false when renew() fails due to missing plan'); + $this->assertFalse($post_fired, 'wu_membership_post_reactivate must NOT fire when reactivation fails'); + } + // --------------------------------------------------------------- // Meta Constants Tests // ---------------------------------------------------------------