Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions tests/WP_Ultimo/Checkout/Cart_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
148 changes: 148 additions & 0 deletions tests/WP_Ultimo/Models/Membership_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------
Expand Down
Loading