Skip to content

fix: prevent free memberships from expiring by treating them as lifetime#957

Merged
superdav42 merged 1 commit intomainfrom
bugfix/free-membership-expiration
Apr 27, 2026
Merged

fix: prevent free memberships from expiring by treating them as lifetime#957
superdav42 merged 1 commit intomainfrom
bugfix/free-membership-expiration

Conversation

@superdav42
Copy link
Copy Markdown
Collaborator

@superdav42 superdav42 commented Apr 27, 2026

Summary

  • Bug: Free membership signups were expiring within hours/days instead of being treated as lifetime
  • Root cause: Checkout::maybe_create_membership() passed null (from Cart::get_billing_start_date()) into gmdate(), which silently used the current timestamp — setting date_expiration to today at 23:59:59
  • Fix: Check for null billing start date before calling gmdate(); when null (free product), set date_expiration to null so the membership is correctly identified as lifetime

Details

The bug trace

  1. Cart::get_billing_start_date() correctly returns null for free, non-recurring products
  2. Checkout::maybe_create_membership() ran gmdate('Y-m-d 23:59:59', null) — PHP treats null as current time
  3. Membership created with date_expiration = "today 23:59:59"
  4. Membership::is_lifetime() returns false (expiration is non-empty)
  5. Cron membership_expired_check() picks it up after 3-day grace period → membership marked EXPIRED

The fix

$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;

When date_expiration is null:

  • Membership::is_lifetime()true
  • Cron explicitly excludes null dates from the expired-check query (date_expiration__not_in => [null, '0000-00-00 00:00:00'])

Files changed

  • inc/checkout/class-checkout.php — fix in maybe_create_membership()
  • tests/WP_Ultimo/Checkout/Checkout_Test.php — new test test_maybe_create_membership_free_product_has_null_expiration

Testing

  • New test verifies free membership gets null expiration and is_lifetime() === true
  • 332 Cart/Checkout tests pass (0 failures, 1 pre-existing skip)
  • 107 Membership tests pass (0 failures)

Note on existing free memberships

Existing free memberships that have already been marked as expired will need to be manually reactivated. The admin can do this from the membership edit screen.


aidevops.sh v3.13.1 plugin for OpenCode v1.3.17 with gemma4:e4b spent 7d and 43,427 tokens on this as a headless worker.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed membership expiration handling for free, non-recurring products. Lifetime memberships now correctly have no expiration date instead of expiring immediately.
  • Tests

    • Added regression test to validate lifetime membership creation and expiration handling for free products.

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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

The changes fix a bug where free memberships were incorrectly expiring immediately due to improper null handling of billing start dates. The maybe_create_membership() method now conditionally sets expiration dates only when a billing start date exists, otherwise storing null to represent lifetime memberships. A new regression test validates this behavior.

Changes

Cohort / File(s) Summary
Membership Expiration Logic
inc/checkout/class-checkout.php
Modified maybe_create_membership() to conditionally compute date_expiration: stores gmdate() formatted date only when billing start date is truthy; otherwise assigns null for lifetime memberships instead of computing expiration on null values.
Regression Test
tests/WP_Ultimo/Checkout/Checkout_Test.php
Added test_maybe_create_membership_free_product_has_null_expiration() to validate that free, non-recurring products create lifetime memberships with null expiration dates, including test setup with reflection-based method invocation and cleanup.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

needs-maintainer-review, origin:worker

Poem

🐰 A membership that never did expire,
No more expiring when it shouldn't, a fix we desire,
Free products now live forever bright,
With null dates shining in the light,
Lifetime memberships done right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preventing free memberships from expiring by treating them as lifetime, which directly addresses the bug fixed in the code.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/free-membership-expiration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/checkout/class-checkout.php`:
- Around line 1324-1326: The ternary for assigning
$membership_data['date_expiration'] uses a truthy check on $billing_start_date
which treats 0/'0' as no date; change the condition to an explicit null check
(e.g. compare $billing_start_date !== null) so that when $billing_start_date is
zero it still calls gmdate('Y-m-d 23:59:59', $billing_start_date) and only
assigns null when $billing_start_date is actually null; update the expression
where $membership_data['date_expiration'] is set (the line that calls gmdate
with $billing_start_date) accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 701bc47f-6542-444a-8469-95d116094c0e

📥 Commits

Reviewing files that changed from the base of the PR and between 9911f4b and 8d70da9.

📒 Files selected for processing (2)
  • inc/checkout/class-checkout.php
  • tests/WP_Ultimo/Checkout/Checkout_Test.php

Comment on lines +1324 to +1326
$membership_data['date_expiration'] = $billing_start_date
? gmdate('Y-m-d 23:59:59', $billing_start_date)
: null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use an explicit null check for date_expiration assignment.

Using a truthy check can incorrectly treat 0/'0' as “no date” and set a lifetime membership. Compare against null explicitly.

Suggested fix
-		$membership_data['date_expiration'] = $billing_start_date
-			? gmdate('Y-m-d 23:59:59', $billing_start_date)
+		$membership_data['date_expiration'] = null !== $billing_start_date
+			? gmdate('Y-m-d 23:59:59', (int) $billing_start_date)
 			: null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$membership_data['date_expiration'] = $billing_start_date
? gmdate('Y-m-d 23:59:59', $billing_start_date)
: null;
$membership_data['date_expiration'] = null !== $billing_start_date
? gmdate('Y-m-d 23:59:59', (int) $billing_start_date)
: null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/checkout/class-checkout.php` around lines 1324 - 1326, The ternary for
assigning $membership_data['date_expiration'] uses a truthy check on
$billing_start_date which treats 0/'0' as no date; change the condition to an
explicit null check (e.g. compare $billing_start_date !== null) so that when
$billing_start_date is zero it still calls gmdate('Y-m-d 23:59:59',
$billing_start_date) and only assigns null when $billing_start_date is actually
null; update the expression where $membership_data['date_expiration'] is set
(the line that calls gmdate with $billing_start_date) accordingly.

@github-actions
Copy link
Copy Markdown

Performance Test Results

Performance test results for 592d5db are in 🛎️!

Note: the numbers in parentheses show the difference to the previous (baseline) test run. Differences below 2% or 0.5 in absolute values are not shown.

URL: /

Run DB Queries Memory Before Template Template WP Total LCP TTFB LCP - TTFB
0 41 (-8 / -20% ) 37.83 MB (-5.60 MB / -15% ) 790.00 ms (-57.00 ms / -7% ) 160.00 ms (+22.00 ms / +14% ) 966.50 ms (-42.00 ms / -4% ) 1884.00 ms (-106.00 ms / -6% ) 1793.05 ms (-96.70 ms / -5% ) 77.65 ms (-2.90 ms / -4% )
1 56 49.03 MB 939.00 ms (+34.00 ms / +4% ) 138.50 ms 1077.00 ms (+41.00 ms / +4% ) 2028.00 ms 1955.65 ms 73.15 ms (-2.30 ms / -3% )

@superdav42 superdav42 merged commit 73e1448 into main Apr 27, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant