Skip to content
Open
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
49 changes: 29 additions & 20 deletions inc/checkout/class-checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -3530,7 +3537,7 @@ public function maybe_display_checkout_errors(): void {
}

/**
* Cleans up expired draft and pending payments (older than 30 days).
* 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
Expand All @@ -3544,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' => '-30 days',
'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,
]
);

Expand All @@ -3569,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 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
* 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
Expand Down
150 changes: 138 additions & 12 deletions tests/WP_Ultimo/Checkout/Checkout_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -4024,6 +4069,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
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -5716,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
* 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 {

Expand Down Expand Up @@ -5761,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();
}

Expand Down
Loading