Skip to content

Revert billing-period switch downgrades (PR #889, #893)#896

Merged
superdav42 merged 2 commits intomainfrom
bugfix/revert-period-switch
Apr 17, 2026
Merged

Revert billing-period switch downgrades (PR #889, #893)#896
superdav42 merged 2 commits intomainfrom
bugfix/revert-period-switch

Conversation

@superdav42
Copy link
Copy Markdown
Collaborator

@superdav42 superdav42 commented Apr 17, 2026

Summary

Reverts PR #889 and follow-up PR #893 which routed billing-period switches (monthly↔yearly) through the scheduled downgrade path.

Why revert

The scheduled swap system (Membership::swap()) only updates the local membership record — it does not modify or cancel the Stripe/PayPal subscription. A cross-period change (e.g. yearly → monthly) would:

  1. Leave the gateway subscription running on the old interval/amount
  2. Update the local membership to the new period
  3. At the next gateway renewal, charge the old amount on the old cycle — permanently out of sync

The original guard that blocked these changes was correct. The upgrade/downgrade swap path was designed for same-period plan changes where the gateway subscription interval stays the same.

What changed

Verification

All 117 Cart_Test tests pass (the 4 removed tests were specific to the reverted behaviour).

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Updated checkout validation for billing cycle changes. The system now prevents downgrading to shorter billing cycles when an active subscription exists.

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 superdav42 added the origin:interactive Created by interactive user session label Apr 17, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The billing period switch detection logic in build_from_membership() was refactored to block certain period switches early when an active membership exists with specific conditions, removing the need for raw rate preservation. Downgrade classification logic was simplified, and related test methods were removed.

Changes

Cohort / File(s) Summary
Billing Period Switch Logic
inc/checkout/class-cart.php
Modified build_from_membership() to add early return when membership is active, not free, old per-day rate is lower than new rate, and billing cycle is shorter. Removed raw rate preservation ($orig_*), simplified downgrade condition by removing shorter-cycle switch boolean, and streamlined get_billing_start_date() and get_billing_next_charge_date() status checks.
Test Cleanup
tests/WP_Ultimo/Checkout/Cart_Test.php
Removed four billing period switch test methods: monthly→yearly upgrade test, yearly→monthly scheduled downgrade test, cross-product period switch test, and null membership guard test.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #889: This PR directly reverses the scheduled-downgrade handling logic and removes tests that were introduced in that PR, making it a conflicting change to the previous billing period behavior.

Suggested labels

needs-maintainer-review

Poem

🐰 A hop through the checkout, a change in the flow,
Where billing period switches now answer with "no,"
Old tests fade away like spring's melting snow,
Shorter cycles blocked when rates start to grow,
Simpler logic shines where complexity used to grow!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main change: reverting billing-period switch downgrades introduced in PRs #889 and #893.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/revert-period-switch

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-cart.php`:
- Around line 2706-2712: The code dereferences $this->membership when
get_cart_type() === 'downgrade' (in the block that returns
strtotime($membership->get_date_expiration())) which can NPE if custom code sets
cart_type to 'downgrade' without calling build_from_membership(); add a
defensive null-guard: check that $this->membership is not null (and that methods
used on it like is_active() and get_date_expiration() exist/return expected
values) before dereferencing, and return the safe default (e.g., null or false)
when membership is null; apply the same guard to the other downgrade branch that
references $this->membership so both downgrade paths are safe even for
out-of-tree cart_type modifications, leaving build_from_membership() logic
unchanged.
🪄 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: 1092527b-89b5-45e7-8e35-31f8b882d436

📥 Commits

Reviewing files that changed from the base of the PR and between 742b58c and cb5e312.

📒 Files selected for processing (2)
  • inc/checkout/class-cart.php
  • tests/WP_Ultimo/Checkout/Cart_Test.php
💤 Files with no reviewable changes (1)
  • tests/WP_Ultimo/Checkout/Cart_Test.php

Comment on lines 2706 to 2712
if ($this->get_cart_type() === 'downgrade') {
$membership = $this->membership;

if ($membership && ($membership->is_active() || $membership->get_status() === Membership_Status::TRIALING)) {
if ($membership->is_active() || $membership->get_status() === Membership_Status::TRIALING) {
return strtotime($membership->get_date_expiration());
}
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify there are no other writers of $this->cart_type = 'downgrade' that bypass build_from_membership()
rg -nP --type=php "cart_type\s*=\s*['\"]downgrade['\"]" -C2
# And confirm no public setter exists for cart_type
rg -nP --type=php '\bset_cart_type\b' -C2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 510


🏁 Script executed:

sed -n '1350,1380p' inc/checkout/class-cart.php | cat -n

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1728


🏁 Script executed:

sed -n '1300,1375p' inc/checkout/class-cart.php | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 774


🏁 Script executed:

rg -n "function build_from_membership" inc/checkout/class-cart.php -A 5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 298


🏁 Script executed:

sed -n '788,822p' inc/checkout/class-cart.php | cat -n

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1233


🏁 Script executed:

rg -n "^\s*(public|protected|private)\s+function\b" inc/checkout/class-cart.php | awk -F: '{print $1}' | sort -n > /tmp/methods.txt && cat /tmp/methods.txt && echo "---" && sed -n '1371p' inc/checkout/class-cart.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 475


Null-guard revert removes safety check for out-of-tree cart_type modifications.

$this->membership is dereferenced directly in the cart_type === 'downgrade' branches at lines 2706-2712 and 2747-2755. The invariant that $this->membership is assigned before this code executes is maintained in-tree: build_from_membership() assigns the membership at line 822, and the only assignment of cart_type = 'downgrade' occurs within that method at line 1371. No public setter for cart_type exists.

However, custom integrations or filters that set cart_type = 'downgrade' without invoking build_from_membership() will now fatal-error on null dereference instead of returning a safe default. This is a conscious trade-off of the null-guard revert that should be accepted intentionally.

🤖 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 2706 - 2712, The code dereferences
$this->membership when get_cart_type() === 'downgrade' (in the block that
returns strtotime($membership->get_date_expiration())) which can NPE if custom
code sets cart_type to 'downgrade' without calling build_from_membership(); add
a defensive null-guard: check that $this->membership is not null (and that
methods used on it like is_active() and get_date_expiration() exist/return
expected values) before dereferencing, and return the safe default (e.g., null
or false) when membership is null; apply the same guard to the other downgrade
branch that references $this->membership so both downgrade paths are safe even
for out-of-tree cart_type modifications, leaving build_from_membership() logic
unchanged.

@github-actions
Copy link
Copy Markdown

Performance Test Results

Performance test results for 63e753b 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 40 37.80 MB 891.50 ms (+44.50 ms / +5% ) 171.50 ms 1084.50 ms (+39.50 ms / +4% ) 2066.00 ms 1975.30 ms 92.05 ms
1 56 49.03 MB 945.50 ms 147.00 ms 1091.00 ms 2078.00 ms 1997.40 ms 81.85 ms

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

Labels

origin:interactive Created by interactive user session

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant