From 8f89d56f6983c44333b2bff0af71630396eaf7af Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 5 Aug 2026 07:59:58 -0600 Subject: [PATCH] feat: support custom plans in membership renewals --- inc/functions/membership.php | 21 +++++ inc/models/class-membership.php | 2 +- .../Functions/Membership_Functions_Test.php | 79 +++++++++++++++++++ tests/WP_Ultimo/Models/Membership_Test.php | 50 ++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) diff --git a/inc/functions/membership.php b/inc/functions/membership.php index 9466ac3e9..b6fadf5bf 100644 --- a/inc/functions/membership.php +++ b/inc/functions/membership.php @@ -324,6 +324,27 @@ function wu_membership_create_new_payment($membership, $should_cancel_pending_pa $new_payment->remove_non_recurring_items(); } + /** + * Fires before a membership payment has its totals recalculated. + * + * Addons can add auditable line items to renewal payments without + * changing the membership's recurring amount or invoking a gateway. + * + * @since 2.15.1 + * + * @param \WP_Ultimo\Models\Payment $new_payment Unsaved payment object. + * @param Membership $membership Membership being billed. + * @param bool $remove_non_recurring Whether non-recurring items were removed. + * @param bool $save Whether the payment will be saved. + */ + do_action( + 'wu_membership_new_payment_pre_totals', + $new_payment, + $membership, + $remove_non_recurring, + $save + ); + $new_payment->recalculate_totals(); if ( ! $save) { diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index ae7fa37b9..e66c4346e 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -785,7 +785,7 @@ public function swap($order) { * If that's the case, we need to replace the current * plan id. */ - if ($product->get_type() === 'plan') { + if (wu_is_plan_type($product->get_type())) { $this->set_plan_id($product->get_id()); continue; diff --git a/tests/WP_Ultimo/Functions/Membership_Functions_Test.php b/tests/WP_Ultimo/Functions/Membership_Functions_Test.php index 1d5b3f42e..e09b8e34c 100644 --- a/tests/WP_Ultimo/Functions/Membership_Functions_Test.php +++ b/tests/WP_Ultimo/Functions/Membership_Functions_Test.php @@ -581,6 +581,85 @@ public function test_membership_create_new_payment_no_save(): void { $this->assertEmpty($payment->get_id()); } + /** + * Test addons can add renewal line items before totals are calculated. + */ + public function test_membership_create_new_payment_exposes_pre_totals_action(): void { + + $user_id = self::factory()->user->create(); + + while (wu_get_customer_by_user_id($user_id)) { + $user_id = self::factory()->user->create(); + } + + $customer = wu_create_customer([ + 'user_id' => $user_id, + 'skip_validation' => true, + ]); + + $this->assertNotWPError($customer); + + $product = wu_create_product([ + 'name' => 'Extensible Renewal Plan', + 'slug' => 'extensible-renewal-plan-' . wp_rand(), + 'type' => 'plan', + 'amount' => 49.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'skip_validation' => true, + ]); + + $this->assertNotWPError($product); + + $membership = wu_create_membership([ + 'customer_id' => $customer->get_id(), + 'plan_id' => $product->get_id(), + 'status' => 'active', + 'amount' => 49.00, + 'initial_amount' => 49.00, + 'currency' => 'USD', + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'skip_validation' => true, + ]); + + $this->assertNotWPError($membership); + + $context = []; + $callback = static function ($payment, $hook_membership, $remove_non_recurring, $save) use (&$context): void { + if ($hook_membership->get_id() !== $payment->get_membership_id()) { + return; + } + + $payment->add_line_item( + new \WP_Ultimo\Checkout\Line_Item([ + 'hash' => 'test_metered_overage', + 'type' => 'fee', + 'title' => 'Metered overage', + 'quantity' => 2, + 'unit_price' => 4.00, + 'recurring' => false, + 'discountable' => false, + ]) + ); + + $context = [$remove_non_recurring, $save]; + }; + + add_action('wu_membership_new_payment_pre_totals', $callback, 10, 4); + + $payment = wu_membership_create_new_payment($membership, false, true, false); + + remove_action('wu_membership_new_payment_pre_totals', $callback, 10); + + $this->assertNotWPError($payment); + $this->assertSame([true, false], $context); + $this->assertEqualsWithDelta(57.00, $payment->get_total(), 0.001); + $this->assertArrayHasKey('LN_FEE_test_metered_overage', $payment->get_line_items()); + } + /** * Test wu_membership_create_new_payment cancels existing pending payment. */ diff --git a/tests/WP_Ultimo/Models/Membership_Test.php b/tests/WP_Ultimo/Models/Membership_Test.php index 7080a49d6..8eda9c21d 100644 --- a/tests/WP_Ultimo/Models/Membership_Test.php +++ b/tests/WP_Ultimo/Models/Membership_Test.php @@ -1079,6 +1079,56 @@ public function test_swap_with_invalid_order(): void { $this->assertInstanceOf(\WP_Error::class, $result); } + /** + * Test swap recognizes addon-registered plan product types. + */ + public function test_swap_replaces_plan_for_registered_custom_plan_type(): void { + $register_network_type = static function (array $types): array { + $types[] = 'network'; + + return $types; + }; + + add_filter('wu_plan_product_types', $register_network_type); + + $network_product = new Product( + [ + 'name' => 'Network Plan', + 'slug' => 'network-plan-' . wp_generate_password(6, false), + 'description' => 'A custom plan type registered by an addon', + 'pricing_type' => 'paid', + 'amount' => 49.00, + 'currency' => 'USD', + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'network', + 'recurring' => true, + 'active' => true, + ] + ); + $network_product->set_skip_validation(true); + $network_product->save(); + + $cart = new \WP_Ultimo\Checkout\Cart( + [ + 'cart_type' => 'upgrade', + 'products' => [$network_product->get_id()], + 'duration' => 1, + 'duration_unit' => 'month', + 'membership_id' => $this->membership->get_id(), + 'currency' => 'USD', + ] + ); + + $result = $this->membership->swap($cart); + + remove_filter('wu_plan_product_types', $register_network_type); + + $this->assertSame($this->membership, $result); + $this->assertSame($network_product->get_id(), $this->membership->get_plan_id()); + $this->assertNotContains($network_product->get_id(), $this->membership->get_addon_ids()); + } + /** * Test get_scheduled_swap returns false when nothing scheduled. */