Skip to content

GH#888: fix(checkout): allow billing-period switches as scheduled downgrades#889

Merged
superdav42 merged 1 commit intomainfrom
feature/auto-20260416-102053-gh888
Apr 16, 2026
Merged

GH#888: fix(checkout): allow billing-period switches as scheduled downgrades#889
superdav42 merged 1 commit intomainfrom
feature/auto-20260416-102053-gh888

Conversation

@superdav42
Copy link
Copy Markdown
Collaborator

@superdav42 superdav42 commented Apr 16, 2026

Problem

Customers were unable to switch their membership billing period (e.g. monthly ↔ yearly) — they received the error "You already have an active yearly agreement."

Root cause: inc/checkout/class-cart.php lines 1347-1362 detected the pattern "old plan is cheaper-per-day AND has a longer billing cycle" (the standard yearly-vs-monthly signature) and hard-blocked with a WP_Error instead of scheduling the change. Both directions were affected:

  • Yearly → Monthly: blocked outright with the error message.
  • Monthly → Yearly (different plan product): sometimes classified as an upgrade, which triggered a prorate credit that exceeded the new plan price, resulting in a negative cart total and a Stripe error.

A secondary defensive bug: get_billing_next_charge_date() called $membership->is_active() without a null guard (unlike the identical code block in get_billing_start_date()), creating a potential fatal if $this->membership were null on a downgrade cart.

Fix

File modified: inc/checkout/class-cart.php

Change 1 — Remove hard-block, route to scheduled downgrade (lines 1347-1372)

Replaced the block that cleared products/line-items and added a no_changes error with a new $is_period_switch_to_shorter boolean:

$is_period_switch_to_shorter = ! $membership->is_free()
    && $days_in_old_cycle > $days_in_new_cycle
    && $old_price_per_day < $new_price_per_day;

This flag is then OR'd into the existing downgrade condition at line 1370 so the period switch is scheduled for the next renewal date — exactly like every other downgrade. Cart total becomes $0 (scheduled-swap credit cancels the new period price); no payment is collected until renewal.

Change 2 — Null guard in get_billing_next_charge_date() (line 2752)

// Before
if ($membership->is_active() || ...)
// After
if ($membership && ($membership->is_active() || ...))

Matches the guard already present in get_billing_start_date().

Testing

4 new tests added to tests/WP_Ultimo/Checkout/Cart_Test.php:

Test Assertion
test_monthly_to_yearly_period_switch_is_upgrade Monthly→Yearly on same plan produces no error and cart_type='upgrade'
test_yearly_to_monthly_period_switch_is_scheduled_downgrade Yearly→Monthly on same plan produces no error, cart_type='downgrade', total=$0
test_yearly_plan_to_monthly_plan_different_products_is_downgrade Different plans, yearly→monthly: no error, cart_type='downgrade', total=$0
test_get_billing_next_charge_date_null_membership_guard Null membership on downgrade-type cart does not fatal

All 121 Cart_Test tests pass (117 pre-existing + 4 new).

Resolves #888


aidevops.sh v3.8.59 plugin for OpenCode v1.4.6 with claude-sonnet-4-6 spent 34m and 91,180 tokens on this as a headless worker.

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Improved handling of membership plan downgrades when switching to shorter billing periods.
    • Enhanced cart calculations to accurately classify period switches and apply appropriate pricing adjustments.
    • Increased stability in cart operations with improved null-safety checks during plan changes.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 16, 2026

Warning

Rate limit exceeded

@superdav42 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 41 minutes and 37 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 41 minutes and 37 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d14ec2a4-8565-43f5-b465-e4b396e3fe84

📥 Commits

Reviewing files that changed from the base of the PR and between f2e28e5 and 146eb9f.

📒 Files selected for processing (2)
  • inc/checkout/class-cart.php
  • tests/WP_Ultimo/Checkout/Cart_Test.php
📝 Walkthrough

Walkthrough

This pull request fixes billing period switching validation in the checkout cart. Previously, switching from longer to shorter billing cycles was blocked entirely. The changes replace this rigid check with nuanced logic that classifies such switches as downgrades, and improve null-safety when accessing membership data during billing calculations.

Changes

Cohort / File(s) Summary
Core Billing Period Switch Logic
inc/checkout/class-cart.php
Replaced hard-block validation that rejected longer-to-shorter billing cycle switches with computed flag $is_period_switch_to_shorter. Updated downgrade classification to recognize this scenario alongside same-product and different-product price comparisons. Added null-safety guard for membership access in get_billing_next_charge_date().
Test Coverage for Period Switching
tests/WP_Ultimo/Checkout/Cart_Test.php
Added four test methods covering monthly↔yearly period switches on same and different products, verifying correct error handling and downgrade classification. Included regression test for get_billing_next_charge_date() with null membership guard.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

A rabbit hops through billing tides,
Where cycles long and short collide,
No more shall switches block the way—
Downgrades flow and plans can sway! 🐰💳

🚥 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 and specifically identifies the main change: allowing billing-period switches to be scheduled downgrades (Issue #888), which directly addresses the primary objective of the changeset.
Linked Issues check ✅ Passed The code changes fully address Issue #888 objectives: removing the hard-block on billing-period switches, routing period switches as scheduled downgrades, adding null-safety to get_billing_next_charge_date(), and comprehensive test coverage for the implemented behavior.
Out of Scope Changes check ✅ Passed All changes are directly scoped to Issue #888: modifications to cart.php billing-period logic, null-safety guard, and four new tests specifically validating period-switching scenarios with no unrelated alterations present.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/auto-20260416-102053-gh888

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: 2

🤖 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-cart.php`:
- Around line 1362-1372: The is_period_switch_to_shorter check is using
$old_price_per_day/$new_price_per_day which may have been normalized by
search_for_same_period_plans(), so preserve the true per-day rates before any
normalization and use those originals for the shorter-period override: capture
the raw rates (e.g. $orig_old_price_per_day and $orig_new_price_per_day) before
calling search_for_same_period_plans(), then change the
is_period_switch_to_shorter calculation to compare $orig_old_price_per_day <
$orig_new_price_per_day (alongside the existing $days_in_old_cycle >
$days_in_new_cycle and ! $membership->is_free()), and keep the rest of the
downgrade logic (the if that checks $is_same_product, $membership->get_amount(),
$this->get_recurring_total(), and $old_price_per_day > $new_price_per_day)
unchanged so only the shorter-period detection uses the preserved original
rates.

In `@tests/WP_Ultimo/Checkout/Cart_Test.php`:
- Around line 3036-3054: The test
test_get_billing_next_charge_date_null_membership_guard currently never hits the
downgrade branch because the Cart remains 'new'; modify the test so the Cart is
forced into the 'downgrade' path while leaving its membership null before
calling get_billing_next_charge_date(). Concretely, after creating the Cart (or
in its constructor args) set the cart_type to 'downgrade' (e.g. $cart->cart_type
= 'downgrade' or include 'cart_type' => 'downgrade') and ensure no membership is
provided, then assert that $cart->get_billing_next_charge_date() returns an int.
This ensures the explicit null guard in get_billing_next_charge_date() (the
method name) is exercised.
🪄 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: 3135f5ba-ef19-4983-9d1d-083438ca75ac

📥 Commits

Reviewing files that changed from the base of the PR and between edc1ea0 and f2e28e5.

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

Comment on lines +1362 to +1372
$is_period_switch_to_shorter = ! $membership->is_free()
&& $days_in_old_cycle > $days_in_new_cycle
&& $old_price_per_day < $new_price_per_day;

/*
* If is the same product and the customer will start to pay less
* or if is not the same product and the price per day is smaller
* this is a downgrade
* If is the same product and the customer will start to pay less,
* or if is not the same product and the price per day is smaller,
* or if the customer is switching to a shorter billing cycle,
* this is a downgrade.
*/
if (($is_same_product && $membership->get_amount() > $this->get_recurring_total()) || (! $is_same_product && $old_price_per_day > $new_price_per_day)) {
if (($is_same_product && $membership->get_amount() > $this->get_recurring_total()) || (! $is_same_product && $old_price_per_day > $new_price_per_day) || $is_period_switch_to_shorter) {
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 | 🟠 Major

Use the real old/new cycle rates for the shorter-period override.

$old_price_per_day and $new_price_per_day may already have been rewritten by search_for_same_period_plans() to compare same-duration variants, so this flag can miss genuine yearly→monthly downgrades across different products. A yearly membership on Plan A switching to a monthly Plan B with the same monthly sticker price is one example: the normalized rates become equal, cart_type stays 'upgrade', and the scheduled-renewal flow in inc/gateways/class-stripe-checkout-gateway.php:340-360 is skipped.

Proposed fix
 $days_in_old_cycle = wu_get_days_in_cycle($membership->get_duration_unit(), $membership->get_duration());
 $days_in_new_cycle = wu_get_days_in_cycle($this->duration_unit, $this->duration);
 
+$old_cycle_price_per_day = $days_in_old_cycle > 0 ? $membership->get_amount() / $days_in_old_cycle : $membership->get_amount();
+$new_cycle_price_per_day = $days_in_new_cycle > 0 ? $this->get_recurring_total() / $days_in_new_cycle : $this->get_recurring_total();
+
 $old_price_per_day = $days_in_old_cycle > 0 ? $membership->get_amount() / $days_in_old_cycle : $membership->get_amount();
 $new_price_per_day = $days_in_new_cycle > 0 ? $this->get_recurring_total() / $days_in_new_cycle : $this->get_recurring_total();
 ...
 $is_period_switch_to_shorter = ! $membership->is_free()
 	&& $days_in_old_cycle > $days_in_new_cycle
-	&& $old_price_per_day < $new_price_per_day;
+	&& $old_cycle_price_per_day < $new_cycle_price_per_day;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/checkout/class-cart.php` around lines 1362 - 1372, The
is_period_switch_to_shorter check is using $old_price_per_day/$new_price_per_day
which may have been normalized by search_for_same_period_plans(), so preserve
the true per-day rates before any normalization and use those originals for the
shorter-period override: capture the raw rates (e.g. $orig_old_price_per_day and
$orig_new_price_per_day) before calling search_for_same_period_plans(), then
change the is_period_switch_to_shorter calculation to compare
$orig_old_price_per_day < $orig_new_price_per_day (alongside the existing
$days_in_old_cycle > $days_in_new_cycle and ! $membership->is_free()), and keep
the rest of the downgrade logic (the if that checks $is_same_product,
$membership->get_amount(), $this->get_recurring_total(), and $old_price_per_day
> $new_price_per_day) unchanged so only the shorter-period detection uses the
preserved original rates.

Comment on lines +3036 to +3054
public function test_get_billing_next_charge_date_null_membership_guard() {
// Build a cart with cart_type=downgrade but no membership_id.
// The cart will default to type 'new' internally (no membership), but
// we can force the scenario by temporarily mocking; instead just verify
// that the guard at get_billing_next_charge_date does not throw on a
// null membership (regression test for the explicit null check added).
$plan = $this->create_plan(['amount' => 30.00]);

$cart = new Cart([
'products' => [$plan->get_id()],
'duration' => 1,
'duration_unit' => 'month',
]);

// Call get_billing_next_charge_date() on a cart that has no membership.
// Before the fix this would fatal with "Call to member function is_active() on null"
// if cart_type was forced to 'downgrade'. With the null guard it must not throw.
$this->assertIsInt($cart->get_billing_next_charge_date());

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 | 🟠 Major

This regression test never exercises the null-guarded branch.

The cart stays 'new', so get_billing_next_charge_date() skips the downgrade path entirely and falls through to the product-based calculation. If the null check were removed, this test would still pass. Force cart_type to 'downgrade' while keeping membership null before making the assertion.

Proposed fix
 public function test_get_billing_next_charge_date_null_membership_guard() {
 	$plan = $this->create_plan(['amount' => 30.00]);
 
 	$cart = new Cart([
 		'products'      => [$plan->get_id()],
 		'duration'      => 1,
 		'duration_unit' => 'month',
 	]);
 
+	$this->assertNull($cart->get_membership());
+
+	$cart_type = new \ReflectionProperty(Cart::class, 'cart_type');
+	$cart_type->setAccessible(true);
+	$cart_type->setValue($cart, 'downgrade');
+
 	$this->assertIsInt($cart->get_billing_next_charge_date());
 
 	$plan->delete();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Test.php` around lines 3036 - 3054, The test
test_get_billing_next_charge_date_null_membership_guard currently never hits the
downgrade branch because the Cart remains 'new'; modify the test so the Cart is
forced into the 'downgrade' path while leaving its membership null before
calling get_billing_next_charge_date(). Concretely, after creating the Cart (or
in its constructor args) set the cart_type to 'downgrade' (e.g. $cart->cart_type
= 'downgrade' or include 'cart_type' => 'downgrade') and ensure no membership is
provided, then assert that $cart->get_billing_next_charge_date() returns an int.
This ensures the explicit null guard in get_billing_next_charge_date() (the
method name) is exercised.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 16, 2026

Performance Test Results

Performance test results for ca6b21d 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 37.80 MB 842.00 ms (-55.50 ms / -7% ) 157.50 ms 1092.50 ms (-26.00 ms / -2% ) 2136.00 ms 2022.85 ms 96.30 ms (+3.95 ms / +4% )
1 56 49.03 MB 979.50 ms 153.00 ms 1135.50 ms 2178.00 ms 2088.55 ms 87.20 ms (+1.90 ms / +2% )

Previously switching from a longer billing cycle (yearly) to a shorter
one (monthly) was blocked with 'You already have an active yearly
agreement.' regardless of whether the customer had the same plan or a
different one. This prevented legitimate billing-period changes.

Root cause: class-cart.php lines 1347-1362 detected 'old_price_per_day
< new_price_per_day && old_cycle_days > new_cycle_days' and hard-blocked
with a WP_Error instead of scheduling the change.

Fix:
- Remove the hard-block. Add $is_period_switch_to_shorter flag that
  covers the same condition and merges it into the existing downgrade
  branch (lines 1370+). The change is now scheduled for the next renewal
  date exactly like any other downgrade — no payment collected, cart
  total $0.
- Fix defensive null guard at get_billing_next_charge_date() line 2752:
  add null check on $membership before calling ->is_active(), matching
  the guard already present in get_billing_start_date() line 2709.

Tests: 4 new tests in Cart_Test covering monthly→yearly upgrade,
yearly→monthly downgrade (same plan), yearly→monthly downgrade (different
plan), and null-membership guard on get_billing_next_charge_date().
All 121 Cart_Test tests pass.

Fixes #888
@superdav42 superdav42 force-pushed the feature/auto-20260416-102053-gh888 branch from f2e28e5 to 146eb9f Compare April 16, 2026 17:13
@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

@superdav42 superdav42 added wontfix This will not be worked on awaiting response needs-maintainer-review Requires maintainer review before proceeding do-not-merge PR should not be merged automatically by the pulse and removed wontfix This will not be worked on awaiting response labels Apr 16, 2026
@superdav42
Copy link
Copy Markdown
Collaborator Author

Resume check — all systems green

Session reconnected to verify state after connection drop. Summary:

All CI checks now passing:

  • PHP 8.2, 8.3, 8.4, 8.5 ✅
  • E2E tests (Cypress 8.1, 8.2 / Chrome) ✅
  • Code Quality, PHP Lint ✅
  • WP Performance Metrics ✅
  • CodeRabbit pre-merge checks (5/5) ✅
  • Build artifact ✅

Housekeeping done:

Remaining before merge:

  • needs-maintainer-review + do-not-merge are intentional — this fix touches billing-critical cart logic. A human approval is required before the pulse can merge.

The fix itself is complete: billing-period switches (monthly↔yearly) are now routed as scheduled downgrades rather than hard-blocked with an error. Four regression tests added, all passing.


aidevops.sh v3.8.59 plugin for OpenCode v1.4.6 with claude-sonnet-4-6 spent 2m and 7,074 tokens on this as a headless worker.

@superdav42 superdav42 merged commit d666041 into main Apr 16, 2026
11 checks passed
superdav42 added a commit that referenced this pull request Apr 17, 2026
Reverts the changes from PR #889 (GH#888) and follow-up PR #893
that routed billing-period switches (monthly<->yearly) through the
scheduled downgrade path.

The swap system only updates the local membership record - it does not
modify or cancel the Stripe/PayPal subscription. A period change would
leave the gateway subscription on the old interval while the local
membership thinks it changed, causing payment mismatches at renewal.

Restores the original guard that blocks cross-period changes, with an
improved error message suggesting plan variations instead of the
previous unhelpful 'You already have an active X agreement' text.
superdav42 added a commit that referenced this pull request Apr 17, 2026
Reverts the changes from PR #889 (GH#888) and follow-up PR #893
that routed billing-period switches (monthly<->yearly) through the
scheduled downgrade path.

The swap system only updates the local membership record - it does not
modify or cancel the Stripe/PayPal subscription. A period change would
leave the gateway subscription on the old interval while the local
membership thinks it changed, causing payment mismatches at renewal.

Restores the original guard that blocks cross-period changes, with an
improved error message suggesting plan variations instead of the
previous unhelpful 'You already have an active X agreement' text.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge PR should not be merged automatically by the pulse needs-maintainer-review Requires maintainer review before proceeding origin:worker

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable for customers to upgrade or downgrade plans

1 participant