Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 147 additions & 38 deletions inc/checkout/class-cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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());

/*
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}

Expand Down Expand Up @@ -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.
*
Expand Down
11 changes: 10 additions & 1 deletion inc/gateways/class-base-stripe-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
6 changes: 4 additions & 2 deletions inc/limitations/class-limit-site-templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}

Expand Down
31 changes: 23 additions & 8 deletions inc/models/class-membership.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
}
Comment on lines 764 to +777
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

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

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

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

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


/*
* Returns self for chaining.
Expand Down
Loading
Loading