Skip to content

fix(checkout): correct addon pricing to only charge for new products#354

Open
superdav42 wants to merge 5 commits intomainfrom
fix/addon-pricing-prorate-bug
Open

fix(checkout): correct addon pricing to only charge for new products#354
superdav42 wants to merge 5 commits intomainfrom
fix/addon-pricing-prorate-bug

Conversation

@superdav42
Copy link
Collaborator

@superdav42 superdav42 commented Mar 5, 2026

Summary

Fixes the bug where adding addon services to an existing membership was incorrectly charging customers for the next billing period in advance instead of only charging for the addon.

Before: Customer adding a €5 addon to a €90/month membership was charged ~€89.59
After: Customer is only charged €5 for the addon

Problem Description

When customers tried to add addon services through the Account section, the cart pricing calculation was:

  1. Adding the existing plan at full price (€90)
  2. Adding the new addon product (€5)
  3. Subtracting a small pro-rata credit based on unused days (~€5.41 if 2 days into cycle)
  4. Result: Total of €89.59 instead of just €5

This happened because the system was treating addon purchases like plan upgrades, charging for the "next" billing period and then applying credits, rather than just charging for the new addon.

Changes Made

1. Fixed Pro-rata Application for Addon Purchases

  • Changed wu_cart_addon_include_existing_plan filter default from true to false
  • Addon purchases now only charge for new addon products
  • Existing plan continues to be billed on its regular subscription schedule at the gateway level
  • Pro-rata credits are now only applied when actually changing plans (upgrades/downgrades)

Files modified: inc/checkout/class-cart.php:876-948, inc/checkout/class-cart.php:984-1060

2. Added Discount Code Application to Addons

  • Membership discount codes with apply_to_renewals enabled are now applied to addon purchases
  • Ensures consistent pricing for customers with recurring discounts
  • Fixes customer report: "it doesn't seem to be taking existing discount into consideration"

Files modified: inc/checkout/class-cart.php:892-903, inc/checkout/class-cart.php:1001-1012

3. Explicit Plan Removal in Edge Cases

  • In the second addon detection path (when product count > 1 with no plan change), explicitly removes the plan from the cart
  • Prevents accidental plan charges in edge cases
  • Resets plan_id to 0 for addon-only carts

Files modified: inc/checkout/class-cart.php:1014-1038

Testing

Added comprehensive unit tests in tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php:

  • test_addon_only_charges_for_addon_product() - Verifies €5 addon only charges €5
  • test_addon_applies_existing_discount_code() - Verifies discount codes are applied
  • test_addon_filter_can_include_existing_plan() - Tests backward compatibility filter
  • test_plan_upgrade_still_uses_prorate() - Ensures upgrades/downgrades still work correctly
  • test_addon_does_not_reapply_setup_fees() - Verifies setup fee handling

Backward Compatibility

The wu_cart_addon_include_existing_plan filter allows sites to revert to the old behavior if needed for specific use cases:

add_filter('wu_cart_addon_include_existing_plan', '__return_true');

However, the old behavior was incorrect for most use cases, so the new default is more appropriate.

Migration Notes

No migration needed. Existing subscriptions and memberships are unaffected. This only changes the behavior for new addon purchases going forward.

Related Issues

Fixes customer-reported issues:

  • "My customer tried the addon service and it seems to miscalculate pricing"
  • "It's doing membership + setup fee again and adding credit but the subtotal is off"
  • "it doesn't seem to be taking existing discount into consideration"
  • Customer seeing €89.59 total when adding €5 addon

Checklist

  • Code follows WordPress Coding Standards (PHPCS)
  • Added comprehensive unit tests
  • No breaking changes to existing functionality
  • Backward compatibility filter provided
  • Comments explain the "why" behind the changes
  • Commit message follows Conventional Commits format

Summary by CodeRabbit

  • Bug Fixes

    • Addons now exclude the existing plan by default (no unintended prorated credits) and preserve original billing.
    • Removes prorate credits for addon-only purchases and prevents duplicate setup fees.
    • Membership discount codes now apply to addon purchases.
  • New Features

    • Configurable option to include the existing plan when adding addons (restores prior behavior).
  • Tests

    • Comprehensive tests for addon pricing, discounts, setup fees, upgrades, and template ID type handling.

…_templates

Fixes #351 - Template validation failing with 'The selected template is not
available for this product' error.

Root cause: Template IDs stored as string keys in the limitations array were
not being converted to integers, causing type mismatch with the validation
rule which uses absint() on the submitted template ID.

Changes:
- Updated get_available_site_templates() to convert site_id to integer
- Updated get_pre_selected_site_template() to convert site_id to integer
- Added regression test to ensure template IDs are returned as integers
- Test validates strict type checking with in_array(..., true)

This ensures consistent integer comparison in the Site_Template validation
rule (inc/helpers/validation-rules/class-site-template.php:104).
Previously, when customers added addon services to their existing membership,
the cart was incorrectly charging for the next billing period in advance:
- Added the existing plan at full price (€90)
- Added the new addon (€5)
- Subtracted a small pro-rata credit (~€5.41 for 2 days used)
- Result: Customer charged €89.59 instead of just €5

This commit fixes three related bugs:

1. **Pro-rata applied incorrectly for addon purchases**
   - Changed wu_cart_addon_include_existing_plan filter default from true to false
   - Addon purchases now only charge for the new addon products
   - Existing plan continues to be billed on its regular subscription schedule
   - Pro-rata credits are only applied when actually changing plans (upgrades/downgrades)

2. **Existing discount codes not applied to addons**
   - Now applies membership discount codes to addon purchases when apply_to_renewals is enabled
   - Ensures consistent pricing for customers with recurring discounts

3. **Plan removal from addon-only carts**
   - In the second addon detection path (product count > 1 with no plan change),
     explicitly removes the plan from products and line_items
   - Prevents accidental plan charges in edge cases

Includes comprehensive unit tests covering:
- Addon-only pricing (should only charge for addon)
- Discount code application to addons
- Filter override capability for backward compatibility
- Plan upgrade/downgrade still using pro-rata correctly
- Setup fee handling for addon products

Fixes customer-reported issue where €5 addon was charging €89.59
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

Adds addon-specific cart behavior: by default, addon-only carts remove existing plans and skip prorate credits; introduces a filter to re-include plans, applies membership discount codes to addons (with a private helper to reapply discounts), normalizes site template IDs, updates membership swap logic, and adds tests.

Changes

Cohort / File(s) Summary
Addon Pricing & Cart Logic
inc/checkout/class-cart.php
Adds addon-path logic: default removes existing plan and skips prorate credits for addon-only carts; introduces wu_cart_addon_include_existing_plan filter (default false) to optionally re-add existing plan and prorate; reads/applies membership discount codes; adds private reapply_discounts_to_existing_line_items() helper.
Membership Swap Behavior
inc/models/class-membership.php
Makes swap() addon-aware: preserves existing addon_products and avoids overwriting totals/duration for addon carts; clears addon_products only for non-addon carts.
Stripe Gateway Update
inc/gateways/class-base-stripe-gateway.php
Replaces deprecated coupon parameter with discounts array when updating Stripe subscriptions, preserving previous conditional behavior.
Site Template ID Normalization
inc/limitations/class-limit-site-templates.php
Normalizes template/site IDs using absint() when collecting available templates and determining pre-selected template.
Addon Pricing Tests
tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php
New PHPUnit test class with fixtures and tests for addon-only charges, discount application to addons, filter-driven inclusion of existing plan (prorate behavior), plan upgrades (prorate), and addon setup fee handling.
Limitations Regression Test
tests/WP_Ultimo/Objects/Limitations_Test.php
Adds test ensuring get_available_site_templates() returns integer IDs (duplicate test method present).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Cart
    participant Membership
    participant Billing
    User->>Cart: add addon product
    Cart->>Membership: get_discount_code() & validate membership renewals
    Membership-->>Cart: discount object (if valid)
    alt wu_cart_addon_include_existing_plan = true
        Cart->>Cart: re-add existing plan line items
        Cart->>Cart: calculate prorate credits
        Cart->>Cart: reapply_discounts_to_existing_line_items()
    else wu_cart_addon_include_existing_plan = false
        Cart->>Cart: remove existing plan line items
        Cart->>Cart: skip prorate credits
    end
    Cart->>Billing: recalc totals, taxes, apply discounts
    Billing-->>User: present final cart total
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~28 minutes

Poem

🐰 I hopped into carts with a curious peep,
Addons now bill while discounts leap,
Plans may stay or softly part,
Credits counted — or skipped — from the start,
Tests thump my paw; the code takes a peep!

🚥 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 accurately summarizes the main fix: correcting addon pricing to charge only for new products instead of including the existing plan.
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 (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/addon-pricing-prorate-bug

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

github-actions bot commented Mar 5, 2026

🔨 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
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: 3

🧹 Nitpick comments (2)
tests/WP_Ultimo/Objects/Limitations_Test.php (1)

914-950: Add direct assertion for pre-selected template integer typing.

This test fully covers get_available_site_templates(), but it doesn’t directly lock the new get_pre_selected_site_template() integer normalization path.

Suggested test extension
 public function test_available_site_templates_returns_integers(): void {
@@
 		$this->assertTrue(in_array(123, $available, true), 'in_array with strict=true should find integer 123');
 		$this->assertTrue(in_array(456, $available, true), 'in_array with strict=true should find integer 456');
+
+		$pre_selected = $limitations->site_templates->get_pre_selected_site_template();
+		$this->assertIsInt($pre_selected, 'Pre-selected template ID should be integer');
+		$this->assertSame(456, $pre_selected, 'Pre-selected template ID should match expected integer');
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Objects/Limitations_Test.php` around lines 914 - 950, Extend
the test to also exercise the integer normalization path by calling
Limitations->site_templates->get_pre_selected_site_template() and asserting it
returns the pre-selected template as an integer (assertIsInt) and equals 456 (or
assertSame(456, ...)/in_array with strict=true); this ensures
get_pre_selected_site_template() normalizes string keys like '456' into an int
and preserves strict comparisons alongside the existing
get_available_site_templates() checks.
tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php (1)

178-203: Use try/finally for filter cleanup to avoid test leakage.

If an assertion fails before Line 202, the filter remains active and can affect subsequent tests.

💡 Proposed fix
 	public function test_addon_filter_can_include_existing_plan() {
 		// Add filter to force inclusion of existing plan
 		add_filter('wu_cart_addon_include_existing_plan', '__return_true');
-
-		$cart = new Cart([
-			'customer_id'   => self::$customer->get_id(),
-			'membership_id' => self::$membership->get_id(),
-			'products'      => [self::$addon->get_id()],
-		]);
-
-		// Should have 2 product line items (plan + addon)
-		$line_items = $cart->get_line_items();
-		$product_line_items = array_filter($line_items, function($item) {
-			return $item->get_type() === 'product';
-		});
-
-		$this->assertCount(2, $product_line_items, 'Should have 2 product line items when filter returns true');
-
-		// Should have a pro-rata credit line item
-		$credit_line_items = array_filter($line_items, function($item) {
-			return $item->get_type() === 'credit';
-		});
-		$this->assertGreaterThan(0, count($credit_line_items), 'Should have pro-rata credit when plan is included');
-
-		// Remove filter
-		remove_filter('wu_cart_addon_include_existing_plan', '__return_true');
+		try {
+			$cart = new Cart([
+				'customer_id'   => self::$customer->get_id(),
+				'membership_id' => self::$membership->get_id(),
+				'products'      => [self::$addon->get_id()],
+			]);
+
+			// Should have 2 product line items (plan + addon)
+			$line_items = $cart->get_line_items();
+			$product_line_items = array_filter($line_items, function($item) {
+				return $item->get_type() === 'product';
+			});
+
+			$this->assertCount(2, $product_line_items, 'Should have 2 product line items when filter returns true');
+
+			// Should have a pro-rata credit line item
+			$credit_line_items = array_filter($line_items, function($item) {
+				return $item->get_type() === 'credit';
+			});
+			$this->assertGreaterThan(0, count($credit_line_items), 'Should have pro-rata credit when plan is included');
+		} finally {
+			remove_filter('wu_cart_addon_include_existing_plan', '__return_true');
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 178 - 203,
Wrap the section that adds the filter and exercises the cart in a try/finally so
the filter is always removed even if an assertion fails: call
add_filter('wu_cart_addon_include_existing_plan', '__return_true') before
creating the Cart and computing
$line_items/$product_line_items/$credit_line_items, then put the assertions
inside the try block and call
remove_filter('wu_cart_addon_include_existing_plan', '__return_true') in the
finally block to guarantee cleanup; reference the existing
add_filter/remove_filter calls and the Cart instantiation to locate where to
wrap the code.
🤖 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 892-903: The membership discount is being set after addon products
are added (add_product), so existing line items aren't repriced; add a private
helper method reapply_discounts_to_existing_line_items() in the Cart class that
iterates $this->line_items, skips non-discountable items (is_discountable()),
re-runs apply_discounts_to_item() and, if is_taxable(), apply_taxes_to_item(),
and writes back each item to $this->line_items; then call this helper
immediately after add_discount_code($membership_discount_code) wherever you set
the membership discount (the current hunk and the similar block around lines
~1008-1012) so addons already in the cart are repriced.
- Around line 1023-1037: When removing the existing plan in class-cart.php
(inside the foreach over $this->products where
wu_is_plan_type($product->get_type()) && $product->get_id() ===
$membership->get_plan_id()), also remove any residual fee line items tied to
that plan: iterate $this->line_items and unset entries where get_product_id()
=== $product->get_id() OR where get_type() === 'fee' and metadata indicates
association with that product/plan; additionally ensure the filter added later
(around the product insertion path) is applied earlier or replicate its logic
here so fees are cleaned when the plan product is removed; keep resetting
$this->plan_id = 0 and breaking as before.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php`:
- Line 1: Add the missing PHPDoc and fix multiline call formatting to satisfy
PHPCS: add a file-level docblock at the top, add class and all test method
docblocks for the Cart_Addon_Pricing_Test class and each test method referenced
(ensure summary, `@covers/`@group tags as appropriate), and reformat any multiline
function/method calls across lines to match the project's coding standard
(aligned parentheses, one argument per line where required, and proper
indentation). Update the docblocks and formatting for the methods and calls
covering the ranges mentioned (lines ~30–73) so the file passes PHPCS checks.

---

Nitpick comments:
In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php`:
- Around line 178-203: Wrap the section that adds the filter and exercises the
cart in a try/finally so the filter is always removed even if an assertion
fails: call add_filter('wu_cart_addon_include_existing_plan', '__return_true')
before creating the Cart and computing
$line_items/$product_line_items/$credit_line_items, then put the assertions
inside the try block and call
remove_filter('wu_cart_addon_include_existing_plan', '__return_true') in the
finally block to guarantee cleanup; reference the existing
add_filter/remove_filter calls and the Cart instantiation to locate where to
wrap the code.

In `@tests/WP_Ultimo/Objects/Limitations_Test.php`:
- Around line 914-950: Extend the test to also exercise the integer
normalization path by calling
Limitations->site_templates->get_pre_selected_site_template() and asserting it
returns the pre-selected template as an integer (assertIsInt) and equals 456 (or
assertSame(456, ...)/in_array with strict=true); this ensures
get_pre_selected_site_template() normalizes string keys like '456' into an int
and preserves strict comparisons alongside the existing
get_available_site_templates() checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a7b07357-253f-404b-8969-b200b56e0eda

📥 Commits

Reviewing files that changed from the base of the PR and between fa58d9d and 2ccfcc9.

📒 Files selected for processing (4)
  • inc/checkout/class-cart.php
  • inc/limitations/class-limit-site-templates.php
  • tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php
  • tests/WP_Ultimo/Objects/Limitations_Test.php

1. Add reapply_discounts_to_existing_line_items() helper method
   - Discounts were not being applied to addons already in cart
   - Helper reapplies discounts and taxes to all discountable line items
   - Called after add_discount_code() in both addon paths

2. Fix plan removal to also remove fee line items
   - When removing existing plan from addon cart, also remove 'fee' line items
   - Changed line item type check from 'product' only to ['product', 'fee']
   - Prevents accidental setup fee charges for removed plans

3. Fix PHPCS violations in test file
   - Added file-level docblock
   - Added docblocks for all class properties
   - Reformatted multiline array calls to match coding standards
   - Used array() instead of [] and proper indentation

4. Add try/finally for filter cleanup in tests
   - Wrapped test_addon_filter_can_include_existing_plan() in try/finally
   - Ensures filter is always removed even if assertions fail
   - Prevents test leakage to other test methods

These changes address all CodeRabbit review comments while maintaining
the core functionality of the addon pricing fix.
@github-actions
Copy link

github-actions bot commented Mar 5, 2026

🔨 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
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

♻️ Duplicate comments (2)
tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php (1)

76-131: ⚠️ Potential issue | 🟠 Major

PHPCS issues are still present in this test file and will keep CI red.

The multiline call formatting and alignment problems reported around Line 76-131, Line 153, and Line 199 should be fixed before merge.

💡 Example formatting fix pattern
-		self::$customer = wu_create_customer([
+		self::$customer = wu_create_customer(
+			[
 			'username' => 'testuser_addon_pricing',
 			'email'    => 'addon_pricing@example.com',
 			'password' => 'password123',
-		]);
+			]
+		);

Also applies to: 153-153, 199-199

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 76 - 131,
The PHP CS (PHPCS) failures are due to multiline array argument
formatting/alignment in the test setup calls—fix the formatting of the
wu_create_customer, wu_create_product (both plan and addon),
wu_create_discount_code, and wu_create_membership calls so each array key =>
value pair is on its own line, aligned and indented consistently with the
project's coding standard (e.g., PSR-12), and remove any trailing comma/spacing
issues; specifically adjust the argument arrays passed to wu_create_customer,
the two wu_create_product calls, wu_create_discount_code, and
wu_create_membership (which references Membership_Status::ACTIVE and wu_date())
to match the same multiline indentation and alignment pattern used elsewhere in
the test suite.
inc/checkout/class-cart.php (1)

1045-1057: ⚠️ Potential issue | 🟠 Major

Late wu_apply_signup_fee filter registration is ineffective and leaks globally.

Line 1056 registers a global filter after addon products were already added, so it does not affect this cart path and can affect subsequent cart operations in the same request.

💡 Proposed fix
-		/*
-		 * For addon purchases, only apply setup fees to NEW addon products.
-		 * Skip setup fees if the membership has already been billed at least once,
-		 * as the plan's setup fee was already paid.
-		 *
-		 * Note: Products were already added to the cart above (line 840), so setup
-		 * fees for addons have already been processed. This filter mainly affects
-		 * any future product additions in this request.
-		 *
-		 * `@since` 2.0.12
-		 */
-		add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0);
+		// No signup-fee filter here: products were already added above.
🤖 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 1045 - 1057, The
wu_apply_signup_fee filter is registered too late (after addons were added) and
left active for the rest of the request; change it so the filter is applied at
the correct time and is scoped/removed immediately afterwards: instead of an
anonymous one-off add_filter('wu_apply_signup_fee', fn() =>
$membership->get_times_billed() <= 0) at the end of this block, either (A) move
the filter registration to before addons/products are added so it affects the
addon setup-fee calculation (use the same callback logic referencing
$membership->get_times_billed()), or (B) register the callback to a variable or
named function (e.g., $apply_signup_fee_cb or function
apply_signup_fee_for_membership) before adding addons and then call
remove_filter('wu_apply_signup_fee', $apply_signup_fee_cb) right after addon
processing to avoid leaking the filter globally; update code references around
add_filter('wu_apply_signup_fee', ...), membership->get_times_billed(), and the
addon/product addition logic to implement this.
🧹 Nitpick comments (1)
tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php (1)

143-147: customer_id in cart args is ignored; tests should set customer context explicitly.

Cart setup does not consume customer_id, so these tests depend on ambient current-user state. Please set the current customer/user explicitly in fixture setup and avoid passing unsupported cart args.

Also applies to: 187-191, 276-280, 313-317

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 143 - 147,
The Cart constructor ignores the 'customer_id' argument causing tests to rely on
global current-user state; update the fixture setup to explicitly set the
current user/context before creating Cart instances (e.g., call the test helper
that sets the WP/current customer user to self::$customer->get_id() or use
wp_set_current_user with self::$customer->get_id()) and remove/stop relying on
the unsupported 'customer_id' cart arg in the Cart(...) calls (affects Cart
creation at the shown location and also the instances at the other ranges
referenced).
🤖 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 899-904: The code calls $membership->get_discount_code() and
immediately invokes ->should_apply_to_renewals(), which can crash if
get_discount_code() returns a string or false; update the checks around
membership->get_discount_code() (used at the shown block and the other
occurrence around lines ~1009-1014) to first verify the returned value is an
object of the expected discount class (or at minimum is_object() and
method_exists('should_apply_to_renewals')) before calling
->should_apply_to_renewals(), and only then call
$this->add_discount_code($membership_discount_code) and
$this->reapply_discounts_to_existing_line_items().

---

Duplicate comments:
In `@inc/checkout/class-cart.php`:
- Around line 1045-1057: The wu_apply_signup_fee filter is registered too late
(after addons were added) and left active for the rest of the request; change it
so the filter is applied at the correct time and is scoped/removed immediately
afterwards: instead of an anonymous one-off add_filter('wu_apply_signup_fee',
fn() => $membership->get_times_billed() <= 0) at the end of this block, either
(A) move the filter registration to before addons/products are added so it
affects the addon setup-fee calculation (use the same callback logic referencing
$membership->get_times_billed()), or (B) register the callback to a variable or
named function (e.g., $apply_signup_fee_cb or function
apply_signup_fee_for_membership) before adding addons and then call
remove_filter('wu_apply_signup_fee', $apply_signup_fee_cb) right after addon
processing to avoid leaking the filter globally; update code references around
add_filter('wu_apply_signup_fee', ...), membership->get_times_billed(), and the
addon/product addition logic to implement this.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php`:
- Around line 76-131: The PHP CS (PHPCS) failures are due to multiline array
argument formatting/alignment in the test setup calls—fix the formatting of the
wu_create_customer, wu_create_product (both plan and addon),
wu_create_discount_code, and wu_create_membership calls so each array key =>
value pair is on its own line, aligned and indented consistently with the
project's coding standard (e.g., PSR-12), and remove any trailing comma/spacing
issues; specifically adjust the argument arrays passed to wu_create_customer,
the two wu_create_product calls, wu_create_discount_code, and
wu_create_membership (which references Membership_Status::ACTIVE and wu_date())
to match the same multiline indentation and alignment pattern used elsewhere in
the test suite.

---

Nitpick comments:
In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php`:
- Around line 143-147: The Cart constructor ignores the 'customer_id' argument
causing tests to rely on global current-user state; update the fixture setup to
explicitly set the current user/context before creating Cart instances (e.g.,
call the test helper that sets the WP/current customer user to
self::$customer->get_id() or use wp_set_current_user with
self::$customer->get_id()) and remove/stop relying on the unsupported
'customer_id' cart arg in the Cart(...) calls (affects Cart creation at the
shown location and also the instances at the other ranges referenced).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e269727-048d-460d-a8b2-14f8b7a7518f

📥 Commits

Reviewing files that changed from the base of the PR and between 2ccfcc9 and 00e1f16.

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

1. Fix get_discount_code() type safety
   - get_discount_code() can return Discount_Code object, string, or false
   - Added is_object() and method_exists() checks before calling should_apply_to_renewals()
   - Applied to both addon detection paths (lines 899-907, 1018-1026)
   - Prevents fatal errors if discount code is not an object

2. Fix test fixture creation errors
   - Added missing required fields: pricing_type, currency, active
   - Added is_wp_error() checks with self::fail() for all fixtures
   - Prevents TypeError when wu_create_* functions return WP_Error
   - Changed all array literals from [] to array() for PHPCS compliance

3. Test improvements
   - All Cart instantiations now use array() instead of []
   - All multiline arrays properly formatted with proper indentation
   - Added docblock for tear_down_after_class()

These fixes address:
- TypeError: Cannot assign WP_Error to property (test failures)
- CodeRabbit suggestion about discount code type safety
- PHPCS violations for array syntax

Tests should now pass successfully.
@github-actions
Copy link

github-actions bot commented Mar 5, 2026

🔨 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
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: 3

♻️ Duplicate comments (3)
inc/checkout/class-cart.php (2)

903-913: ⚠️ Potential issue | 🟠 Major

Resolve string membership discount codes before renewal checks.

These blocks only apply discounts when get_discount_code() returns an object. If it returns a string code (which is a valid path), addon discounts are skipped.

💡 Suggested fix
 		$membership_discount_code = $membership->get_discount_code();
+		if (is_string($membership_discount_code)) {
+			$membership_discount_code = wu_get_discount_code_by_code($membership_discount_code);
+		}

 		if (
-			$membership_discount_code
-			&& is_object($membership_discount_code)
-			&& method_exists($membership_discount_code, 'should_apply_to_renewals')
+			$membership_discount_code instanceof \WP_Ultimo\Models\Discount_Code
 			&& $membership_discount_code->should_apply_to_renewals()
 		) {
 			$this->add_discount_code($membership_discount_code);
 			$this->reapply_discounts_to_existing_line_items();
 		}

Also applies to: 1022-1032

🤖 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 903 - 913, The code only handles
membership discount codes when membership->get_discount_code() returns an
object, skipping valid string codes; update the renewal check to resolve string
discount codes into discount objects before inspecting
should_apply_to_renewals(): call get_discount_code(), if it's a string use the
existing discount resolution logic (the same conversion used elsewhere when
adding codes) to obtain the discount object, then check method_exists($discount,
'should_apply_to_renewals') and $discount->should_apply_to_renewals() and only
then call $this->add_discount_code($discount) and
$this->reapply_discounts_to_existing_line_items(); apply the same fix to the
other occurrence that mirrors this block (the second get_discount_code() usage).

1063-1074: ⚠️ Potential issue | 🟠 Major

Remove late wu_apply_signup_fee filter in this addon edge-path.

At this point, product insertion already happened, so this filter does not affect current line items and can bleed into unrelated operations later in the same request.

💡 Suggested fix
-		/*
-		 * For addon purchases, only apply setup fees to NEW addon products.
-		 * Skip setup fees if the membership has already been billed at least once,
-		 * as the plan's setup fee was already paid.
-		 *
-		 * Note: Products were already added to the cart above (line 840), so setup
-		 * fees for addons have already been processed. This filter mainly affects
-		 * any future product additions in this request.
-		 *
-		 * `@since` 2.0.12
-		 */
-		add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0);
🤖 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 1063 - 1074, The late
add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0)
is registered after products were already added and can leak into unrelated work
later in the same request; make this a scoped/temporary filter by registering it
only around the code that needs it (or store the closure in a variable and call
remove_filter('wu_apply_signup_fee', $closure) immediately after the
product-insert logic), or replace it with a direct conditional when creating
addon items so you don't register the global 'wu_apply_signup_fee' filter at
all; locate the add_filter call referencing wu_apply_signup_fee and
membership->get_times_billed() and ensure it is removed or confined to the
immediate operation.
tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php (1)

84-85: ⚠️ Potential issue | 🟠 Major

Fix PHPCS-reported spacing/alignment to keep checks green.

Current style around is_wp_error(...) and the discount-code array still violates the configured standard.

💡 Suggested fix
-		if ( is_wp_error( self::$customer ) ) {
+		if (is_wp_error(self::$customer)) {
 			self::fail( 'Failed to create test customer' );
 		}
@@
-		if ( is_wp_error( self::$plan ) ) {
+		if (is_wp_error(self::$plan)) {
 			self::fail( 'Failed to create test plan' );
 		}
@@
-		if ( is_wp_error( self::$addon ) ) {
+		if (is_wp_error(self::$addon)) {
 			self::fail( 'Failed to create test addon' );
 		}
@@
-				'name'                => 'Test Discount',
-				'code'                => 'TEST10',
-				'value'               => 10,
-				'type'                => 'percentage',
-				'uses'                => 0,
-				'max_uses'            => 100,
-				'apply_to_renewals'   => true,
+				'name' => 'Test Discount',
+				'code' => 'TEST10',
+				'value' => 10,
+				'type' => 'percentage',
+				'uses' => 0,
+				'max_uses' => 100,
+				'apply_to_renewals' => true,

Also applies to: 105-106, 126-126, 133-139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 84 - 85,
Fix PHPCS spacing/alignment issues by normalizing the conditional and array
formatting: remove or add spaces inside function-call parentheses to match the
project standard for is_wp_error checks (update the occurrences using
is_wp_error and self::$customer), and reformat the discount-code array entries
so keys, arrows (=>) and values are consistently aligned and indented according
to the coding standard; apply the same adjustments to the other occurrences
noted (the other is_wp_error checks and the discount-code array blocks in this
test class) so PHPCS passes.
🧹 Nitpick comments (1)
inc/checkout/class-cart.php (1)

1085-1085: Drop stale commented-out code marker.

Prefer a normal explanatory comment over commented executable code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/checkout/class-cart.php` at line 1085, Remove the stale commented-out
code marker "$this->calculate_prorate_credits();" in class-cart.php and replace
it with a short explanatory comment describing why prorate credit calculation
was removed or where that logic now lives (e.g., mention the current method or
hook responsible for prorate handling), so future readers understand intent
instead of seeing commented executable code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php`:
- Around line 311-325: The test dereferences products returned by
wu_create_product (e.g., $upgraded_plan and $addon_with_fee) without checking
for creation errors; add is_wp_error() guards immediately after each
wu_create_product call (for $upgraded_plan and $addon_with_fee) and handle
failures by failing or skipping the test (use $this->fail(...) or
$this->markTestSkipped(...)) with a clear message so subsequent dereferences are
safe; locate the calls to wu_create_product in the test and insert the
is_wp_error check and failure/skip logic for each created product.
- Around line 72-73: In set_up_before_class and the other test locations that
build membership-change carts, ensure the test sets the WordPress current user
to the intended customer so Cart sanitization honors the customer_id: create the
customer user via the test factory (or use the existing test user IDs) and call
wp_set_current_user($customer_id) (or wp_set_current_user(self::$customer_id))
immediately before instantiating new Cart objects; alternatively, if Cart has a
static sanitization helper (e.g., Cart::sanitize_args or Cart::__construct),
call wp_set_current_user first so Cart's customer_id logic sees the correct
current user.
- Around line 76-87: The typed static properties (self::$customer, self::$plan,
self::$addon, self::$discount_code, self::$membership) are being assigned the
raw results of factory functions (wu_create_customer, wu_create_product,
wu_create_membership, wu_create_discount_code) which may return WP_Error and
will cause a TypeError; instead call each factory into a local variable first,
check is_wp_error(localVar) and call self::fail(...) on error, then assign the
verified value to the typed static property. Update all occurrences where these
factories are directly assigned (the blocks around the current assignments) to
follow this guard-then-assign pattern.

---

Duplicate comments:
In `@inc/checkout/class-cart.php`:
- Around line 903-913: The code only handles membership discount codes when
membership->get_discount_code() returns an object, skipping valid string codes;
update the renewal check to resolve string discount codes into discount objects
before inspecting should_apply_to_renewals(): call get_discount_code(), if it's
a string use the existing discount resolution logic (the same conversion used
elsewhere when adding codes) to obtain the discount object, then check
method_exists($discount, 'should_apply_to_renewals') and
$discount->should_apply_to_renewals() and only then call
$this->add_discount_code($discount) and
$this->reapply_discounts_to_existing_line_items(); apply the same fix to the
other occurrence that mirrors this block (the second get_discount_code() usage).
- Around line 1063-1074: The late add_filter('wu_apply_signup_fee', fn() =>
$membership->get_times_billed() <= 0) is registered after products were already
added and can leak into unrelated work later in the same request; make this a
scoped/temporary filter by registering it only around the code that needs it (or
store the closure in a variable and call remove_filter('wu_apply_signup_fee',
$closure) immediately after the product-insert logic), or replace it with a
direct conditional when creating addon items so you don't register the global
'wu_apply_signup_fee' filter at all; locate the add_filter call referencing
wu_apply_signup_fee and membership->get_times_billed() and ensure it is removed
or confined to the immediate operation.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php`:
- Around line 84-85: Fix PHPCS spacing/alignment issues by normalizing the
conditional and array formatting: remove or add spaces inside function-call
parentheses to match the project standard for is_wp_error checks (update the
occurrences using is_wp_error and self::$customer), and reformat the
discount-code array entries so keys, arrows (=>) and values are consistently
aligned and indented according to the coding standard; apply the same
adjustments to the other occurrences noted (the other is_wp_error checks and the
discount-code array blocks in this test class) so PHPCS passes.

---

Nitpick comments:
In `@inc/checkout/class-cart.php`:
- Line 1085: Remove the stale commented-out code marker
"$this->calculate_prorate_credits();" in class-cart.php and replace it with a
short explanatory comment describing why prorate credit calculation was removed
or where that logic now lives (e.g., mention the current method or hook
responsible for prorate handling), so future readers understand intent instead
of seeing commented executable code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2072d25c-a7b1-442b-933e-f98c5178295e

📥 Commits

Reviewing files that changed from the base of the PR and between 00e1f16 and 1153360.

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

Comment on lines +72 to +73
public static function set_up_before_class() {
parent::set_up_before_class();
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Set current user before building membership-change carts.

These tests rely on membership ownership checks, but user context is never set. Also, customer_id cart arg is ignored by Cart arg sanitization.

💡 Suggested fix
 class Cart_Addon_Pricing_Test extends WP_UnitTestCase {
+	public function set_up() {
+		parent::set_up();
+		wp_set_current_user(self::$customer->get_user_id(), self::$customer->get_username());
+	}

Also applies to: 178-185, 231-237, 270-276, 327-333, 374-380

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 72 - 73,
In set_up_before_class and the other test locations that build membership-change
carts, ensure the test sets the WordPress current user to the intended customer
so Cart sanitization honors the customer_id: create the customer user via the
test factory (or use the existing test user IDs) and call
wp_set_current_user($customer_id) (or wp_set_current_user(self::$customer_id))
immediately before instantiating new Cart objects; alternatively, if Cart has a
static sanitization helper (e.g., Cart::sanitize_args or Cart::__construct),
call wp_set_current_user first so Cart's customer_id logic sees the correct
current user.

Comment on lines +76 to +87
self::$customer = wu_create_customer(
array(
'username' => 'testuser_addon_pricing',
'email' => 'addon_pricing@example.com',
'password' => 'password123',
)
);

if ( is_wp_error( self::$customer ) ) {
self::fail( 'Failed to create test customer' );
}

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check file size first
echo "=== File size check ==="
wc -l tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php

# Read the beginning of the file to see typed property declarations
echo ""
echo "=== Typed property declarations (first 60 lines) ==="
head -60 tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php | cat -n

# Read lines around 76-87 (the specific snippet mentioned)
echo ""
echo "=== Lines 76-87 (snippet in review) ==="
sed -n '76,87p' tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php | cat -n

# Read lines around 89-107
echo ""
echo "=== Lines 89-107 ==="
sed -n '89,107p' tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php | cat -n

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2774


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for factory function definitions to understand their return types
echo "=== Searching for wu_create_customer definition ==="
rg -n "function wu_create_customer" --type php -A 10

echo ""
echo "=== Searching for wu_create_product definition ==="
rg -n "function wu_create_product" --type php -A 10

echo ""
echo "=== Searching for `@return` WP_Error in factory functions ==="
rg -n "@return.*WP_Error|WP_Error.*@return" --type php -B 5 | head -100

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 6855


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the complete wu_create_customer function
echo "=== wu_create_customer function (inc/functions/customer.php) ==="
sed -n '124,250p' inc/functions/customer.php | cat -n

echo ""
echo "=== wu_create_product function (inc/functions/product.php) ==="
sed -n '117,250p' inc/functions/product.php | cat -n

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 9784


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check for return statements in wu_create_customer
echo "=== Return statements in wu_create_customer ==="
rg -n "return " inc/functions/customer.php | head -20

echo ""
echo "=== Return statements in wu_create_product ==="
rg -n "return " inc/functions/product.php | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Lines 110-128 (expected: wu_create_addon) ==="
sed -n '110,128p' tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php | cat -n

echo ""
echo "=== Lines 131-145 (expected: wu_create_membership) ==="
sed -n '131,145p' tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php | cat -n

echo ""
echo "=== Lines 148-167 (expected: wu_create_discount_code) ==="
sed -n '148,167p' tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php | cat -n

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2339


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check return types for remaining factory functions
echo "=== wu_create_membership return type ==="
rg -n "function wu_create_membership" inc/functions/*.php -A 3 | head -20

echo ""
echo "=== wu_create_discount_code return type ==="
rg -n "function wu_create_discount_code" inc/functions/*.php -A 3 | head -20

# Look for `@return` hints
echo ""
echo "=== Return type hints for membership and discount_code ==="
rg -B 2 "function wu_create_(membership|discount_code)" inc/functions/*.php | grep -E "@return|function wu_create"

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1124


Guard factory results before assigning typed static properties.

PHP will throw TypeError when WP_Error is assigned to a typed property. The error checks at lines 76–87, 89–107, 110–128, 131–145, and 148–167 execute too late—after the property assignment fails.

The factory functions wu_create_customer(), wu_create_product(), wu_create_membership(), and wu_create_discount_code() are all documented to return WP_Error on failure. Assignments must be guarded first:

Suggested fix pattern
-		self::$customer = wu_create_customer(
+		$customer = wu_create_customer(
 			array(
 				'username' => 'testuser_addon_pricing',
 				'email'    => 'addon_pricing@example.com',
 				'password' => 'password123',
 			)
 		);

-		if ( is_wp_error( self::$customer ) ) {
+		if (is_wp_error($customer)) {
 			self::fail( 'Failed to create test customer' );
 		}
+		self::$customer = $customer;

Apply the same pattern to $plan, $addon, $discount_code, and $membership.

📝 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
self::$customer = wu_create_customer(
array(
'username' => 'testuser_addon_pricing',
'email' => 'addon_pricing@example.com',
'password' => 'password123',
)
);
if ( is_wp_error( self::$customer ) ) {
self::fail( 'Failed to create test customer' );
}
$customer = wu_create_customer(
array(
'username' => 'testuser_addon_pricing',
'email' => 'addon_pricing@example.com',
'password' => 'password123',
)
);
if (is_wp_error($customer)) {
self::fail( 'Failed to create test customer' );
}
self::$customer = $customer;
🧰 Tools
🪛 GitHub Check: Code Quality Checks

[failure] 85-85:
Expected 0 spaces before closing parenthesis; 1 found


[failure] 85-85:
Space after opening parenthesis of function call prohibited


[failure] 84-84:
Expected 0 spaces before closing parenthesis; 1 found


[failure] 84-84:
Space after opening parenthesis of function call prohibited

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 76 - 87,
The typed static properties (self::$customer, self::$plan, self::$addon,
self::$discount_code, self::$membership) are being assigned the raw results of
factory functions (wu_create_customer, wu_create_product, wu_create_membership,
wu_create_discount_code) which may return WP_Error and will cause a TypeError;
instead call each factory into a local variable first, check
is_wp_error(localVar) and call self::fail(...) on error, then assign the
verified value to the typed static property. Update all occurrences where these
factories are directly assigned (the blocks around the current assignments) to
follow this guard-then-assign pattern.

1. Fix Stripe 'coupon' parameter deprecated error
   - Use 'discounts' array instead of deprecated 'coupon' parameter
   - Fixes: 'Received unknown parameter: coupon' error when updating subscriptions

2. Fix addon products not showing in membership
   - Modified swap() to handle addon carts differently than plan changes
   - For addon carts: preserve existing addons and merge new ones
   - For addon carts: don't update recurring amount/duration

3. Fix discount code type safety (CodeRabbit feedback)
   - Resolve string discount codes to objects for legacy compatibility
   - Use instanceof check instead of is_object() + method_exists()

4. Remove late/ineffective wu_apply_signup_fee filter
   - Filter was registered after products were already added
   - Removed as it had no effect and could leak globally

5. Fix PHPCS violations
   - Auto-fixed spacing and indentation issues in test file and cart class
@github-actions
Copy link

github-actions bot commented Mar 7, 2026

🔨 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
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

♻️ Duplicate comments (4)
tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php (3)

76-163: ⚠️ Potential issue | 🟠 Major

Guard factory results before assigning typed static fixtures.

These helpers can return WP_Error. Assigning the raw result into a typed static property will throw before the subsequent is_wp_error() check runs, so the test never reaches the intended failure path. Validate into locals first, then assign.

🛡️ Suggested fix pattern
-		self::$customer = wu_create_customer(
+		$customer = wu_create_customer(
 			array(
 				'username' => 'testuser_addon_pricing',
 				'email'    => 'addon_pricing@example.com',
 				'password' => 'password123',
 			)
 		);
 
-		if ( is_wp_error(self::$customer) ) {
+		if (is_wp_error($customer)) {
 			self::fail('Failed to create test customer');
 		}
+		self::$customer = $customer;

Apply the same guard-then-assign pattern to self::$plan, self::$addon, self::$discount_code, and self::$membership.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 76 - 163,
The test assigns factory return values (which may be WP_Error) directly to typed
static fixtures (self::$customer, self::$plan, self::$addon,
self::$discount_code, self::$membership) causing type errors before the
is_wp_error() checks; fix by first storing each factory call
(wu_create_customer, wu_create_product, wu_create_discount_code,
wu_create_membership) into a local variable, call is_wp_error() and fail early
if needed, then assign the validated object to the corresponding static property
(self::$customer, self::$plan, self::$addon, self::$discount_code,
self::$membership).

72-168: ⚠️ Potential issue | 🟠 Major

Set the current WP user before building membership-change carts.

These tests pass customer_id, but Cart resolves ownership through wu_get_current_customer(). Without a per-test wp_set_current_user(self::$customer->get_user_id()), the addon/upgrade carts can fail the permission gate and make the assertions flaky.

🧪 Suggested fix
 class Cart_Addon_Pricing_Test extends WP_UnitTestCase {
+	public function set_up() {
+		parent::set_up();
+		wp_set_current_user(self::$customer->get_user_id());
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 72 - 168,
The test setup never sets the current WP user so Cart ownership (resolved via
wu_get_current_customer()) can be wrong; after creating self::$customer in
set_up_before_class() call wp_set_current_user(self::$customer->get_user_id())
so Cart/permission gates resolve to the test customer when building
addon/upgrade carts (update any teardown if needed to reset the current user).
Ensure this change is placed in set_up_before_class() immediately after the
wu_create_customer block so functions like Cart and wu_get_current_customer()
use the test user.

311-325: ⚠️ Potential issue | 🟡 Minor

Check per-test product creation before dereferencing the result.

Both wu_create_product() calls are used immediately via get_id() and delete(). If either factory returns WP_Error, the test will fatal instead of failing with a clear message.

🧪 Suggested fix
 		$upgraded_plan = wu_create_product(
 			array(
 				'name'          => 'Premium Plan',
@@
 				'active'        => true,
 			)
 		);
+		if (is_wp_error($upgraded_plan)) {
+			$this->fail('Failed to create upgraded plan');
+		}
@@
 		$addon_with_fee = wu_create_product(
 			array(
 				'name'          => 'Addon with Fee',
@@
 				'active'        => true,
 			)
 		);
+		if (is_wp_error($addon_with_fee)) {
+			$this->fail('Failed to create addon with setup fee');
+		}

Also applies to: 358-372

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php` around lines 311 - 325,
The test currently assumes wu_create_product() returns a product object and
immediately calls methods like get_id() and delete(), which will fatal if
wu_create_product() returns a WP_Error; update the test to check the return
value from wu_create_product() (for both $upgraded_plan and the other product
created around the 358-372 region) with is_wp_error() or similar before
dereferencing—if it is an error, fail the test with a clear message (or skip)
instead of calling get_id() or delete(); ensure all uses of get_id() and
delete() on the created product variables are guarded by this check.
inc/checkout/class-cart.php (1)

942-959: ⚠️ Potential issue | 🟠 Major

Scope the temporary signup-fee override to this one plan insertion.

This wu_apply_signup_fee filter is never removed, so opting into the legacy path can leak the override into later add_product() calls in the same request. Store the closure and remove it in a finally after re-adding the existing plan.

♻️ Suggested fix
-				add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0);
-
-				$this->add_product($membership->get_plan_id());
+				$signup_fee_filter = static fn() => $membership->get_times_billed() <= 0;
+				add_filter('wu_apply_signup_fee', $signup_fee_filter);
+				try {
+					$this->add_product($membership->get_plan_id());
+				} finally {
+					remove_filter('wu_apply_signup_fee', $signup_fee_filter);
+				}
🤖 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 942 - 959, The temporary
wu_apply_signup_fee filter must be scoped to the single add_product call to
avoid leaking into later insertions: capture the closure used for add_filter
(instead of passing an anonymous fn inline), call
add_filter('wu_apply_signup_fee', $closure) before calling
$this->add_product($membership->get_plan_id()), then ensure you remove it in a
finally block with remove_filter('wu_apply_signup_fee', $closure) after
re-adding the existing plan and running $this->calculate_prorate_credits(); this
confines the override to only that plan insertion.
🤖 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/models/class-membership.php`:
- Around line 764-777: The current branch skips updating the membership
recurring amount for addon carts which leaves Membership::get_amount() stale and
can cause wu_get_membership_new_cart() to compute incorrect adjustment; fix by
still merging the addon’s recurring delta into the membership before saving:
move/ensure $this->set_amount($order->get_recurring_total()),
$this->set_initial_amount($order->get_total()), and
$this->set_recurring($order->has_recurring()) run even when $is_addon_cart is
true, but keep $this->set_duration($order->get_duration()) and
$this->set_duration_unit($order->get_duration_unit()) inside the !
$is_addon_cart block so the billing period is unchanged; this ensures
Membership::save()/process_membership_update() rebuilds the subscription with
the updated amount while preserving duration/unit.

---

Duplicate comments:
In `@inc/checkout/class-cart.php`:
- Around line 942-959: The temporary wu_apply_signup_fee filter must be scoped
to the single add_product call to avoid leaking into later insertions: capture
the closure used for add_filter (instead of passing an anonymous fn inline),
call add_filter('wu_apply_signup_fee', $closure) before calling
$this->add_product($membership->get_plan_id()), then ensure you remove it in a
finally block with remove_filter('wu_apply_signup_fee', $closure) after
re-adding the existing plan and running $this->calculate_prorate_credits(); this
confines the override to only that plan insertion.

In `@tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php`:
- Around line 76-163: The test assigns factory return values (which may be
WP_Error) directly to typed static fixtures (self::$customer, self::$plan,
self::$addon, self::$discount_code, self::$membership) causing type errors
before the is_wp_error() checks; fix by first storing each factory call
(wu_create_customer, wu_create_product, wu_create_discount_code,
wu_create_membership) into a local variable, call is_wp_error() and fail early
if needed, then assign the validated object to the corresponding static property
(self::$customer, self::$plan, self::$addon, self::$discount_code,
self::$membership).
- Around line 72-168: The test setup never sets the current WP user so Cart
ownership (resolved via wu_get_current_customer()) can be wrong; after creating
self::$customer in set_up_before_class() call
wp_set_current_user(self::$customer->get_user_id()) so Cart/permission gates
resolve to the test customer when building addon/upgrade carts (update any
teardown if needed to reset the current user). Ensure this change is placed in
set_up_before_class() immediately after the wu_create_customer block so
functions like Cart and wu_get_current_customer() use the test user.
- Around line 311-325: The test currently assumes wu_create_product() returns a
product object and immediately calls methods like get_id() and delete(), which
will fatal if wu_create_product() returns a WP_Error; update the test to check
the return value from wu_create_product() (for both $upgraded_plan and the other
product created around the 358-372 region) with is_wp_error() or similar before
dereferencing—if it is an error, fail the test with a clear message (or skip)
instead of calling get_id() or delete(); ensure all uses of get_id() and
delete() on the created product variables are guarded by this check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ff9bd976-8c42-4e85-808b-aa610cc1ecde

📥 Commits

Reviewing files that changed from the base of the PR and between 1153360 and c051b14.

📒 Files selected for processing (4)
  • inc/checkout/class-cart.php
  • inc/gateways/class-base-stripe-gateway.php
  • inc/models/class-membership.php
  • tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php

Comment on lines 764 to +777
/*
* Finally, we have a couple of other parameters to set.
* For addon carts, don't update the recurring amount/duration
* since we're not changing the plan, just adding products.
*
* @since 2.0.12
*/
$this->set_amount($order->get_recurring_total());
$this->set_initial_amount($order->get_total());
$this->set_recurring($order->has_recurring());
if ( ! $is_addon_cart) {
$this->set_amount($order->get_recurring_total());
$this->set_initial_amount($order->get_total());
$this->set_recurring($order->has_recurring());

$this->set_duration($order->get_duration());
$this->set_duration_unit($order->get_duration_unit());
$this->set_duration($order->get_duration());
$this->set_duration_unit($order->get_duration_unit());
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Don’t leave the membership amount stale on addon swaps.

This branch keeps addon_products in sync but leaves Membership::get_amount() at the pre-addon value. Membership::save() still calls process_membership_update() when products change, and that flow rebuilds the recurring subscription from membership state; because wu_get_membership_new_cart() derives an adjustment from the stored amount, the new addon can be canceled back out on future renewals. Keep the billing period unchanged for addon carts, but still merge the addon’s recurring delta into the membership amount before saving.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/models/class-membership.php` around lines 764 - 777, The current branch
skips updating the membership recurring amount for addon carts which leaves
Membership::get_amount() stale and can cause wu_get_membership_new_cart() to
compute incorrect adjustment; fix by still merging the addon’s recurring delta
into the membership before saving: move/ensure
$this->set_amount($order->get_recurring_total()),
$this->set_initial_amount($order->get_total()), and
$this->set_recurring($order->has_recurring()) run even when $is_addon_cart is
true, but keep $this->set_duration($order->get_duration()) and
$this->set_duration_unit($order->get_duration_unit()) inside the !
$is_addon_cart block so the billing period is unchanged; this ensures
Membership::save()/process_membership_update() rebuilds the subscription with
the updated amount while preserving duration/unit.

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