From 717112f829020ec2f3ea3cb459fea256ebfdb2d9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 4 Mar 2026 18:34:13 -0700 Subject: [PATCH 1/5] fix(checkout): convert template IDs to integers in get_available_site_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). --- .../class-limit-site-templates.php | 6 ++- tests/WP_Ultimo/Objects/Limitations_Test.php | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/inc/limitations/class-limit-site-templates.php b/inc/limitations/class-limit-site-templates.php index 18441650..8fafb34b 100644 --- a/inc/limitations/class-limit-site-templates.php +++ b/inc/limitations/class-limit-site-templates.php @@ -221,7 +221,8 @@ public function get_available_site_templates() { if (self::BEHAVIOR_AVAILABLE === $site_settings->behavior || self::BEHAVIOR_PRE_SELECTED === $site_settings->behavior || self::MODE_DEFAULT === $this->mode) { - $available[] = $site_id; + // Convert to integer to match type used in validation (absint) + $available[] = absint($site_id); } } @@ -248,7 +249,8 @@ public function get_pre_selected_site_template() { $site_settings = (object) $site_settings; if (self::BEHAVIOR_PRE_SELECTED === $site_settings->behavior) { - $pre_selected_site_template = $site_id; + // Convert to integer to match type used in validation (absint) + $pre_selected_site_template = absint($site_id); } } diff --git a/tests/WP_Ultimo/Objects/Limitations_Test.php b/tests/WP_Ultimo/Objects/Limitations_Test.php index 9fba84a5..9c94683d 100644 --- a/tests/WP_Ultimo/Objects/Limitations_Test.php +++ b/tests/WP_Ultimo/Objects/Limitations_Test.php @@ -910,4 +910,42 @@ public function test_checkout_plan_plus_addon_preserves_templates(): void { // Disk space should be additive $this->assertEquals(600, $limits->disk_space->get_limit(), 'Disk space should be summed'); } + + /** + * Test that get_available_site_templates returns integers, not strings. + * + * Regression test for issue #351: When template IDs are stored as string keys + * in the limit array, they must be converted to integers for proper comparison + * in the Site_Template validation rule. + */ + public function test_available_site_templates_returns_integers(): void { + + $limitations = new Limitations([ + 'site_templates' => [ + 'enabled' => true, + 'mode' => 'choose_available_templates', + 'limit' => [ + '123' => ['behavior' => 'available'], + '456' => ['behavior' => 'pre_selected'], + '789' => ['behavior' => 'not_available'], + ], + ], + ]); + + $available = $limitations->site_templates->get_available_site_templates(); + + // Should return integers, not strings + $this->assertContains(123, $available, 'Template 123 should be in available array as integer'); + $this->assertContains(456, $available, 'Template 456 should be in available array as integer'); + $this->assertNotContains(789, $available, 'Template 789 should not be available'); + + // Verify strict type checking + foreach ($available as $template_id) { + $this->assertIsInt($template_id, 'All template IDs should be integers'); + } + + // Verify in_array works with strict comparison + $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'); + } } From 2ccfcc9a194eac48cbdb1130177864281191b073 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 4 Mar 2026 18:49:40 -0700 Subject: [PATCH 2/5] fix(checkout): correct addon pricing to only charge for new products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- inc/checkout/class-cart.php | 211 ++++++++----- .../Checkout/Cart_Addon_Pricing_Test.php | 292 ++++++++++++++++++ 2 files changed, 428 insertions(+), 75 deletions(-) create mode 100644 tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index 043fd14e..e4343075 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -873,22 +873,60 @@ protected function build_from_membership($membership_id): bool { return true; } - /* - * Set the type to addon. - */ - $this->cart_type = 'addon'; + /* + * Set the type to addon. + */ + $this->cart_type = 'addon'; - /* - * Sets the durations to avoid problems - * with addon purchases. - */ - $plan_product = $membership->get_plan(); + /* + * Sets the durations to avoid problems + * with addon purchases. + */ + $plan_product = $membership->get_plan(); - if ($plan_product && ! $membership->is_free()) { - $this->duration = $plan_product->get_duration(); - $this->duration_unit = $plan_product->get_duration_unit(); - } + if ($plan_product && ! $membership->is_free()) { + $this->duration = $plan_product->get_duration(); + $this->duration_unit = $plan_product->get_duration_unit(); + } + + /* + * Apply existing discount code from membership to the cart. + * This ensures that discount codes with 'apply_to_renewals' setting + * are properly applied to addon purchases. + * + * @since 2.0.12 + */ + $membership_discount_code = $membership->get_discount_code(); + + if ($membership_discount_code && $membership_discount_code->should_apply_to_renewals()) { + $this->add_discount_code($membership_discount_code); + } + + /* + * For addon-only purchases, we should NOT add the existing plan back + * at full price and then calculate pro-rata credits. This was causing + * the customer to be charged for the next billing period in advance. + * + * Instead, we only charge for the new addon products being added. + * + * The existing plan will continue to be billed on its regular schedule + * via the subscription at the gateway level. + * + * Allows filtering for special cases where the old behavior is needed. + * + * @since 2.0.12 + * @param bool $should_include Whether to include the existing plan. + * @param self $cart The cart object. + * @param \WP_Ultimo\Models\Membership $membership The existing membership. + */ + $should_include_existing_plan = apply_filters( + 'wu_cart_addon_include_existing_plan', + false, // Changed from true to false - only charge for addons + $this, + $membership + ); + if ($should_include_existing_plan) { /* * Checks the membership to see if we need to add back the * setup fee. @@ -898,41 +936,20 @@ protected function build_from_membership($membership_id): bool { */ add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); + $this->add_product($membership->get_plan_id()); + /* - * Adds the membership plan back in, for completeness. - * This is also useful to make sure we present - * the totals correctly for the customer. - * - * Allows filtering for addons like domain registration where - * only the addon product should appear (not the existing plan). - * - * @since 2.4.12 - * @param bool $should_include Whether to include the existing plan. - * @param self $cart The cart object. - * @param \WP_Ultimo\Models\Membership $membership The existing membership. + * Adds the credit line, after + * calculating pro-rate. */ - $should_include_existing_plan = apply_filters( - 'wu_cart_addon_include_existing_plan', - true, - $this, - $membership - ); - - if ($should_include_existing_plan) { - $this->add_product($membership->get_plan_id()); - - /* - * Adds the credit line, after - * calculating pro-rate. - */ - $this->calculate_prorate_credits(); - } - - return true; + $this->calculate_prorate_credits(); } - /* - * With products added, let's check if the plan is changing. + return true; + } + + /* + * With products added, let's check if the plan is changing. * * A plan change implies a upgrade or a downgrade, which we will determine * below. @@ -959,46 +976,90 @@ protected function build_from_membership($membership_id): bool { $is_plan_change = true; } + /* + * If there is no plan change, but the product count is > 1 + * We know that there is another product in this cart other than the + * plan, so this is again an addon cart. + */ + if (count($this->products) > 1 && false === $is_plan_change) { /* - * If there is no plan change, but the product count is > 1 - * We know that there is another product in this cart other than the - * plan, so this is again an addon cart. + * Set the type to addon. */ - if (count($this->products) > 1 && false === $is_plan_change) { - /* - * Set the type to addon. - */ - $this->cart_type = 'addon'; + $this->cart_type = 'addon'; - /* - * Sets the durations to avoid problems - * with addon purchases. - */ - $plan_product = $membership->get_plan(); + /* + * Sets the durations to avoid problems + * with addon purchases. + */ + $plan_product = $membership->get_plan(); - if ($plan_product && ! $membership->is_free()) { - $this->duration = $plan_product->get_duration(); - $this->duration_unit = $plan_product->get_duration_unit(); - } + if ($plan_product && ! $membership->is_free()) { + $this->duration = $plan_product->get_duration(); + $this->duration_unit = $plan_product->get_duration_unit(); + } - /* - * Checks the membership to see if we need to add back the - * setup fee. - * - * If the membership was already successfully charged once, - * it probably means that the setup fee was already paid, so we can skip it. - */ - add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); + /* + * Apply existing discount code from membership to the cart. + * This ensures that discount codes with 'apply_to_renewals' setting + * are properly applied to addon purchases. + * + * @since 2.0.12 + */ + $membership_discount_code = $membership->get_discount_code(); - /* - * Adds the credit line, after - * calculating pro-rate. - */ - $this->calculate_prorate_credits(); + if ($membership_discount_code && $membership_discount_code->should_apply_to_renewals()) { + $this->add_discount_code($membership_discount_code); + } - return true; + /* + * Remove the existing plan from the cart to prevent charging + * for the next billing period in advance. + * + * For addon purchases, we only want to charge for the new addon + * products, not the existing plan subscription. + * + * @since 2.0.12 + */ + foreach ($this->products as $key => $product) { + if (wu_is_plan_type($product->get_type()) && $product->get_id() === $membership->get_plan_id()) { + unset($this->products[$key]); + + // Also remove the plan's line item + foreach ($this->line_items as $line_key => $line_item) { + if ($line_item->get_product_id() === $product->get_id() && $line_item->get_type() === 'product') { + unset($this->line_items[$line_key]); + } + } + + // Reset the plan_id since we're not changing plans + $this->plan_id = 0; + break; + } } + /* + * Checks the membership to see if we need to add back the + * setup fee for addon products. + * + * If the membership was already successfully charged once, + * it probably means that the setup fee was already paid, so we can skip it. + */ + add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); + + /* + * Do NOT calculate pro-rata credits for addon-only purchases. + * Pro-rata only makes sense when changing plans. + * + * Previously, this was incorrectly charging customers for the next + * billing period in advance when adding addons. + * + * @since 2.0.12 + */ + // Removed: $this->calculate_prorate_credits(); + + return true; + } + /* * We'll probably never enter in this if, but we * hev it here to prevent bugs. diff --git a/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php new file mode 100644 index 00000000..5db07ded --- /dev/null +++ b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php @@ -0,0 +1,292 @@ + 'testuser_addon_pricing', + 'email' => 'addon_pricing@example.com', + 'password' => 'password123', + ]); + + // Create a plan product (€90/month) + self::$plan = wu_create_product([ + 'name' => 'Test Plan', + 'slug' => 'test-plan-addon-pricing', + 'amount' => 90.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'plan', + 'recurring' => true, + 'setup_fee' => 0, + ]); + + // Create an addon product (€5) + self::$addon = wu_create_product([ + 'name' => 'Test Addon', + 'slug' => 'test-addon-service', + 'amount' => 5.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'service', + 'recurring' => true, + 'setup_fee' => 0, + ]); + + // Create a discount code (10% off, applies to renewals) + self::$discount_code = wu_create_discount_code([ + 'name' => 'Test Discount', + 'code' => 'TEST10', + 'value' => 10, + 'type' => 'percentage', + 'uses' => 0, + 'max_uses' => 100, + 'apply_to_renewals' => true, + ]); + + // Create an active membership for the customer + self::$membership = wu_create_membership([ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => self::$plan->get_id(), + 'amount' => 90.00, + 'currency' => 'EUR', + 'duration' => 1, + 'duration_unit' => 'month', + 'status' => Membership_Status::ACTIVE, + 'times_billed' => 1, + 'discount_code' => self::$discount_code->get_code(), + 'date_created' => wu_date()->modify('-15 days')->format('Y-m-d H:i:s'), // 15 days ago + 'date_renewed' => wu_date()->modify('-15 days')->format('Y-m-d H:i:s'), + 'date_expiration' => wu_date()->modify('+15 days')->format('Y-m-d H:i:s'), // 15 days from now + ]); + } + + /** + * Test that addon purchases only charge for the addon, not the existing plan. + * + * Bug: Previously, adding a €5 addon to a €90/month membership would charge ~€89.59 + * (€90 plan + €5 addon - small pro-rata credit). + * + * Expected: Should only charge €5 for the addon. + */ + public function test_addon_only_charges_for_addon_product() { + $cart = new Cart([ + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => [self::$addon->get_id()], + ]); + + // The cart should be type 'addon' + $this->assertEquals('addon', $cart->get_cart_type(), 'Cart type should be "addon"'); + + // The cart should NOT include the existing plan + $line_items = $cart->get_line_items(); + $product_line_items = array_filter($line_items, function($item) { + return $item->get_type() === 'product'; + }); + + // Should only have 1 product line item (the addon) + $this->assertCount(1, $product_line_items, 'Should only have 1 product line item (the addon)'); + + // Verify it's the addon, not the plan + $addon_line_item = reset($product_line_items); + $this->assertEquals(self::$addon->get_id(), $addon_line_item->get_product_id(), 'Product should be the addon'); + + // The subtotal should be €5.00 (addon price only) + $this->assertEquals(5.00, $cart->get_subtotal(), 'Subtotal should be €5.00 (addon price only)'); + + // There should be NO pro-rata credit line items + $credit_line_items = array_filter($line_items, function($item) { + return $item->get_type() === 'credit'; + }); + $this->assertCount(0, $credit_line_items, 'Should have NO pro-rata credit for addon-only purchases'); + + // Total should be €5.00 (no taxes in this test) + $this->assertEquals(5.00, $cart->get_total(), 'Total should be €5.00'); + } + + /** + * Test that existing discount codes are applied to addon purchases. + * + * Bug: Previously, discount codes from the membership were not being applied + * to addon purchases. + * + * Expected: The membership's discount code (10% off) should be applied to the addon. + */ + public function test_addon_applies_existing_discount_code() { + $cart = new Cart([ + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => [self::$addon->get_id()], + ]); + + // The cart should have the discount code from the membership + $discount_code = $cart->get_discount_code(); + $this->assertNotNull($discount_code, 'Discount code should be applied'); + $this->assertEquals('TEST10', $discount_code->get_code(), 'Should be the membership discount code'); + + // The addon should have a discount applied (10% off €5 = €0.50) + $line_items = $cart->get_line_items(); + $addon_line_item = null; + foreach ($line_items as $item) { + if ($item->get_type() === 'product' && $item->get_product_id() === self::$addon->get_id()) { + $addon_line_item = $item; + break; + } + } + + $this->assertNotNull($addon_line_item, 'Addon line item should exist'); + $this->assertEquals(0.50, $addon_line_item->get_discount_total(), 'Discount should be €0.50 (10% of €5)'); + $this->assertEquals(4.50, $addon_line_item->get_total(), 'Addon total should be €4.50 after discount'); + } + + /** + * Test that the filter 'wu_cart_addon_include_existing_plan' can override the default behavior. + * + * The filter defaults to false (don't include plan), but sites can set it to true + * if they need the old behavior for specific use cases. + */ + 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'); + } + + /** + * Test that plan upgrades still use pro-rata correctly. + * + * When changing plans (upgrade/downgrade), pro-rata SHOULD still be applied. + * The fix should only affect addon-only purchases. + */ + public function test_plan_upgrade_still_uses_prorate() { + // Create a higher-tier plan + $upgraded_plan = wu_create_product([ + 'name' => 'Premium Plan', + 'slug' => 'premium-plan-addon-test', + 'amount' => 150.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'plan', + 'recurring' => true, + 'setup_fee' => 0, + ]); + + $cart = new Cart([ + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => [$upgraded_plan->get_id()], + ]); + + // Cart type should be 'upgrade' (or 'downgrade') + $this->assertContains($cart->get_cart_type(), ['upgrade', 'downgrade'], 'Cart type should be upgrade or downgrade'); + + // Should have a pro-rata credit for plan changes + $line_items = $cart->get_line_items(); + $credit_line_items = array_filter($line_items, function($item) { + return $item->get_type() === 'credit'; + }); + + $this->assertGreaterThan(0, count($credit_line_items), 'Plan upgrades should have pro-rata credit'); + + // Clean up + $upgraded_plan->delete(); + } + + /** + * Test that setup fees are not re-applied for addon purchases on existing memberships. + */ + public function test_addon_does_not_reapply_setup_fees() { + // Create an addon with a setup fee + $addon_with_fee = wu_create_product([ + 'name' => 'Addon with Fee', + 'slug' => 'addon-with-setup-fee', + 'amount' => 10.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'service', + 'recurring' => true, + 'setup_fee' => 20.00, // €20 setup fee + ]); + + $cart = new Cart([ + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => [$addon_with_fee->get_id()], + ]); + + // Get all line items + $line_items = $cart->get_line_items(); + + // Should have setup fee line item for the NEW addon (first time adding it) + $fee_line_items = array_filter($line_items, function($item) use ($addon_with_fee) { + return $item->get_type() === 'fee' && $item->get_product_id() === $addon_with_fee->get_id(); + }); + + $this->assertCount(1, $fee_line_items, 'Should have 1 setup fee for the new addon'); + + $fee_line_item = reset($fee_line_items); + $this->assertEquals(20.00, $fee_line_item->get_unit_price(), 'Setup fee should be €20'); + + // Clean up + $addon_with_fee->delete(); + } + + public static function tear_down_after_class() { + self::$membership->delete(); + self::$addon->delete(); + self::$plan->delete(); + self::$discount_code->delete(); + self::$customer->delete(); + parent::tear_down_after_class(); + } +} From 00e1f161cbe0468c9bc5e2519a6eb60e5a7d424c Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 4 Mar 2026 22:53:38 -0700 Subject: [PATCH 3/5] fix(checkout): address CodeRabbit review comments 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. --- inc/checkout/class-cart.php | 48 ++++++++-- .../Checkout/Cart_Addon_Pricing_Test.php | 96 ++++++++++++++----- 2 files changed, 116 insertions(+), 28 deletions(-) diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index e4343075..eca9ad8e 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -900,6 +900,7 @@ protected function build_from_membership($membership_id): bool { if ($membership_discount_code && $membership_discount_code->should_apply_to_renewals()) { $this->add_discount_code($membership_discount_code); + $this->reapply_discounts_to_existing_line_items(); } /* @@ -1009,6 +1010,7 @@ protected function build_from_membership($membership_id): bool { if ($membership_discount_code && $membership_discount_code->should_apply_to_renewals()) { $this->add_discount_code($membership_discount_code); + $this->reapply_discounts_to_existing_line_items(); } /* @@ -1024,9 +1026,12 @@ protected function build_from_membership($membership_id): bool { if (wu_is_plan_type($product->get_type()) && $product->get_id() === $membership->get_plan_id()) { unset($this->products[$key]); - // Also remove the plan's line item + // Also remove line items tied to the old plan (product + fee) foreach ($this->line_items as $line_key => $line_item) { - if ($line_item->get_product_id() === $product->get_id() && $line_item->get_type() === 'product') { + if ( + $line_item->get_product_id() === $product->get_id() + && in_array($line_item->get_type(), ['product', 'fee'], true) + ) { unset($this->line_items[$line_key]); } } @@ -1038,11 +1043,15 @@ protected function build_from_membership($membership_id): bool { } /* - * Checks the membership to see if we need to add back the - * setup fee for addon products. + * 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. * - * If the membership was already successfully charged once, - * it probably means that the setup fee was already paid, so we can skip it. + * 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); @@ -2604,6 +2613,33 @@ public function apply_discounts_to_item($line_item) { return $line_item; } + /** + * Reapply discounts to all existing line items in the cart. + * + * This helper method is used when a discount code is set after products + * have already been added to the cart (e.g., when applying membership + * discount codes to addon purchases). It iterates through all line items, + * reapplies discounts, and recalculates taxes if applicable. + * + * @since 2.0.12 + * @return void + */ + private function reapply_discounts_to_existing_line_items() { + foreach ($this->line_items as $id => $line_item) { + if (! $line_item->is_discountable()) { + continue; + } + + $line_item = $this->apply_discounts_to_item($line_item); + + if ($line_item->is_taxable()) { + $line_item = $this->apply_taxes_to_item($line_item); + } + + $this->line_items[$id] = $line_item; + } + } + /** * Apply taxes to a line item. * diff --git a/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php index 5db07ded..dd0e8e79 100644 --- a/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php +++ b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php @@ -1,4 +1,11 @@ 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( + array( + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => array( 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 - always cleanup even if assertions fail + remove_filter('wu_cart_addon_include_existing_plan', '__return_true'); + } } /** From 1153360fa115fd9584e8429cb9b7408a48973393 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 4 Mar 2026 23:08:58 -0700 Subject: [PATCH 4/5] fix(checkout): address failing tests and CodeRabbit suggestions 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. --- inc/checkout/class-cart.php | 22 +- .../Checkout/Cart_Addon_Pricing_Test.php | 402 +++++++++++------- 2 files changed, 257 insertions(+), 167 deletions(-) diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index eca9ad8e..12400137 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -894,11 +894,20 @@ protected function build_from_membership($membership_id): bool { * This ensures that discount codes with 'apply_to_renewals' setting * are properly applied to addon purchases. * + * Note: get_discount_code() can return a Discount_Code object, string, or false. + * We need to ensure it's an object with the should_apply_to_renewals() method + * before attempting to call it. + * * @since 2.0.12 */ $membership_discount_code = $membership->get_discount_code(); - if ($membership_discount_code && $membership_discount_code->should_apply_to_renewals()) { + if ( + $membership_discount_code + && is_object($membership_discount_code) + && method_exists($membership_discount_code, 'should_apply_to_renewals') + && $membership_discount_code->should_apply_to_renewals() + ) { $this->add_discount_code($membership_discount_code); $this->reapply_discounts_to_existing_line_items(); } @@ -1004,11 +1013,20 @@ protected function build_from_membership($membership_id): bool { * This ensures that discount codes with 'apply_to_renewals' setting * are properly applied to addon purchases. * + * Note: get_discount_code() can return a Discount_Code object, string, or false. + * We need to ensure it's an object with the should_apply_to_renewals() method + * before attempting to call it. + * * @since 2.0.12 */ $membership_discount_code = $membership->get_discount_code(); - if ($membership_discount_code && $membership_discount_code->should_apply_to_renewals()) { + if ( + $membership_discount_code + && is_object($membership_discount_code) + && method_exists($membership_discount_code, 'should_apply_to_renewals') + && $membership_discount_code->should_apply_to_renewals() + ) { $this->add_discount_code($membership_discount_code); $this->reapply_discounts_to_existing_line_items(); } diff --git a/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php index dd0e8e79..3cbfcd65 100644 --- a/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php +++ b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php @@ -72,63 +72,99 @@ class Cart_Addon_Pricing_Test extends WP_UnitTestCase { public static function set_up_before_class() { parent::set_up_before_class(); - // Create a test customer - self::$customer = wu_create_customer([ - 'username' => 'testuser_addon_pricing', - 'email' => 'addon_pricing@example.com', - 'password' => 'password123', - ]); - - // Create a plan product (€90/month) - self::$plan = wu_create_product([ - 'name' => 'Test Plan', - 'slug' => 'test-plan-addon-pricing', - 'amount' => 90.00, - 'duration' => 1, - 'duration_unit' => 'month', - 'type' => 'plan', - 'recurring' => true, - 'setup_fee' => 0, - ]); - - // Create an addon product (€5) - self::$addon = wu_create_product([ - 'name' => 'Test Addon', - 'slug' => 'test-addon-service', - 'amount' => 5.00, - 'duration' => 1, - 'duration_unit' => 'month', - 'type' => 'service', - 'recurring' => true, - 'setup_fee' => 0, - ]); - - // Create a discount code (10% off, applies to renewals) - self::$discount_code = wu_create_discount_code([ - 'name' => 'Test Discount', - 'code' => 'TEST10', - 'value' => 10, - 'type' => 'percentage', - 'uses' => 0, - 'max_uses' => 100, - 'apply_to_renewals' => true, - ]); - - // Create an active membership for the customer - self::$membership = wu_create_membership([ - 'customer_id' => self::$customer->get_id(), - 'plan_id' => self::$plan->get_id(), - 'amount' => 90.00, - 'currency' => 'EUR', - 'duration' => 1, - 'duration_unit' => 'month', - 'status' => Membership_Status::ACTIVE, - 'times_billed' => 1, - 'discount_code' => self::$discount_code->get_code(), - 'date_created' => wu_date()->modify('-15 days')->format('Y-m-d H:i:s'), // 15 days ago - 'date_renewed' => wu_date()->modify('-15 days')->format('Y-m-d H:i:s'), - 'date_expiration' => wu_date()->modify('+15 days')->format('Y-m-d H:i:s'), // 15 days from now - ]); + // Create a test customer. + 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' ); + } + + // Create a plan product (€90/month). + self::$plan = wu_create_product( + array( + 'name' => 'Test Plan', + 'slug' => 'test-plan-addon-pricing', + 'amount' => 90.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'plan', + 'pricing_type' => 'paid', + 'currency' => 'EUR', + 'recurring' => true, + 'setup_fee' => 0, + 'active' => true, + ) + ); + + if ( is_wp_error( self::$plan ) ) { + self::fail( 'Failed to create test plan' ); + } + + // Create an addon product (€5). + self::$addon = wu_create_product( + array( + 'name' => 'Test Addon', + 'slug' => 'test-addon-service', + 'amount' => 5.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'service', + 'pricing_type' => 'paid', + 'currency' => 'EUR', + 'recurring' => true, + 'setup_fee' => 0, + 'active' => true, + ) + ); + + if ( is_wp_error( self::$addon ) ) { + self::fail( 'Failed to create test addon' ); + } + + // Create a discount code (10% off, applies to renewals). + self::$discount_code = wu_create_discount_code( + array( + 'name' => 'Test Discount', + 'code' => 'TEST10', + 'value' => 10, + 'type' => 'percentage', + 'uses' => 0, + 'max_uses' => 100, + 'apply_to_renewals' => true, + ) + ); + + if ( is_wp_error( self::$discount_code ) ) { + self::fail( 'Failed to create test discount code' ); + } + + // Create an active membership for the customer. + self::$membership = wu_create_membership( + array( + 'customer_id' => self::$customer->get_id(), + 'plan_id' => self::$plan->get_id(), + 'amount' => 90.00, + 'currency' => 'EUR', + 'duration' => 1, + 'duration_unit' => 'month', + 'status' => Membership_Status::ACTIVE, + 'times_billed' => 1, + 'discount_code' => self::$discount_code->get_code(), + 'date_created' => wu_date()->modify( '-15 days' )->format( 'Y-m-d H:i:s' ), + 'date_renewed' => wu_date()->modify( '-15 days' )->format( 'Y-m-d H:i:s' ), + 'date_expiration' => wu_date()->modify( '+15 days' )->format( 'Y-m-d H:i:s' ), + ) + ); + + if ( is_wp_error( self::$membership ) ) { + self::fail( 'Failed to create test membership' ); + } } /** @@ -140,39 +176,47 @@ public static function set_up_before_class() { * Expected: Should only charge €5 for the addon. */ public function test_addon_only_charges_for_addon_product() { - $cart = new Cart([ - 'customer_id' => self::$customer->get_id(), - 'membership_id' => self::$membership->get_id(), - 'products' => [self::$addon->get_id()], - ]); - - // The cart should be type 'addon' - $this->assertEquals('addon', $cart->get_cart_type(), 'Cart type should be "addon"'); - - // The cart should NOT include the existing plan - $line_items = $cart->get_line_items(); - $product_line_items = array_filter($line_items, function($item) { - return $item->get_type() === 'product'; - }); + $cart = new Cart( + array( + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => array( self::$addon->get_id() ), + ) + ); + + // The cart should be type 'addon'. + $this->assertEquals( 'addon', $cart->get_cart_type(), 'Cart type should be "addon"' ); + + // The cart should NOT include the existing plan. + $line_items = $cart->get_line_items(); + $product_line_items = array_filter( + $line_items, + function ( $item ) { + return $item->get_type() === 'product'; + } + ); - // Should only have 1 product line item (the addon) - $this->assertCount(1, $product_line_items, 'Should only have 1 product line item (the addon)'); + // Should only have 1 product line item (the addon). + $this->assertCount( 1, $product_line_items, 'Should only have 1 product line item (the addon)' ); - // Verify it's the addon, not the plan - $addon_line_item = reset($product_line_items); - $this->assertEquals(self::$addon->get_id(), $addon_line_item->get_product_id(), 'Product should be the addon'); + // Verify it's the addon, not the plan. + $addon_line_item = reset( $product_line_items ); + $this->assertEquals( self::$addon->get_id(), $addon_line_item->get_product_id(), 'Product should be the addon' ); - // The subtotal should be €5.00 (addon price only) - $this->assertEquals(5.00, $cart->get_subtotal(), 'Subtotal should be €5.00 (addon price only)'); + // The subtotal should be €5.00 (addon price only). + $this->assertEquals( 5.00, $cart->get_subtotal(), 'Subtotal should be €5.00 (addon price only)' ); - // There should be NO pro-rata credit line items - $credit_line_items = array_filter($line_items, function($item) { - return $item->get_type() === 'credit'; - }); - $this->assertCount(0, $credit_line_items, 'Should have NO pro-rata credit for addon-only purchases'); + // There should be NO pro-rata credit line items. + $credit_line_items = array_filter( + $line_items, + function ( $item ) { + return $item->get_type() === 'credit'; + } + ); + $this->assertCount( 0, $credit_line_items, 'Should have NO pro-rata credit for addon-only purchases' ); - // Total should be €5.00 (no taxes in this test) - $this->assertEquals(5.00, $cart->get_total(), 'Total should be €5.00'); + // Total should be €5.00 (no taxes in this test). + $this->assertEquals( 5.00, $cart->get_total(), 'Total should be €5.00' ); } /** @@ -184,30 +228,32 @@ public function test_addon_only_charges_for_addon_product() { * Expected: The membership's discount code (10% off) should be applied to the addon. */ public function test_addon_applies_existing_discount_code() { - $cart = new Cart([ - 'customer_id' => self::$customer->get_id(), - 'membership_id' => self::$membership->get_id(), - 'products' => [self::$addon->get_id()], - ]); - - // The cart should have the discount code from the membership + $cart = new Cart( + array( + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => array( self::$addon->get_id() ), + ) + ); + + // The cart should have the discount code from the membership. $discount_code = $cart->get_discount_code(); - $this->assertNotNull($discount_code, 'Discount code should be applied'); - $this->assertEquals('TEST10', $discount_code->get_code(), 'Should be the membership discount code'); + $this->assertNotNull( $discount_code, 'Discount code should be applied' ); + $this->assertEquals( 'TEST10', $discount_code->get_code(), 'Should be the membership discount code' ); - // The addon should have a discount applied (10% off €5 = €0.50) - $line_items = $cart->get_line_items(); + // The addon should have a discount applied (10% off €5 = €0.50). + $line_items = $cart->get_line_items(); $addon_line_item = null; - foreach ($line_items as $item) { - if ($item->get_type() === 'product' && $item->get_product_id() === self::$addon->get_id()) { + foreach ( $line_items as $item ) { + if ( $item->get_type() === 'product' && $item->get_product_id() === self::$addon->get_id() ) { $addon_line_item = $item; break; } } - $this->assertNotNull($addon_line_item, 'Addon line item should exist'); - $this->assertEquals(0.50, $addon_line_item->get_discount_total(), 'Discount should be €0.50 (10% of €5)'); - $this->assertEquals(4.50, $addon_line_item->get_total(), 'Addon total should be €4.50 after discount'); + $this->assertNotNull( $addon_line_item, 'Addon line item should exist' ); + $this->assertEquals( 0.50, $addon_line_item->get_discount_total(), 'Discount should be €0.50 (10% of €5)' ); + $this->assertEquals( 4.50, $addon_line_item->get_total(), 'Addon total should be €4.50 after discount' ); } /** @@ -217,8 +263,8 @@ public function test_addon_applies_existing_discount_code() { * if they need the old behavior for specific use cases. */ 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'); + // Add filter to force inclusion of existing plan. + add_filter( 'wu_cart_addon_include_existing_plan', '__return_true' ); try { $cart = new Cart( @@ -229,8 +275,8 @@ public function test_addon_filter_can_include_existing_plan() { ) ); - // Should have 2 product line items (plan + addon) - $line_items = $cart->get_line_items(); + // Should have 2 product line items (plan + addon). + $line_items = $cart->get_line_items(); $product_line_items = array_filter( $line_items, function ( $item ) { @@ -238,19 +284,19 @@ function ( $item ) { } ); - $this->assertCount(2, $product_line_items, 'Should have 2 product line items when filter returns true'); + $this->assertCount( 2, $product_line_items, 'Should have 2 product line items when filter returns true' ); - // Should have a pro-rata credit line item + // 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'); + $this->assertGreaterThan( 0, count( $credit_line_items ), 'Should have pro-rata credit when plan is included' ); } finally { - // Remove filter - always cleanup even if assertions fail - remove_filter('wu_cart_addon_include_existing_plan', '__return_true'); + // Remove filter - always cleanup even if assertions fail. + remove_filter( 'wu_cart_addon_include_existing_plan', '__return_true' ); } } @@ -261,36 +307,46 @@ function ( $item ) { * The fix should only affect addon-only purchases. */ public function test_plan_upgrade_still_uses_prorate() { - // Create a higher-tier plan - $upgraded_plan = wu_create_product([ - 'name' => 'Premium Plan', - 'slug' => 'premium-plan-addon-test', - 'amount' => 150.00, - 'duration' => 1, - 'duration_unit' => 'month', - 'type' => 'plan', - 'recurring' => true, - 'setup_fee' => 0, - ]); - - $cart = new Cart([ - 'customer_id' => self::$customer->get_id(), - 'membership_id' => self::$membership->get_id(), - 'products' => [$upgraded_plan->get_id()], - ]); - - // Cart type should be 'upgrade' (or 'downgrade') - $this->assertContains($cart->get_cart_type(), ['upgrade', 'downgrade'], 'Cart type should be upgrade or downgrade'); - - // Should have a pro-rata credit for plan changes - $line_items = $cart->get_line_items(); - $credit_line_items = array_filter($line_items, function($item) { - return $item->get_type() === 'credit'; - }); + // Create a higher-tier plan. + $upgraded_plan = wu_create_product( + array( + 'name' => 'Premium Plan', + 'slug' => 'premium-plan-addon-test', + 'amount' => 150.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'plan', + 'pricing_type' => 'paid', + 'currency' => 'EUR', + 'recurring' => true, + 'setup_fee' => 0, + 'active' => true, + ) + ); + + $cart = new Cart( + array( + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => array( $upgraded_plan->get_id() ), + ) + ); + + // Cart type should be 'upgrade' (or 'downgrade'). + $this->assertContains( $cart->get_cart_type(), array( 'upgrade', 'downgrade' ), 'Cart type should be upgrade or downgrade' ); + + // Should have a pro-rata credit for plan changes. + $line_items = $cart->get_line_items(); + $credit_line_items = array_filter( + $line_items, + function ( $item ) { + return $item->get_type() === 'credit'; + } + ); - $this->assertGreaterThan(0, count($credit_line_items), 'Plan upgrades should have pro-rata credit'); + $this->assertGreaterThan( 0, count( $credit_line_items ), 'Plan upgrades should have pro-rata credit' ); - // Clean up + // Clean up. $upgraded_plan->delete(); } @@ -298,41 +354,57 @@ public function test_plan_upgrade_still_uses_prorate() { * Test that setup fees are not re-applied for addon purchases on existing memberships. */ public function test_addon_does_not_reapply_setup_fees() { - // Create an addon with a setup fee - $addon_with_fee = wu_create_product([ - 'name' => 'Addon with Fee', - 'slug' => 'addon-with-setup-fee', - 'amount' => 10.00, - 'duration' => 1, - 'duration_unit' => 'month', - 'type' => 'service', - 'recurring' => true, - 'setup_fee' => 20.00, // €20 setup fee - ]); - - $cart = new Cart([ - 'customer_id' => self::$customer->get_id(), - 'membership_id' => self::$membership->get_id(), - 'products' => [$addon_with_fee->get_id()], - ]); - - // Get all line items + // Create an addon with a setup fee. + $addon_with_fee = wu_create_product( + array( + 'name' => 'Addon with Fee', + 'slug' => 'addon-with-setup-fee', + 'amount' => 10.00, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'service', + 'pricing_type' => 'paid', + 'currency' => 'EUR', + 'recurring' => true, + 'setup_fee' => 20.00, + 'active' => true, + ) + ); + + $cart = new Cart( + array( + 'customer_id' => self::$customer->get_id(), + 'membership_id' => self::$membership->get_id(), + 'products' => array( $addon_with_fee->get_id() ), + ) + ); + + // Get all line items. $line_items = $cart->get_line_items(); - // Should have setup fee line item for the NEW addon (first time adding it) - $fee_line_items = array_filter($line_items, function($item) use ($addon_with_fee) { - return $item->get_type() === 'fee' && $item->get_product_id() === $addon_with_fee->get_id(); - }); + // Should have setup fee line item for the NEW addon (first time adding it). + $fee_line_items = array_filter( + $line_items, + function ( $item ) use ( $addon_with_fee ) { + return $item->get_type() === 'fee' && $item->get_product_id() === $addon_with_fee->get_id(); + } + ); - $this->assertCount(1, $fee_line_items, 'Should have 1 setup fee for the new addon'); + $this->assertCount( 1, $fee_line_items, 'Should have 1 setup fee for the new addon' ); - $fee_line_item = reset($fee_line_items); - $this->assertEquals(20.00, $fee_line_item->get_unit_price(), 'Setup fee should be €20'); + $fee_line_item = reset( $fee_line_items ); + $this->assertEquals( 20.00, $fee_line_item->get_unit_price(), 'Setup fee should be €20' ); - // Clean up + // Clean up. $addon_with_fee->delete(); } + /** + * Tear down test fixtures after all tests are complete. + * + * @since 2.0.12 + * @return void + */ public static function tear_down_after_class() { self::$membership->delete(); self::$addon->delete(); From c051b141dca406b40cb1e9b56cdd720fbc205817 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 6 Mar 2026 19:18:18 -0700 Subject: [PATCH 5/5] fix(checkout): resolve addon purchase issues and CodeRabbit feedback 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 --- inc/checkout/class-cart.php | 320 +++++++++--------- inc/gateways/class-base-stripe-gateway.php | 11 +- inc/models/class-membership.php | 31 +- .../Checkout/Cart_Addon_Pricing_Test.php | 104 +++--- 4 files changed, 242 insertions(+), 224 deletions(-) diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index 12400137..5443e19d 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -873,93 +873,96 @@ protected function build_from_membership($membership_id): bool { return true; } - /* - * Set the type to addon. - */ - $this->cart_type = 'addon'; + /* + * Set the type to addon. + */ + $this->cart_type = 'addon'; - /* - * Sets the durations to avoid problems - * with addon purchases. - */ - $plan_product = $membership->get_plan(); + /* + * Sets the durations to avoid problems + * with addon purchases. + */ + $plan_product = $membership->get_plan(); - if ($plan_product && ! $membership->is_free()) { - $this->duration = $plan_product->get_duration(); - $this->duration_unit = $plan_product->get_duration_unit(); - } + if ($plan_product && ! $membership->is_free()) { + $this->duration = $plan_product->get_duration(); + $this->duration_unit = $plan_product->get_duration_unit(); + } - /* - * Apply existing discount code from membership to the cart. - * This ensures that discount codes with 'apply_to_renewals' setting - * are properly applied to addon purchases. - * - * Note: get_discount_code() can return a Discount_Code object, string, or false. - * We need to ensure it's an object with the should_apply_to_renewals() method - * before attempting to call it. - * - * @since 2.0.12 - */ - $membership_discount_code = $membership->get_discount_code(); + /* + * Apply existing discount code from membership to the cart. + * This ensures that discount codes with 'apply_to_renewals' setting + * are properly applied to addon purchases. + * + * Note: get_discount_code() returns a Discount_Code object or false. + * In rare cases with legacy data, it could be a string code that needs + * to be resolved to an object. + * + * @since 2.0.12 + */ + $membership_discount_code = $membership->get_discount_code(); - if ( - $membership_discount_code - && is_object($membership_discount_code) - && method_exists($membership_discount_code, 'should_apply_to_renewals') - && $membership_discount_code->should_apply_to_renewals() - ) { - $this->add_discount_code($membership_discount_code); - $this->reapply_discounts_to_existing_line_items(); - } + // Resolve string discount codes to objects (legacy data compatibility) + if (is_string($membership_discount_code) && ! empty($membership_discount_code)) { + $membership_discount_code = wu_get_discount_code_by_code($membership_discount_code); + } - /* - * For addon-only purchases, we should NOT add the existing plan back - * at full price and then calculate pro-rata credits. This was causing - * the customer to be charged for the next billing period in advance. - * - * Instead, we only charge for the new addon products being added. - * - * The existing plan will continue to be billed on its regular schedule - * via the subscription at the gateway level. - * - * Allows filtering for special cases where the old behavior is needed. - * - * @since 2.0.12 - * @param bool $should_include Whether to include the existing plan. - * @param self $cart The cart object. - * @param \WP_Ultimo\Models\Membership $membership The existing membership. - */ - $should_include_existing_plan = apply_filters( - 'wu_cart_addon_include_existing_plan', - false, // Changed from true to false - only charge for addons - $this, - $membership - ); + if ( + $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(); + } - if ($should_include_existing_plan) { /* - * Checks the membership to see if we need to add back the - * setup fee. - * - * If the membership was already successfully charged once, - * it probably means that the setup fee was already paid, so we can skip it. - */ - add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); + * For addon-only purchases, we should NOT add the existing plan back + * at full price and then calculate pro-rata credits. This was causing + * the customer to be charged for the next billing period in advance. + * + * Instead, we only charge for the new addon products being added. + * + * The existing plan will continue to be billed on its regular schedule + * via the subscription at the gateway level. + * + * Allows filtering for special cases where the old behavior is needed. + * + * @since 2.0.12 + * @param bool $should_include Whether to include the existing plan. + * @param self $cart The cart object. + * @param \WP_Ultimo\Models\Membership $membership The existing membership. + */ + $should_include_existing_plan = apply_filters( + 'wu_cart_addon_include_existing_plan', + false, // Changed from true to false - only charge for addons + $this, + $membership + ); - $this->add_product($membership->get_plan_id()); + if ($should_include_existing_plan) { + /* + * Checks the membership to see if we need to add back the + * setup fee. + * + * If the membership was already successfully charged once, + * it probably means that the setup fee was already paid, so we can skip it. + */ + add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); - /* - * Adds the credit line, after - * calculating pro-rate. - */ - $this->calculate_prorate_credits(); - } + $this->add_product($membership->get_plan_id()); - return true; - } + /* + * Adds the credit line, after + * calculating pro-rate. + */ + $this->calculate_prorate_credits(); + } - /* - * With products added, let's check if the plan is changing. + return true; + } + + /* + * With products added, let's check if the plan is changing. * * A plan change implies a upgrade or a downgrade, which we will determine * below. @@ -986,106 +989,97 @@ protected function build_from_membership($membership_id): bool { $is_plan_change = true; } - /* - * If there is no plan change, but the product count is > 1 - * We know that there is another product in this cart other than the - * plan, so this is again an addon cart. - */ - if (count($this->products) > 1 && false === $is_plan_change) { /* - * Set the type to addon. - */ - $this->cart_type = 'addon'; + * If there is no plan change, but the product count is > 1 + * We know that there is another product in this cart other than the + * plan, so this is again an addon cart. + */ + if (count($this->products) > 1 && false === $is_plan_change) { + /* + * Set the type to addon. + */ + $this->cart_type = 'addon'; - /* - * Sets the durations to avoid problems - * with addon purchases. - */ - $plan_product = $membership->get_plan(); + /* + * Sets the durations to avoid problems + * with addon purchases. + */ + $plan_product = $membership->get_plan(); - if ($plan_product && ! $membership->is_free()) { - $this->duration = $plan_product->get_duration(); - $this->duration_unit = $plan_product->get_duration_unit(); - } + if ($plan_product && ! $membership->is_free()) { + $this->duration = $plan_product->get_duration(); + $this->duration_unit = $plan_product->get_duration_unit(); + } - /* - * Apply existing discount code from membership to the cart. - * This ensures that discount codes with 'apply_to_renewals' setting - * are properly applied to addon purchases. - * - * Note: get_discount_code() can return a Discount_Code object, string, or false. - * We need to ensure it's an object with the should_apply_to_renewals() method - * before attempting to call it. - * - * @since 2.0.12 - */ - $membership_discount_code = $membership->get_discount_code(); + /* + * Apply existing discount code from membership to the cart. + * This ensures that discount codes with 'apply_to_renewals' setting + * are properly applied to addon purchases. + * + * Note: get_discount_code() returns a Discount_Code object or false. + * In rare cases with legacy data, it could be a string code that needs + * to be resolved to an object. + * + * @since 2.0.12 + */ + $membership_discount_code = $membership->get_discount_code(); - if ( - $membership_discount_code - && is_object($membership_discount_code) - && method_exists($membership_discount_code, 'should_apply_to_renewals') + // Resolve string discount codes to objects (legacy data compatibility) + if (is_string($membership_discount_code) && ! empty($membership_discount_code)) { + $membership_discount_code = wu_get_discount_code_by_code($membership_discount_code); + } + + if ( + $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(); - } + ) { + $this->add_discount_code($membership_discount_code); + $this->reapply_discounts_to_existing_line_items(); + } - /* - * Remove the existing plan from the cart to prevent charging - * for the next billing period in advance. - * - * For addon purchases, we only want to charge for the new addon - * products, not the existing plan subscription. - * - * @since 2.0.12 - */ - foreach ($this->products as $key => $product) { - if (wu_is_plan_type($product->get_type()) && $product->get_id() === $membership->get_plan_id()) { - unset($this->products[$key]); + /* + * Remove the existing plan from the cart to prevent charging + * for the next billing period in advance. + * + * For addon purchases, we only want to charge for the new addon + * products, not the existing plan subscription. + * + * @since 2.0.12 + */ + foreach ($this->products as $key => $product) { + if (wu_is_plan_type($product->get_type()) && $product->get_id() === $membership->get_plan_id()) { + unset($this->products[ $key ]); - // Also remove line items tied to the old plan (product + fee) - foreach ($this->line_items as $line_key => $line_item) { - if ( + // Also remove line items tied to the old plan (product + fee) + foreach ($this->line_items as $line_key => $line_item) { + if ( $line_item->get_product_id() === $product->get_id() && in_array($line_item->get_type(), ['product', 'fee'], true) - ) { - unset($this->line_items[$line_key]); + ) { + unset($this->line_items[ $line_key ]); + } } - } - // Reset the plan_id since we're not changing plans - $this->plan_id = 0; - break; + // Reset the plan_id since we're not changing plans + $this->plan_id = 0; + break; + } } - } - /* - * 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); - - /* - * Do NOT calculate pro-rata credits for addon-only purchases. - * Pro-rata only makes sense when changing plans. - * - * Previously, this was incorrectly charging customers for the next - * billing period in advance when adding addons. - * - * @since 2.0.12 - */ - // Removed: $this->calculate_prorate_credits(); - - return true; - } + /* + * Do NOT calculate pro-rata credits for addon-only purchases. + * Pro-rata only makes sense when changing plans. + * + * Previously, this was incorrectly charging customers for the next + * billing period in advance when adding addons. + * + * Note: Setup fees for addon products are handled naturally by the cart - + * they are only applied to new products being added, not to existing ones. + * + * @since 2.0.12 + */ + return true; + } /* * We'll probably never enter in this if, but we @@ -2654,7 +2648,7 @@ private function reapply_discounts_to_existing_line_items() { $line_item = $this->apply_taxes_to_item($line_item); } - $this->line_items[$id] = $line_item; + $this->line_items[ $id ] = $line_item; } } diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 3d173948..49f77964 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -1364,9 +1364,18 @@ public function process_membership_update(&$membership, $customer) { $update_data = [ 'items' => array_merge($recurring_items, $existing_items), 'proration_behavior' => 'none', - 'coupon' => $s_coupon, ]; + /* + * Use 'discounts' array instead of deprecated 'coupon' parameter. + * The 'coupon' parameter was removed in newer Stripe API versions. + * + * @since 2.0.12 + */ + if ( ! empty($s_coupon)) { + $update_data['discounts'] = [['coupon' => $s_coupon]]; + } + $subscription = $this->get_stripe_client()->subscriptions->update($gateway_subscription_id, $update_data); if (empty($s_coupon) && ! empty($subscription->discount)) { diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index e1b3eca8..c2c5056a 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -713,8 +713,18 @@ public function swap($order) { return new \WP_Error('invalid-date', __('Swap Cart is invalid.', 'ultimate-multisite')); } - // clear the current addons. - $this->addon_products = []; + /* + * For addon-only carts, we merge new addons with existing ones. + * For plan changes (upgrade/downgrade), we replace all products. + * + * @since 2.0.12 + */ + $is_addon_cart = 'addon' === $order->get_cart_type(); + + if ( ! $is_addon_cart) { + // Clear the current addons for plan changes. + $this->addon_products = []; + } /* * We'll do that based on the line items, @@ -752,14 +762,19 @@ public function swap($order) { } /* - * 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()); + } /* * Returns self for chaining. diff --git a/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php index 3cbfcd65..32985b9e 100644 --- a/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php +++ b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php @@ -81,8 +81,8 @@ public static function set_up_before_class() { ) ); - if ( is_wp_error( self::$customer ) ) { - self::fail( 'Failed to create test customer' ); + if ( is_wp_error(self::$customer) ) { + self::fail('Failed to create test customer'); } // Create a plan product (€90/month). @@ -102,8 +102,8 @@ public static function set_up_before_class() { ) ); - if ( is_wp_error( self::$plan ) ) { - self::fail( 'Failed to create test plan' ); + if ( is_wp_error(self::$plan) ) { + self::fail('Failed to create test plan'); } // Create an addon product (€5). @@ -123,25 +123,25 @@ public static function set_up_before_class() { ) ); - if ( is_wp_error( self::$addon ) ) { - self::fail( 'Failed to create test addon' ); + if ( is_wp_error(self::$addon) ) { + self::fail('Failed to create test addon'); } // Create a discount code (10% off, applies to renewals). self::$discount_code = wu_create_discount_code( array( - '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, ) ); - if ( is_wp_error( self::$discount_code ) ) { - self::fail( 'Failed to create test discount code' ); + if ( is_wp_error(self::$discount_code) ) { + self::fail('Failed to create test discount code'); } // Create an active membership for the customer. @@ -156,14 +156,14 @@ public static function set_up_before_class() { 'status' => Membership_Status::ACTIVE, 'times_billed' => 1, 'discount_code' => self::$discount_code->get_code(), - 'date_created' => wu_date()->modify( '-15 days' )->format( 'Y-m-d H:i:s' ), - 'date_renewed' => wu_date()->modify( '-15 days' )->format( 'Y-m-d H:i:s' ), - 'date_expiration' => wu_date()->modify( '+15 days' )->format( 'Y-m-d H:i:s' ), + 'date_created' => wu_date()->modify('-15 days')->format('Y-m-d H:i:s'), + 'date_renewed' => wu_date()->modify('-15 days')->format('Y-m-d H:i:s'), + 'date_expiration' => wu_date()->modify('+15 days')->format('Y-m-d H:i:s'), ) ); - if ( is_wp_error( self::$membership ) ) { - self::fail( 'Failed to create test membership' ); + if ( is_wp_error(self::$membership) ) { + self::fail('Failed to create test membership'); } } @@ -180,43 +180,43 @@ public function test_addon_only_charges_for_addon_product() { array( 'customer_id' => self::$customer->get_id(), 'membership_id' => self::$membership->get_id(), - 'products' => array( self::$addon->get_id() ), + 'products' => array(self::$addon->get_id()), ) ); // The cart should be type 'addon'. - $this->assertEquals( 'addon', $cart->get_cart_type(), 'Cart type should be "addon"' ); + $this->assertEquals('addon', $cart->get_cart_type(), 'Cart type should be "addon"'); // The cart should NOT include the existing plan. $line_items = $cart->get_line_items(); $product_line_items = array_filter( $line_items, - function ( $item ) { + function ($item) { return $item->get_type() === 'product'; } ); // Should only have 1 product line item (the addon). - $this->assertCount( 1, $product_line_items, 'Should only have 1 product line item (the addon)' ); + $this->assertCount(1, $product_line_items, 'Should only have 1 product line item (the addon)'); // Verify it's the addon, not the plan. - $addon_line_item = reset( $product_line_items ); - $this->assertEquals( self::$addon->get_id(), $addon_line_item->get_product_id(), 'Product should be the addon' ); + $addon_line_item = reset($product_line_items); + $this->assertEquals(self::$addon->get_id(), $addon_line_item->get_product_id(), 'Product should be the addon'); // The subtotal should be €5.00 (addon price only). - $this->assertEquals( 5.00, $cart->get_subtotal(), 'Subtotal should be €5.00 (addon price only)' ); + $this->assertEquals(5.00, $cart->get_subtotal(), 'Subtotal should be €5.00 (addon price only)'); // There should be NO pro-rata credit line items. $credit_line_items = array_filter( $line_items, - function ( $item ) { + function ($item) { return $item->get_type() === 'credit'; } ); - $this->assertCount( 0, $credit_line_items, 'Should have NO pro-rata credit for addon-only purchases' ); + $this->assertCount(0, $credit_line_items, 'Should have NO pro-rata credit for addon-only purchases'); // Total should be €5.00 (no taxes in this test). - $this->assertEquals( 5.00, $cart->get_total(), 'Total should be €5.00' ); + $this->assertEquals(5.00, $cart->get_total(), 'Total should be €5.00'); } /** @@ -232,14 +232,14 @@ public function test_addon_applies_existing_discount_code() { array( 'customer_id' => self::$customer->get_id(), 'membership_id' => self::$membership->get_id(), - 'products' => array( self::$addon->get_id() ), + 'products' => array(self::$addon->get_id()), ) ); // The cart should have the discount code from the membership. $discount_code = $cart->get_discount_code(); - $this->assertNotNull( $discount_code, 'Discount code should be applied' ); - $this->assertEquals( 'TEST10', $discount_code->get_code(), 'Should be the membership discount code' ); + $this->assertNotNull($discount_code, 'Discount code should be applied'); + $this->assertEquals('TEST10', $discount_code->get_code(), 'Should be the membership discount code'); // The addon should have a discount applied (10% off €5 = €0.50). $line_items = $cart->get_line_items(); @@ -251,9 +251,9 @@ public function test_addon_applies_existing_discount_code() { } } - $this->assertNotNull( $addon_line_item, 'Addon line item should exist' ); - $this->assertEquals( 0.50, $addon_line_item->get_discount_total(), 'Discount should be €0.50 (10% of €5)' ); - $this->assertEquals( 4.50, $addon_line_item->get_total(), 'Addon total should be €4.50 after discount' ); + $this->assertNotNull($addon_line_item, 'Addon line item should exist'); + $this->assertEquals(0.50, $addon_line_item->get_discount_total(), 'Discount should be €0.50 (10% of €5)'); + $this->assertEquals(4.50, $addon_line_item->get_total(), 'Addon total should be €4.50 after discount'); } /** @@ -264,14 +264,14 @@ public function test_addon_applies_existing_discount_code() { */ 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' ); + add_filter('wu_cart_addon_include_existing_plan', '__return_true'); try { $cart = new Cart( array( 'customer_id' => self::$customer->get_id(), 'membership_id' => self::$membership->get_id(), - 'products' => array( self::$addon->get_id() ), + 'products' => array(self::$addon->get_id()), ) ); @@ -279,24 +279,24 @@ public function test_addon_filter_can_include_existing_plan() { $line_items = $cart->get_line_items(); $product_line_items = array_filter( $line_items, - function ( $item ) { + function ($item) { return $item->get_type() === 'product'; } ); - $this->assertCount( 2, $product_line_items, 'Should have 2 product line items when filter returns true' ); + $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 ) { + function ($item) { return $item->get_type() === 'credit'; } ); - $this->assertGreaterThan( 0, count( $credit_line_items ), 'Should have pro-rata credit when plan is included' ); + $this->assertGreaterThan(0, count($credit_line_items), 'Should have pro-rata credit when plan is included'); } finally { // Remove filter - always cleanup even if assertions fail. - remove_filter( 'wu_cart_addon_include_existing_plan', '__return_true' ); + remove_filter('wu_cart_addon_include_existing_plan', '__return_true'); } } @@ -328,23 +328,23 @@ public function test_plan_upgrade_still_uses_prorate() { array( 'customer_id' => self::$customer->get_id(), 'membership_id' => self::$membership->get_id(), - 'products' => array( $upgraded_plan->get_id() ), + 'products' => array($upgraded_plan->get_id()), ) ); // Cart type should be 'upgrade' (or 'downgrade'). - $this->assertContains( $cart->get_cart_type(), array( 'upgrade', 'downgrade' ), 'Cart type should be upgrade or downgrade' ); + $this->assertContains($cart->get_cart_type(), array('upgrade', 'downgrade'), 'Cart type should be upgrade or downgrade'); // Should have a pro-rata credit for plan changes. $line_items = $cart->get_line_items(); $credit_line_items = array_filter( $line_items, - function ( $item ) { + function ($item) { return $item->get_type() === 'credit'; } ); - $this->assertGreaterThan( 0, count( $credit_line_items ), 'Plan upgrades should have pro-rata credit' ); + $this->assertGreaterThan(0, count($credit_line_items), 'Plan upgrades should have pro-rata credit'); // Clean up. $upgraded_plan->delete(); @@ -375,7 +375,7 @@ public function test_addon_does_not_reapply_setup_fees() { array( 'customer_id' => self::$customer->get_id(), 'membership_id' => self::$membership->get_id(), - 'products' => array( $addon_with_fee->get_id() ), + 'products' => array($addon_with_fee->get_id()), ) ); @@ -385,15 +385,15 @@ public function test_addon_does_not_reapply_setup_fees() { // Should have setup fee line item for the NEW addon (first time adding it). $fee_line_items = array_filter( $line_items, - function ( $item ) use ( $addon_with_fee ) { + function ($item) use ($addon_with_fee) { return $item->get_type() === 'fee' && $item->get_product_id() === $addon_with_fee->get_id(); } ); - $this->assertCount( 1, $fee_line_items, 'Should have 1 setup fee for the new addon' ); + $this->assertCount(1, $fee_line_items, 'Should have 1 setup fee for the new addon'); - $fee_line_item = reset( $fee_line_items ); - $this->assertEquals( 20.00, $fee_line_item->get_unit_price(), 'Setup fee should be €20' ); + $fee_line_item = reset($fee_line_items); + $this->assertEquals(20.00, $fee_line_item->get_unit_price(), 'Setup fee should be €20'); // Clean up. $addon_with_fee->delete();