diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index 043fd14e..5443e19d 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -874,14 +874,14 @@ protected function build_from_membership($membership_id): bool { } /* - * Set the type to addon. - */ + * Set the type to addon. + */ $this->cart_type = 'addon'; /* - * Sets the durations to avoid problems - * with addon purchases. - */ + * Sets the durations to avoid problems + * with addon purchases. + */ $plan_product = $membership->get_plan(); if ($plan_product && ! $membership->is_free()) { @@ -890,35 +890,65 @@ protected function build_from_membership($membership_id): bool { } /* - * 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. + * + * 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(); + + // 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(); + } /* - * 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. - */ + * 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', - true, + 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. + * + * 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); + $this->add_product($membership->get_plan_id()); /* @@ -932,7 +962,7 @@ protected function build_from_membership($membership_id): bool { } /* - * With products added, let's check if the plan is changing. + * 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. @@ -960,10 +990,10 @@ protected function build_from_membership($membership_id): bool { } /* - * 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 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. @@ -982,20 +1012,72 @@ protected function build_from_membership($membership_id): bool { } /* - * Checks the membership to see if we need to add back the - * setup fee. + * 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. * - * If the membership was already successfully charged once, - * it probably means that the setup fee was already paid, so we can skip it. + * @since 2.0.12 */ - add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); + $membership_discount_code = $membership->get_discount_code(); + + // 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(); + } /* - * Adds the credit line, after - * calculating pro-rate. + * 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 */ - $this->calculate_prorate_credits(); + 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 ( + $line_item->get_product_id() === $product->get_id() + && in_array($line_item->get_type(), ['product', 'fee'], true) + ) { + unset($this->line_items[ $line_key ]); + } + } + // Reset the plan_id since we're not changing plans + $this->plan_id = 0; + break; + } + } + + /* + * 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; } @@ -2543,6 +2625,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/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/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/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 new file mode 100644 index 00000000..32985b9e --- /dev/null +++ b/tests/WP_Ultimo/Checkout/Cart_Addon_Pricing_Test.php @@ -0,0 +1,416 @@ + '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'); + } + } + + /** + * 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( + 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)'); + + // 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( + 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'); + + // 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'); + + 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'); + } + } + + /** + * 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( + 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'); + + // 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( + 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(); + } + ); + + $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(); + } + + /** + * 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(); + self::$plan->delete(); + self::$discount_code->delete(); + self::$customer->delete(); + parent::tear_down_after_class(); + } +} 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'); + } }