From 406ca541e69a9f805744f37442b5446c26117800 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Tue, 19 May 2026 13:04:00 -0300 Subject: [PATCH 01/17] test(wc-subscriptions): add get_total to order-item mock --- tests/mocks/wc-mocks.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index c973c2272d..9b1de65a5f 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -135,6 +135,9 @@ public function get_product_id() { public function get_subtotal() { return $this->data['subtotal'] ?? 0; } + public function get_total() { + return $this->data['total'] ?? 0; + } public function get_product() { global $products_database; $product_id = $this->data['product_id'] ?? 0; From 96236dde4704297521558a664e170e800dfe907e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Tue, 19 May 2026 13:18:45 -0300 Subject: [PATCH 02/17] fix(wc-subscriptions): recover switch proration when no amount paid --- .../class-woocommerce-subscriptions.php | 44 ++++++++++++ .../class-woocommerce-subscriptions.php | 68 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 7d06aadc82..137fb1cf08 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -22,6 +22,7 @@ public static function init() { add_filter( 'woocommerce_subscriptions_product_trial_length', [ __CLASS__, 'limit_free_trials_to_one_per_user' ], 10, 2 ); add_filter( 'wcs_get_users_subscriptions', [ __CLASS__, 'filter_subscriptions_for_account_page' ], 10, 1 ); add_filter( 'woocommerce_subscriptions_can_item_be_switched', [ __CLASS__, 'allow_migrated_subscription_switch' ], 10, 3 ); + add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -79,6 +80,49 @@ public static function allow_migrated_subscription_switch( $can_switch, $item, $ return $can_switch; } + /** + * Recover the proration baseline when WooCommerce Subscriptions cannot + * determine an amount paid for the current billing period. + * + * WCS sums the matching line item across the subscription's related orders. + * That sum is 0 for migrated subscriptions (no parent/renewal order), + * 100%-discount or comped purchases (order exists, $0 paid), and broken + * cross-product switch chains. A 0 baseline makes WCS treat the old + * subscription as $0/day, misclassify downgrades as upgrades, and charge + * the full prorated price of the new plan as a sign-up fee. + * + * When the WCS value is non-positive, fall back to the subscription line + * item's recurring total (one billing period's recurring charge), which is + * dimensionally what WCS divides by the old billing cycle length. A + * genuinely free subscription has a 0 recurring total and correctly stays + * at 0, so no phantom credit is created. + * + * @param float $total_paid The amount WCS computed for the current period. + * @param \WC_Subscription $subscription The subscription being switched. + * @param \WC_Order_Item_Product $existing_item The subscription line item being switched. + * + * @return float The corrected amount paid for the current period. + */ + public static function recover_total_paid_for_switch( $total_paid, $subscription, $existing_item ) { + // Only intervene when WCS could not determine a positive amount paid. + if ( (float) $total_paid > 0 ) { + return $total_paid; + } + + if ( ! is_object( $existing_item ) || ! method_exists( $existing_item, 'get_total' ) ) { + return $total_paid; + } + + $recurring_total = (float) $existing_item->get_total(); + + // Never reduce the value WCS produced; only fill a missing baseline. + if ( $recurring_total <= (float) $total_paid ) { + return $total_paid; + } + + return $recurring_total; + } + /** * Initialize WooCommerce Subscriptions Integration. */ diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 74d37600b9..0464596a2f 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -160,4 +160,72 @@ public function test_get_user_subscription_cancelled_returns_null() { $result = WooCommerce_Subscriptions::get_user_subscription( $product, $user_id ); $this->assertNull( $result, 'Should return null for a cancelled subscription.' ); } + + /** + * When WCS finds no amount paid, recover the baseline from the + * subscription's recurring line-item total. + */ + public function test_recover_total_paid_when_wcs_returns_zero() { + $subscription = new WC_Subscription( + [ + 'id' => 1, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 50.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 50.0, $result, 'A zero WCS value should fall back to the recurring line-item total.' ); + } + + /** + * A legitimate non-zero WCS value must be returned unchanged. + */ + public function test_recover_total_paid_leaves_positive_value_untouched() { + $subscription = new WC_Subscription( + [ + 'id' => 2, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 50.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 12.34, $subscription, $existing_item ); + + $this->assertSame( 12.34, $result, 'A positive WCS value must not be overridden.' ); + } + + /** + * A genuinely free subscription (recurring total is zero) must stay zero + * so no phantom credit is created. + */ + public function test_recover_total_paid_stays_zero_for_free_subscription() { + $subscription = new WC_Subscription( + [ + 'id' => 3, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 0.0, $result, 'A free subscription must not gain a phantom proration credit.' ); + } } From 61c840db7c1f7fd26e3a94b1832b43a65da3a31a Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Tue, 19 May 2026 14:07:57 -0300 Subject: [PATCH 03/17] refactor(wc-subscriptions): tighten switch proration item guard --- .../class-woocommerce-subscriptions.php | 2 +- .../class-woocommerce-subscriptions.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 137fb1cf08..83d2db1744 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -109,7 +109,7 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription return $total_paid; } - if ( ! is_object( $existing_item ) || ! method_exists( $existing_item, 'get_total' ) ) { + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { return $total_paid; } diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 0464596a2f..c406de388e 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -228,4 +228,21 @@ public function test_recover_total_paid_stays_zero_for_free_subscription() { $this->assertSame( 0.0, $result, 'A free subscription must not gain a phantom proration credit.' ); } + + /** + * A non-object / unexpected existing item must be passed through untouched + * so the filter never fatals or fabricates a value. + */ + public function test_recover_total_paid_passes_through_when_item_not_an_order_item() { + $subscription = new WC_Subscription( + [ + 'id' => 4, + 'status' => 'active', + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, null ); + + $this->assertSame( 0.0, $result, 'A non-order-item argument must be returned unchanged.' ); + } } From 9e731ad9f2d75d175399af6dead191e143d30713 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Tue, 19 May 2026 17:12:16 -0300 Subject: [PATCH 04/17] fix(wc-subscriptions): skip switch proration recovery during trial --- .../class-woocommerce-subscriptions.php | 8 ++++++ tests/mocks/wc-mocks.php | 3 +++ .../class-woocommerce-subscriptions.php | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 83d2db1744..3fb9822f02 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -109,6 +109,14 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription return $total_paid; } + // A subscription still within its free trial has paid nothing and has + // no accrued credit. Recovering a baseline here would let an unpaid + // trial be switched into manufactured proration credit, so leave WCS's + // value untouched. + if ( $subscription instanceof \WC_Subscription && $subscription->get_time( 'trial_end' ) > time() ) { + return $total_paid; + } + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { return $total_paid; } diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index 9b1de65a5f..986b47e550 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -365,6 +365,9 @@ public function get_parent() { public function get_date( $type ) { return $this->data['dates'][ $type ] ?? 0; } + public function get_time( $type ) { + return $this->data['times'][ $type ] ?? 0; + } public function calculate_date() { $start = strtotime( $this->get_date( 'start' ) ); $interval = $this->get_billing_interval(); diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index c406de388e..96d3df014b 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -245,4 +245,31 @@ public function test_recover_total_paid_passes_through_when_item_not_an_order_it $this->assertSame( 0.0, $result, 'A non-order-item argument must be returned unchanged.' ); } + + /** + * A subscription still in its free trial must not gain a recovered + * baseline, otherwise an unpaid trial could be switched into + * manufactured proration credit. + */ + public function test_recover_total_paid_skips_active_free_trial() { + $subscription = new WC_Subscription( + [ + 'id' => 5, + 'status' => 'active', + 'times' => [ + 'trial_end' => time() + DAY_IN_SECONDS, + ], + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 50.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 0.0, $result, 'A subscription in an active free trial must not receive a recovered baseline.' ); + } } From 21be8e31769c97c6a6e1ccc506159848ef7ebbbb Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 20 May 2026 15:56:50 -0300 Subject: [PATCH 05/17] fix(wc-subscriptions): scope switch proration recovery to migrated subs --- .../class-woocommerce-subscriptions.php | 67 ++++++++++++------- .../class-woocommerce-subscriptions.php | 27 ++++++++ 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 3fb9822f02..6a93d4d26d 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -26,6 +26,25 @@ public static function init() { add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } + /** + * Detect a migrated subscription by the meta a migration writes. + * + * @param \WC_Subscription $subscription The subscription to check. + * + * @return bool True if the subscription carries Piano or Stripe migration meta. + */ + private static function is_migrated_subscription( $subscription ) { + if ( ! ( $subscription instanceof \WC_Subscription ) ) { + return false; + } + foreach ( [ '_piano_subscription_id', '_stripe_subscription_id' ] as $meta_key ) { + if ( $subscription->get_meta( $meta_key ) ) { + return true; + } + } + return false; + } + /** * Filter to allow migrated subscription without a last order date to switch. * @@ -48,16 +67,7 @@ public static function allow_migrated_subscription_switch( $can_switch, $item, $ return $can_switch; } - // Detect whether it's a migrated subscription. - $migrated_meta = [ '_piano_subscription_id', '_stripe_subscription_id' ]; - $migrated = false; - foreach ( $migrated_meta as $meta ) { - if ( $subscription->get_meta( $meta ) ) { - $migrated = true; - break; - } - } - if ( ! $migrated ) { + if ( ! self::is_migrated_subscription( $subscription ) ) { return $can_switch; } @@ -81,21 +91,23 @@ public static function allow_migrated_subscription_switch( $can_switch, $item, $ } /** - * Recover the proration baseline when WooCommerce Subscriptions cannot - * determine an amount paid for the current billing period. + * Recover the proration baseline for migrated subscriptions that have no + * WooCommerce order history. * - * WCS sums the matching line item across the subscription's related orders. - * That sum is 0 for migrated subscriptions (no parent/renewal order), - * 100%-discount or comped purchases (order exists, $0 paid), and broken - * cross-product switch chains. A 0 baseline makes WCS treat the old - * subscription as $0/day, misclassify downgrades as upgrades, and charge - * the full prorated price of the new plan as a sign-up fee. + * WCS sums the matching line item across the subscription's related + * orders to determine the amount paid for the current billing period. + * For subscriptions migrated from another platform (Piano, Stripe) that + * sum is `0` because no parent or renewal order exists. A `0` baseline + * makes WCS treat the old subscription as `$0/day`, misclassify + * downgrades as upgrades, and charge the full prorated price of the new + * plan as a sign-up fee. * - * When the WCS value is non-positive, fall back to the subscription line - * item's recurring total (one billing period's recurring charge), which is - * dimensionally what WCS divides by the old billing cycle length. A - * genuinely free subscription has a 0 recurring total and correctly stays - * at 0, so no phantom credit is created. + * When the subscription is migrated and WCS produced a non-positive + * amount paid, fall back to the subscription line item's recurring total + * (one billing period's recurring charge), which is dimensionally what + * WCS divides by the old billing cycle length. All other zero-paid + * subscriptions are left to WCS's default behavior on purpose: we do not + * carry discounts or comps across switches. * * @param float $total_paid The amount WCS computed for the current period. * @param \WC_Subscription $subscription The subscription being switched. @@ -117,6 +129,15 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription return $total_paid; } + // The recovery exists to backfill proration for subscriptions migrated + // into WooCommerce from another platform (which have no Woo order + // history). For every other zero-paid case (100%-discount purchases, + // comps, etc.) WCS's default switching behavior is intentional and + // must not be overridden, so leave it alone. + if ( ! self::is_migrated_subscription( $subscription ) ) { + return $total_paid; + } + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { return $total_paid; } diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 96d3df014b..d4b2979a00 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -170,6 +170,7 @@ public function test_recover_total_paid_when_wcs_returns_zero() { [ 'id' => 1, 'status' => 'active', + 'meta' => [ '_piano_subscription_id' => 'piano-1' ], ] ); $existing_item = new WC_Order_Item_Product( @@ -215,6 +216,7 @@ public function test_recover_total_paid_stays_zero_for_free_subscription() { [ 'id' => 3, 'status' => 'active', + 'meta' => [ '_piano_subscription_id' => 'piano-3' ], ] ); $existing_item = new WC_Order_Item_Product( @@ -259,6 +261,7 @@ public function test_recover_total_paid_skips_active_free_trial() { 'times' => [ 'trial_end' => time() + DAY_IN_SECONDS, ], + 'meta' => [ '_piano_subscription_id' => 'piano-5' ], ] ); $existing_item = new WC_Order_Item_Product( @@ -272,4 +275,28 @@ public function test_recover_total_paid_skips_active_free_trial() { $this->assertSame( 0.0, $result, 'A subscription in an active free trial must not receive a recovered baseline.' ); } + + /** + * A non-migrated subscription must not gain a recovered baseline. WCS's + * default switching behavior is intentional for comped, discounted, or + * otherwise zero-paid subscriptions that originate in WooCommerce. + */ + public function test_recover_total_paid_skips_non_migrated_subscription() { + $subscription = new WC_Subscription( + [ + 'id' => 6, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 50.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 0.0, $result, 'A non-migrated subscription must be left to WCS default behavior.' ); + } } From 1a1098c0aaa42cf7c01dfff610421845356af9f3 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 22 May 2026 16:47:35 -0300 Subject: [PATCH 06/17] test(wc-subscriptions): add WC_Subscriptions_Switcher mock --- tests/mocks/wc-mocks.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index 986b47e550..de6f909589 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -400,6 +400,21 @@ public function save() { class WC_Subscriptions { } +if ( ! class_exists( 'WC_Subscriptions_Switcher' ) ) { + /** + * Mock of WC_Subscriptions_Switcher. + * + * Calculate_total_paid_since_last_order() returns the value of the + * $wcs_mock_total_paid_including_signup_fee global so tests can drive it. + */ + class WC_Subscriptions_Switcher { + public static function calculate_total_paid_since_last_order( $subscription, $subscription_item, $include_sign_up_fees = 'include_sign_up_fees', $orders_to_include = [] ) { + global $wcs_mock_total_paid_including_signup_fee; + return $wcs_mock_total_paid_including_signup_fee ?? 0; + } + } +} + if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { class WC_Subscriptions_Product { } From fe50c0a9ca41867f1122de6938f8242a0a1d2cd7 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 22 May 2026 16:51:11 -0300 Subject: [PATCH 07/17] feat(wc-subscriptions): count paid sign-up fee in switch proration --- .../class-woocommerce-subscriptions.php | 124 +++++++++++++----- .../class-woocommerce-subscriptions.php | 121 ++++++++++++++++- 2 files changed, 208 insertions(+), 37 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 6a93d4d26d..39e6ca79fe 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -91,23 +91,27 @@ public static function allow_migrated_subscription_switch( $can_switch, $item, $ } /** - * Recover the proration baseline for migrated subscriptions that have no - * WooCommerce order history. + * Recover the switch proration baseline when WooCommerce Subscriptions + * cannot determine the amount paid for the current billing period. * * WCS sums the matching line item across the subscription's related - * orders to determine the amount paid for the current billing period. - * For subscriptions migrated from another platform (Piano, Stripe) that - * sum is `0` because no parent or renewal order exists. A `0` baseline - * makes WCS treat the old subscription as `$0/day`, misclassify - * downgrades as upgrades, and charge the full prorated price of the new - * plan as a sign-up fee. - * - * When the subscription is migrated and WCS produced a non-positive - * amount paid, fall back to the subscription line item's recurring total - * (one billing period's recurring charge), which is dimensionally what - * WCS divides by the old billing cycle length. All other zero-paid - * subscriptions are left to WCS's default behavior on purpose: we do not - * carry discounts or comps across switches. + * orders. That sum is `0` in two cases this method handles: + * + * - Migrated subscriptions (Piano, Stripe) have no Woo order history, so + * there is nothing to sum. The recurring line-item total is used as the + * baseline (one billing period's recurring charge), which is + * dimensionally what WCS divides by the old billing cycle length. + * + * - Paid-trial subscriptions (a one-time sign-up fee plus a free trial) + * have an order, but WCS excludes the sign-up fee from the amount paid, + * so a switch during the trial sees `0`. When the publisher opts in via + * the NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE constant, the amount + * paid including the sign-up fee is used instead. + * + * A `0` baseline makes WCS treat the old subscription as `$0/day`, + * misclassify downgrades as upgrades, and charge the full prorated price + * of the new plan as a sign-up fee. Every other zero-paid case (100%- + * discount purchases, comps) is left to WCS's default behavior on purpose. * * @param float $total_paid The amount WCS computed for the current period. * @param \WC_Subscription $subscription The subscription being switched. @@ -121,35 +125,87 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription return $total_paid; } - // A subscription still within its free trial has paid nothing and has - // no accrued credit. Recovering a baseline here would let an unpaid - // trial be switched into manufactured proration credit, so leave WCS's - // value untouched. - if ( $subscription instanceof \WC_Subscription && $subscription->get_time( 'trial_end' ) > time() ) { + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { return $total_paid; } - // The recovery exists to backfill proration for subscriptions migrated - // into WooCommerce from another platform (which have no Woo order - // history). For every other zero-paid case (100%-discount purchases, - // comps, etc.) WCS's default switching behavior is intentional and - // must not be overridden, so leave it alone. - if ( ! self::is_migrated_subscription( $subscription ) ) { - return $total_paid; + // Branch 1: subscriptions migrated into WooCommerce from another + // platform (Piano, Stripe) have no Woo order history, so WCS cannot + // see what was paid. Fall back to the recurring line-item total. + if ( self::is_migrated_subscription( $subscription ) ) { + // A migrated subscription still within its free trial has paid + // nothing and has no accrued credit -- recovering a baseline here + // would let an unpaid trial be switched into manufactured credit. + if ( $subscription instanceof \WC_Subscription && $subscription->get_time( 'trial_end' ) > time() ) { + return $total_paid; + } + + $recurring_total = (float) $existing_item->get_total(); + return $recurring_total > (float) $total_paid ? $recurring_total : $total_paid; } - if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { - return $total_paid; + // Branch 2: publishers that sell stepped pricing as a one-time sign-up + // fee plus a free trial. WCS excludes the sign-up fee from the amount + // paid, so a switch during the trial sees $0. When the publisher has + // opted in, count the sign-up fee the reader actually paid. A free + // trial with no sign-up fee, or a comp, yields nothing and no-ops. + if ( self::should_count_signup_fee_on_switch() ) { + $paid_with_signup_fee = self::get_total_paid_including_signup_fee( $subscription, $existing_item ); + return $paid_with_signup_fee > (float) $total_paid ? $paid_with_signup_fee : $total_paid; } - $recurring_total = (float) $existing_item->get_total(); + return $total_paid; + } - // Never reduce the value WCS produced; only fill a missing baseline. - if ( $recurring_total <= (float) $total_paid ) { - return $total_paid; + /** + * Whether a paid one-time sign-up fee should count toward the switch + * proration baseline. + * + * Off by default. Publishers that sell stepped pricing as a sign-up fee + * plus a free trial opt in by defining the + * NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE constant in wp-config.php. + * + * @return bool + */ + private static function should_count_signup_fee_on_switch() { + $enabled = defined( 'NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE' ) && NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE; + + /** + * Filters whether a paid one-time sign-up fee is counted toward the + * proration baseline when switching subscriptions. + * + * @param bool $enabled Whether the sign-up fee is counted. + */ + return (bool) apply_filters( 'newspack_wc_subs_switch_include_signup_fee', $enabled ); + } + + /** + * Get the amount paid for the current billing period including the + * one-time sign-up fee. + * + * Reuses WooCommerce Subscriptions' own accounting -- which walks the + * subscription's related orders and handles trials, synced fees, switch + * chains, and tax -- but includes sign-up fees, where WCS's + * get_total_paid_for_current_period() excludes them. + * + * @param \WC_Subscription $subscription The subscription being switched. + * @param \WC_Order_Item_Product $existing_item The subscription line item being switched. + * + * @return float The amount paid including sign-up fees, or 0 if it cannot be determined. + */ + private static function get_total_paid_including_signup_fee( $subscription, $existing_item ) { + if ( + ! class_exists( 'WC_Subscriptions_Switcher' ) + || ! method_exists( 'WC_Subscriptions_Switcher', 'calculate_total_paid_since_last_order' ) + ) { + return 0.0; } - return $recurring_total; + return (float) \WC_Subscriptions_Switcher::calculate_total_paid_since_last_order( + $subscription, + $existing_item, + 'include_sign_up_fees' + ); } /** diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index d4b2979a00..764871a775 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -21,9 +21,10 @@ class Newspack_Test_WooCommerce_Subscriptions extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); - global $subscriptions_database, $products_database; - $subscriptions_database = []; - $products_database = []; + global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee; + $subscriptions_database = []; + $products_database = []; + $wcs_mock_total_paid_including_signup_fee = 0; } /** @@ -299,4 +300,118 @@ public function test_recover_total_paid_skips_non_migrated_subscription() { $this->assertSame( 0.0, $result, 'A non-migrated subscription must be left to WCS default behavior.' ); } + + /** + * With sign-up-fee counting enabled, a non-migrated subscription whose + * amount paid (including the sign-up fee) is higher than WCS's value + * recovers to the sign-up-fee-inclusive amount. + */ + public function test_recover_total_paid_counts_signup_fee_when_enabled() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 30.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 10, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 30.0, $result, 'The paid sign-up fee should become the recovered baseline when counting is enabled.' ); + } + + /** + * With sign-up-fee counting disabled (the default), a non-migrated + * subscription is left to WCS's default switching behavior even when a + * sign-up fee was paid. + */ + public function test_recover_total_paid_skips_signup_fee_when_disabled() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 30.0; + + $subscription = new WC_Subscription( + [ + 'id' => 11, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 0.0, $result, 'A non-migrated subscription must not recover the sign-up fee while counting is disabled.' ); + } + + /** + * With sign-up-fee counting enabled but nothing actually paid (a comped + * purchase, no sign-up fee), the subscription is left untouched. + */ + public function test_recover_total_paid_skips_signup_fee_when_nothing_paid() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 0.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 12, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 0.0, $result, 'With no sign-up fee actually paid there is nothing to recover.' ); + } + + /** + * A migrated subscription is recovered through the migrated branch even + * when sign-up-fee counting is enabled; the sign-up-fee branch is not + * reached. + */ + public function test_recover_total_paid_migrated_takes_precedence_over_signup_fee() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 999.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 13, + 'status' => 'active', + 'meta' => [ '_piano_subscription_id' => 'piano-13' ], + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 50.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 50.0, $result, 'A migrated subscription must recover via the recurring total, not the sign-up-fee branch.' ); + } } From 693e7b916247470b51d333109daa519e59d6290c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 22 May 2026 17:01:46 -0300 Subject: [PATCH 08/17] test(wc-subscriptions): remove signup-fee filter in test teardown --- .../class-woocommerce-subscriptions.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 764871a775..0aa7368038 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -27,6 +27,14 @@ public function set_up() { $wcs_mock_total_paid_including_signup_fee = 0; } + /** + * Remove filters added by individual tests so they do not leak across tests. + */ + public function tear_down() { + remove_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + parent::tear_down(); + } + /** * Test WooCommerce_Subscriptions::is_active. */ From 4a9181d8637a2197e2193d361063af8a71779aee Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 25 May 2026 13:44:39 -0300 Subject: [PATCH 09/17] feat(wc-subscriptions): clamp switch cycle for migrated subs --- .../class-woocommerce-subscriptions.php | 74 ++++-- tests/mocks/wc-mocks.php | 24 +- .../class-woocommerce-subscriptions.php | 214 +++++++++++++++++- 3 files changed, 289 insertions(+), 23 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 39e6ca79fe..e23445dab9 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -23,6 +23,7 @@ public static function init() { add_filter( 'wcs_get_users_subscriptions', [ __CLASS__, 'filter_subscriptions_for_account_page' ], 10, 1 ); add_filter( 'woocommerce_subscriptions_can_item_be_switched', [ __CLASS__, 'allow_migrated_subscription_switch' ], 10, 3 ); add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); + add_filter( 'wcs_switch_proration_days_in_old_cycle', [ __CLASS__, 'bound_switch_proration_days_in_old_cycle' ], 10, 2 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -131,17 +132,18 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription // Branch 1: subscriptions migrated into WooCommerce from another // platform (Piano, Stripe) have no Woo order history, so WCS cannot - // see what was paid. Fall back to the recurring line-item total. + // see what was paid. Fall back to the recurring line-item total. The + // companion wcs_switch_proration_days_in_old_cycle filter bounds the + // denominator so the per-day baseline matches a single billing cycle. if ( self::is_migrated_subscription( $subscription ) ) { // A migrated subscription still within its free trial has paid // nothing and has no accrued credit -- recovering a baseline here // would let an unpaid trial be switched into manufactured credit. - if ( $subscription instanceof \WC_Subscription && $subscription->get_time( 'trial_end' ) > time() ) { + if ( $subscription->get_time( 'trial_end' ) > time() ) { return $total_paid; } - $recurring_total = (float) $existing_item->get_total(); - return $recurring_total > (float) $total_paid ? $recurring_total : $total_paid; + return max( (float) $existing_item->get_total(), (float) $total_paid ); } // Branch 2: publishers that sell stepped pricing as a one-time sign-up @@ -150,20 +152,61 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription // opted in, count the sign-up fee the reader actually paid. A free // trial with no sign-up fee, or a comp, yields nothing and no-ops. if ( self::should_count_signup_fee_on_switch() ) { - $paid_with_signup_fee = self::get_total_paid_including_signup_fee( $subscription, $existing_item ); - return $paid_with_signup_fee > (float) $total_paid ? $paid_with_signup_fee : $total_paid; + return max( self::get_total_paid_including_signup_fee( $subscription, $existing_item ), (float) $total_paid ); } return $total_paid; } + /** + * Bound the switch proration denominator to one billing cycle for migrated + * subscriptions. + * + * WCS computes days_in_old_cycle as + * (next_payment_timestamp - last_order_paid_time) / DAY_IN_SECONDS. For a + * migrated subscription with no Woo order history, last_order_paid_time + * falls back to the subscription's start timestamp -- the original + * platform sign-up date, which can be months or years in the past. The + * resulting denominator spans many billing cycles, which would make + * old_price_per_day artificially low and still misclassify a downgrade as + * an upgrade even after recover_total_paid_for_switch supplies one cycle's + * worth of recurring total. + * + * Clamping to one billing cycle here keeps both sides of WCS's per-day + * price calculation in agreement for migrated subscriptions. Non-migrated + * subscriptions are left to WCS's default behavior. + * + * @param int $days_in_old_cycle The number of days WCS computed for the old cycle. + * @param \WC_Subscription $subscription The subscription being switched. + * + * @return int The (possibly bounded) number of days in the old cycle. + */ + public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cycle, $subscription ) { + if ( ! self::is_migrated_subscription( $subscription ) ) { + return $days_in_old_cycle; + } + + if ( ! function_exists( 'wcs_get_days_in_cycle' ) ) { + return $days_in_old_cycle; + } + + $cycle_days = (int) wcs_get_days_in_cycle( $subscription->get_billing_period(), $subscription->get_billing_interval() ); + if ( $cycle_days <= 0 ) { + return $days_in_old_cycle; + } + + return min( (int) $days_in_old_cycle, $cycle_days ); + } + /** * Whether a paid one-time sign-up fee should count toward the switch * proration baseline. * * Off by default. Publishers that sell stepped pricing as a sign-up fee * plus a free trial opt in by defining the - * NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE constant in wp-config.php. + * NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE constant in wp-config.php, or + * by returning true from the newspack_wc_subs_switch_include_signup_fee + * filter for finer-grained control (e.g. per-subscription or per-product). * * @return bool */ @@ -201,6 +244,14 @@ private static function get_total_paid_including_signup_fee( $subscription, $exi return 0.0; } + // Leaving the 4th argument ($orders_to_include) at its default of an + // empty array means WCS scans all related orders. WCS's own caller in + // WCS_Switch_Cart_Item::get_total_paid_for_current_period() narrows + // this for switch chains via is_switch_after_fully_reduced_prepaid_term(); + // we accept the broader scan because this branch only fires for + // publishers who opted in to counting the sign-up fee, where a long + // switch chain at the same product price would still sum to the same + // amount the reader paid. return (float) \WC_Subscriptions_Switcher::calculate_total_paid_since_last_order( $subscription, $existing_item, @@ -507,14 +558,7 @@ public static function allow_migrated_subscription_to_resubscribe( $can_resubscr return false; } - $migrated_meta = [ '_piano_subscription_id', '_stripe_subscription_id' ]; - foreach ( $migrated_meta as $meta ) { - if ( $subscription->get_meta( $meta ) ) { - $can_resubscribe = true; - break; - } - } - return $can_resubscribe; + return self::is_migrated_subscription( $subscription ); } } WooCommerce_Subscriptions::init(); diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index de6f909589..a68017203d 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -404,12 +404,21 @@ class WC_Subscriptions { /** * Mock of WC_Subscriptions_Switcher. * - * Calculate_total_paid_since_last_order() returns the value of the - * $wcs_mock_total_paid_including_signup_fee global so tests can drive it. + * The calculate_total_paid_since_last_order() method returns the value of + * the $wcs_mock_total_paid_including_signup_fee global so tests can drive + * it, and records the arguments it was called with on + * $wcs_mock_last_calculate_total_paid_args so tests can assert that the + * caller passed the expected sign-up-fee mode and orders_to_include list. */ class WC_Subscriptions_Switcher { public static function calculate_total_paid_since_last_order( $subscription, $subscription_item, $include_sign_up_fees = 'include_sign_up_fees', $orders_to_include = [] ) { - global $wcs_mock_total_paid_including_signup_fee; + global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args; + $wcs_mock_last_calculate_total_paid_args = [ + 'subscription' => $subscription, + 'subscription_item' => $subscription_item, + 'include_sign_up_fees' => $include_sign_up_fees, + 'orders_to_include' => $orders_to_include, + ]; return $wcs_mock_total_paid_including_signup_fee ?? 0; } } @@ -511,6 +520,15 @@ function wcs_get_canonical_product_id( $item ) { } return null; } +function wcs_get_days_in_cycle( $period, $interval ) { + $days_per_period = [ + 'day' => 1, + 'week' => 7, + 'month' => 30, + 'year' => 365, + ]; + return ( $days_per_period[ $period ] ?? 0 ) * (int) $interval; +} function wc_string_to_bool( $string ) { return is_bool( $string ) ? $string : ( 'yes' === strtolower( $string ) || '1' === $string || 'true' === strtolower( $string ) ); } diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 0aa7368038..7dd1b0e26f 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -21,17 +21,22 @@ class Newspack_Test_WooCommerce_Subscriptions extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); - global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee; - $subscriptions_database = []; - $products_database = []; + global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args; + $subscriptions_database = []; + $products_database = []; $wcs_mock_total_paid_including_signup_fee = 0; + $wcs_mock_last_calculate_total_paid_args = null; } /** - * Remove filters added by individual tests so they do not leak across tests. + * Reset any filters or mock state added by individual tests so they do + * not leak across tests. */ public function tear_down() { - remove_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args; + $wcs_mock_total_paid_including_signup_fee = 0; + $wcs_mock_last_calculate_total_paid_args = null; + remove_all_filters( 'newspack_wc_subs_switch_include_signup_fee' ); parent::tear_down(); } @@ -422,4 +427,203 @@ public function test_recover_total_paid_migrated_takes_precedence_over_signup_fe $this->assertSame( 50.0, $result, 'A migrated subscription must recover via the recurring total, not the sign-up-fee branch.' ); } + + /** + * Stripe migration meta triggers the same recovery as Piano migration meta. + * + * The recovery path keys off the meta-driven helper, so both keys must + * behave identically. Without this test, dropping `_stripe_subscription_id` + * from the helper would not fail any assertion. + */ + public function test_recover_total_paid_recognizes_stripe_migration_meta() { + $subscription = new WC_Subscription( + [ + 'id' => 20, + 'status' => 'active', + 'meta' => [ '_stripe_subscription_id' => 'stripe-20' ], + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 50.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 50.0, $result, 'A Stripe-migrated subscription should recover the same way a Piano-migrated one does.' ); + } + + /** + * The `instanceof WC_Order_Item_Product` guard rejects any object that is + * not an order item -- not just `null`. Without this test, a regression + * that removed the instanceof check would only fail the null case. + */ + public function test_recover_total_paid_passes_through_for_non_order_item_object() { + $subscription = new WC_Subscription( + [ + 'id' => 21, + 'status' => 'active', + 'meta' => [ '_piano_subscription_id' => 'piano-21' ], + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, new stdClass() ); + + $this->assertSame( 0.0, $result, 'A wrong-typed object must be returned unchanged, just like null.' ); + } + + /** + * A paid-trial subscription with the opt-in enabled and an active free + * trial recovers to the sign-up fee the reader actually paid. + * + * This is the publisher use case the opt-in is designed for: stepped + * pricing as a sign-up fee plus a free trial. WCS sees `$0` paid (the + * sign-up fee is excluded from its accounting), but the reader did pay + * the fee, and a switch during the trial should be prorated against it. + */ + public function test_recover_total_paid_counts_signup_fee_during_active_trial() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 25.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 22, + 'status' => 'active', + 'times' => [ + 'trial_end' => time() + DAY_IN_SECONDS, + ], + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 25.0, $result, 'A paid-trial sub mid-trial with opt-in enabled should recover the paid sign-up fee.' ); + } + + /** + * When the sign-up-fee branch fires, the WCS call must include sign-up + * fees -- not the default `exclude_sign_up_fees` mode. A regression + * flipping that flag would silently break the recovery without changing + * the returned value. + */ + public function test_recover_total_paid_passes_include_sign_up_fees_argument() { + global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args; + $wcs_mock_total_paid_including_signup_fee = 25.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 23, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertNotNull( $wcs_mock_last_calculate_total_paid_args, 'WCS::calculate_total_paid_since_last_order() should have been called.' ); + $this->assertSame( 'include_sign_up_fees', $wcs_mock_last_calculate_total_paid_args['include_sign_up_fees'], 'Sign-up fees must be included; otherwise the recovery is a no-op.' ); + } + + /** + * Migrated subscriptions clamp days_in_old_cycle to one billing cycle. + * + * Without this clamp, WCS divides the recovered recurring total by the + * span from the original platform sign-up to the next renewal -- often + * many cycles -- which makes old_price_per_day artificially low and + * misclassifies a downgrade as an upgrade even after + * recover_total_paid_for_switch supplies one cycle's worth of value. + */ + public function test_bound_switch_proration_days_in_old_cycle_clamps_migrated_subscription() { + $subscription = new WC_Subscription( + [ + 'id' => 30, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + 'meta' => [ '_piano_subscription_id' => 'piano-30' ], + ] + ); + + // 730 days = ~2 years of accumulated span since the original platform sign-up. + $result = WooCommerce_Subscriptions::bound_switch_proration_days_in_old_cycle( 730, $subscription ); + + $this->assertSame( 30, $result, 'A migrated monthly sub must clamp to one cycle (30 days), not the full span since original sign-up.' ); + } + + /** + * If WCS already computed a value inside a single billing cycle (early + * switches, monthly subs newly migrated), respect that value instead of + * inflating it to one cycle's worth. + */ + public function test_bound_switch_proration_days_in_old_cycle_respects_smaller_value() { + $subscription = new WC_Subscription( + [ + 'id' => 31, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + 'meta' => [ '_piano_subscription_id' => 'piano-31' ], + ] + ); + + $result = WooCommerce_Subscriptions::bound_switch_proration_days_in_old_cycle( 12, $subscription ); + + $this->assertSame( 12, $result, 'The clamp is a ceiling, not a floor; a smaller WCS value must pass through.' ); + } + + /** + * Non-migrated subscriptions are left to WCS's default behavior even + * when WCS computes a denominator longer than one cycle. + */ + public function test_bound_switch_proration_days_in_old_cycle_skips_non_migrated_subscription() { + $subscription = new WC_Subscription( + [ + 'id' => 32, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + ] + ); + + $result = WooCommerce_Subscriptions::bound_switch_proration_days_in_old_cycle( 730, $subscription ); + + $this->assertSame( 730, $result, 'A non-migrated subscription must pass through unchanged.' ); + } + + /** + * Annual migrated subscriptions clamp to one annual cycle, not one month. + */ + public function test_bound_switch_proration_days_in_old_cycle_uses_billing_period_for_clamp() { + $subscription = new WC_Subscription( + [ + 'id' => 33, + 'status' => 'active', + 'billing_period' => 'year', + 'billing_interval' => 1, + 'meta' => [ '_stripe_subscription_id' => 'stripe-33' ], + ] + ); + + // 1500 days = ~4+ years of accumulated span. + $result = WooCommerce_Subscriptions::bound_switch_proration_days_in_old_cycle( 1500, $subscription ); + + $this->assertSame( 365, $result, 'An annual migrated sub must clamp to one year (365 days), not one month.' ); + } } From 2cd7405c8d02a8016ac54512bda3de81697a7324 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 25 May 2026 14:19:04 -0300 Subject: [PATCH 10/17] feat(wc-subscriptions): pass sub and item to signup-fee filter --- .../class-woocommerce-subscriptions.php | 17 ++++++-- .../class-woocommerce-subscriptions.php | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index e23445dab9..4d862dbb29 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -151,7 +151,7 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription // paid, so a switch during the trial sees $0. When the publisher has // opted in, count the sign-up fee the reader actually paid. A free // trial with no sign-up fee, or a comp, yields nothing and no-ops. - if ( self::should_count_signup_fee_on_switch() ) { + if ( self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { return max( self::get_total_paid_including_signup_fee( $subscription, $existing_item ), (float) $total_paid ); } @@ -208,18 +208,27 @@ public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cy * by returning true from the newspack_wc_subs_switch_include_signup_fee * filter for finer-grained control (e.g. per-subscription or per-product). * + * @param \WC_Subscription $subscription The subscription being switched. + * @param \WC_Order_Item_Product $existing_item The subscription line item being switched. + * * @return bool */ - private static function should_count_signup_fee_on_switch() { + private static function should_count_signup_fee_on_switch( $subscription, $existing_item ) { $enabled = defined( 'NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE' ) && NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE; /** * Filters whether a paid one-time sign-up fee is counted toward the * proration baseline when switching subscriptions. * - * @param bool $enabled Whether the sign-up fee is counted. + * The subscription and line item are provided so callbacks can scope + * the decision per-subscription or per-product (e.g. enable for a + * specific product variation only). + * + * @param bool $enabled Whether the sign-up fee is counted. + * @param \WC_Subscription $subscription The subscription being switched. + * @param \WC_Order_Item_Product $existing_item The subscription line item being switched. */ - return (bool) apply_filters( 'newspack_wc_subs_switch_include_signup_fee', $enabled ); + return (bool) apply_filters( 'newspack_wc_subs_switch_include_signup_fee', $enabled, $subscription, $existing_item ); } /** diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 7dd1b0e26f..68685b5423 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -510,6 +510,49 @@ public function test_recover_total_paid_counts_signup_fee_during_active_trial() $this->assertSame( 25.0, $result, 'A paid-trial sub mid-trial with opt-in enabled should recover the paid sign-up fee.' ); } + /** + * The `newspack_wc_subs_switch_include_signup_fee` filter must receive + * the subscription and line item alongside the enabled flag so callbacks + * can scope the decision per-subscription or per-product. A regression + * dropping those args would silently downgrade the filter to a global + * on/off toggle. + */ + public function test_signup_fee_filter_receives_subscription_and_item() { + $captured = []; + $subscription = new WC_Subscription( + [ + 'id' => 40, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + add_filter( + 'newspack_wc_subs_switch_include_signup_fee', + function ( $enabled, $sub, $item ) use ( &$captured ) { + $captured = [ + 'enabled' => $enabled, + 'subscription' => $sub, + 'item' => $item, + ]; + return $enabled; + }, + 10, + 3 + ); + + WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( $subscription, $captured['subscription'], 'Filter must receive the subscription so callbacks can scope per-subscription.' ); + $this->assertSame( $existing_item, $captured['item'], 'Filter must receive the line item so callbacks can scope per-product.' ); + $this->assertFalse( $captured['enabled'], 'Default enabled value must be false when neither constant nor opt-in filter sets it.' ); + } + /** * When the sign-up-fee branch fires, the WCS call must include sign-up * fees -- not the default `exclude_sign_up_fees` mode. A regression From 574896fa417d40817a8c4a5f6be52c4e6ffd0c4c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 25 May 2026 17:39:12 -0300 Subject: [PATCH 11/17] feat(wc-subscriptions): charge consumed credit on paid-trial switch --- .../class-woocommerce-subscriptions.php | 67 ++++++++ tests/mocks/wc-mocks.php | 4 + .../class-woocommerce-subscriptions.php | 157 +++++++++++++++++- 3 files changed, 226 insertions(+), 2 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 4d862dbb29..4687fc1029 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -24,6 +24,7 @@ public static function init() { add_filter( 'woocommerce_subscriptions_can_item_be_switched', [ __CLASS__, 'allow_migrated_subscription_switch' ], 10, 3 ); add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); add_filter( 'wcs_switch_proration_days_in_old_cycle', [ __CLASS__, 'bound_switch_proration_days_in_old_cycle' ], 10, 2 ); + add_filter( 'wcs_switch_proration_extra_to_pay', [ __CLASS__, 'clamp_negative_switch_proration_credit' ], 10, 3 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -198,6 +199,72 @@ public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cy return min( (int) $days_in_old_cycle, $cycle_days ); } + /** + * Replace WCS's manufactured trial credit with the consumed-value charge + * for paid-trial switches. + * + * When switching between two products that both use a sign-up fee + free + * trial (Newspack's stepped-pricing pattern), WCS sees matching trial + * periods and forces new_price_per_day to 0 -- which makes extra_to_pay + * negative by exactly the unconsumed portion of the original sign-up + * fee. WCS then nets that negative credit against the new plan's + * apportioned sign-up fee, charging the reader nothing for what is + * actually an upgrade to a more expensive plan. + * + * The model publishers actually want for stepped pricing: the unconsumed + * portion of what the reader paid for the old plan is credited toward + * the new plan's sign-up fee; the reader pays the remainder. Computed as + * consumed_value = total_paid_so_far - unconsumed_credit, which expressed + * in WCS terms is simply total_paid + (WCS's negative extra_to_pay). This + * sits on top of WCS's sign_up_fee_delta to produce the correct charge: + * sign_up_fee_delta + consumed_value = new_sign_up_fee - unconsumed_credit. + * + * Only fires when the publisher has opted in to counting sign-up fees in + * switch proration, the subscription is in an active trial, and WCS + * produced a negative extra_to_pay. Legitimate downgrade credits on + * non-trial switches are untouched. + * + * @param float $extra_to_pay The amount WCS computed as the upgrade cost. + * @param \WC_Subscription $subscription The subscription being switched. + * @param array $cart_item The cart item recording the switch. + * + * @return float The corrected extra_to_pay value. + */ + public static function clamp_negative_switch_proration_credit( $extra_to_pay, $subscription, $cart_item ) { + if ( (float) $extra_to_pay >= 0 ) { + return $extra_to_pay; + } + + if ( ! ( $subscription instanceof \WC_Subscription ) ) { + return $extra_to_pay; + } + + // Only intervene during an active trial -- the case where WCS's + // matching-trials path forces new_price_per_day to 0 and manufactures + // a negative extra_to_pay. Off-trial negative values are legitimate + // downgrade credits and must pass through unchanged. + if ( $subscription->get_time( 'trial_end' ) <= time() ) { + return $extra_to_pay; + } + + $existing_item = null; + if ( isset( $cart_item['subscription_switch']['item_id'] ) && function_exists( 'wcs_get_order_item' ) ) { + $existing_item = wcs_get_order_item( $cart_item['subscription_switch']['item_id'], $subscription ); + } + + if ( ! self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { + return $extra_to_pay; + } + + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { + return $extra_to_pay; + } + + $total_paid = self::get_total_paid_including_signup_fee( $subscription, $existing_item ); + + return max( $total_paid + (float) $extra_to_pay, 0.0 ); + } + /** * Whether a paid one-time sign-up fee should count toward the switch * proration baseline. diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index a68017203d..d93f531d35 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -529,6 +529,10 @@ function wcs_get_days_in_cycle( $period, $interval ) { ]; return ( $days_per_period[ $period ] ?? 0 ) * (int) $interval; } +function wcs_get_order_item( $item_id, $subscription ) { + global $wcs_mock_order_items; + return $wcs_mock_order_items[ $item_id ] ?? null; +} function wc_string_to_bool( $string ) { return is_bool( $string ) ? $string : ( 'yes' === strtolower( $string ) || '1' === $string || 'true' === strtolower( $string ) ); } diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 68685b5423..00d969f821 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -21,11 +21,12 @@ class Newspack_Test_WooCommerce_Subscriptions extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); - global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args; + global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items; $subscriptions_database = []; $products_database = []; $wcs_mock_total_paid_including_signup_fee = 0; $wcs_mock_last_calculate_total_paid_args = null; + $wcs_mock_order_items = []; } /** @@ -33,9 +34,10 @@ public function set_up() { * not leak across tests. */ public function tear_down() { - global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args; + global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items; $wcs_mock_total_paid_including_signup_fee = 0; $wcs_mock_last_calculate_total_paid_args = null; + $wcs_mock_order_items = []; remove_all_filters( 'newspack_wc_subs_switch_include_signup_fee' ); parent::tear_down(); } @@ -669,4 +671,155 @@ public function test_bound_switch_proration_days_in_old_cycle_uses_billing_perio $this->assertSame( 365, $result, 'An annual migrated sub must clamp to one year (365 days), not one month.' ); } + + /** + * Helper: stage a paid-trial switch cart context so the clamp filter has + * something to read for the existing item and the WCS total_paid call. + * + * @param float $total_paid Amount the reader paid for the old plan, returned by the WCS mock. + * @return array { subscription, existing_item, cart_item } tuple. + */ + private function stage_paid_trial_switch_context( $total_paid ) { + global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_order_items; + + $wcs_mock_total_paid_including_signup_fee = $total_paid; + + $existing_item = new WC_Order_Item_Product( + [ + 'id' => 999, + 'product_id' => 100, + 'total' => 5.0, + ] + ); + $wcs_mock_order_items[999] = $existing_item; + + $subscription = new WC_Subscription( + [ + 'id' => 50, + 'status' => 'active', + 'times' => [ + 'trial_end' => time() + ( 15 * DAY_IN_SECONDS ), + ], + ] + ); + + $cart_item = [ 'subscription_switch' => [ 'item_id' => 999 ] ]; + + return [ $subscription, $existing_item, $cart_item ]; + } + + /** + * An immediate switch (day 0 of the trial) has consumed nothing -- the + * full sign-up fee remains as credit. extra_to_pay must come back at 0 + * so the only charge is the sign-up fee delta WCS will apply on top. + */ + public function test_clamp_negative_switch_proration_credit_returns_zero_for_immediate_switch() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + // total_paid = $3 (Regular's sign-up fee); WCS computed extra_to_pay = -$3 (full unconsumed credit). + [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); + + $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, $subscription, $cart_item ); + + $this->assertSame( 0.0, $result, 'Day-0 switch must return extra_to_pay=0 so only the sign-up fee delta is charged.' ); + } + + /** + * A mid-trial switch has consumed part of the original sign-up fee -- + * that consumed portion must be charged on top of the sign-up fee delta. + * + * Example: $3 paid, day 15 of 30 -> unconsumed $1.50, WCS extra_to_pay = + * -$1.50, consumed_value = $3 + (-$1.50) = $1.50. + */ + public function test_clamp_negative_switch_proration_credit_charges_consumed_value_mid_trial() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); + + $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -1.5, $subscription, $cart_item ); + + $this->assertSame( 1.5, $result, 'Mid-trial switch must charge for the consumed portion of the original sign-up fee.' ); + } + + /** + * Without the opt-in, the manufactured negative credit is left alone -- + * publishers who have not opted in get WCS's default behavior. + */ + public function test_clamp_negative_switch_proration_credit_passes_through_without_optin() { + [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); + + $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, $subscription, $cart_item ); + + $this->assertSame( -3.0, $result, 'Without the opt-in, the negative credit must pass through unchanged.' ); + } + + /** + * A legitimate downgrade credit outside any trial is left alone -- our + * filter must not block normal proration refunds when the publisher + * downgrades a fully-paid subscription. + */ + public function test_clamp_negative_switch_proration_credit_passes_through_outside_trial() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 42, + 'status' => 'active', + ] + ); + + $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -5.0, $subscription, [] ); + + $this->assertSame( -5.0, $result, 'Negative credits on non-trial switches are legitimate downgrade refunds and must not be touched.' ); + } + + /** + * A positive extra_to_pay -- a real upgrade charge -- always passes + * through unchanged, regardless of opt-in or trial state. + */ + public function test_clamp_negative_switch_proration_credit_passes_through_positive_value() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); + + $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( 7.5, $subscription, $cart_item ); + + $this->assertSame( 7.5, $result, 'A positive extra_to_pay is a real upgrade charge and must be preserved.' ); + } + + /** + * The filter guards against non-WC_Subscription inputs so it cannot + * fatal if a third-party callback supplies an unexpected value. + */ + public function test_clamp_negative_switch_proration_credit_passes_through_non_subscription() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, null, [] ); + + $this->assertSame( -3.0, $result, 'A non-WC_Subscription argument must be returned unchanged.' ); + } + + /** + * If the existing item cannot be resolved from the cart context, the + * filter must pass through so we never fabricate a charge from incomplete + * data. Real-world this would happen if the switch metadata is malformed. + */ + public function test_clamp_negative_switch_proration_credit_passes_through_without_existing_item() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 43, + 'status' => 'active', + 'times' => [ + 'trial_end' => time() + ( 15 * DAY_IN_SECONDS ), + ], + ] + ); + + // cart_item missing 'subscription_switch'.item_id -> no existing_item lookup possible. + $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, $subscription, [] ); + + $this->assertSame( -3.0, $result, 'Without an existing_item the filter cannot compute consumed value and must pass through.' ); + } } From cabc15b8d500d1ded509a4fc7858b38449977ead Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 25 May 2026 19:53:38 -0300 Subject: [PATCH 12/17] feat(wc-subscriptions): force signup-fee delta on stepped-pricing switch --- .../class-woocommerce-subscriptions.php | 60 +++++++++ tests/mocks/wc-mocks.php | 29 +++++ .../class-woocommerce-subscriptions.php | 117 +++++++++++++++++- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 4687fc1029..7b0f4ab6b0 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -25,6 +25,7 @@ public static function init() { add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); add_filter( 'wcs_switch_proration_days_in_old_cycle', [ __CLASS__, 'bound_switch_proration_days_in_old_cycle' ], 10, 2 ); add_filter( 'wcs_switch_proration_extra_to_pay', [ __CLASS__, 'clamp_negative_switch_proration_credit' ], 10, 3 ); + add_filter( 'wcs_switch_sign_up_fee', [ __CLASS__, 'force_signup_fee_delta_on_paid_trial_switch' ], 10, 2 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -265,6 +266,65 @@ public static function clamp_negative_switch_proration_credit( $extra_to_pay, $s return max( $total_paid + (float) $extra_to_pay, 0.0 ); } + /** + * Force the apportioned sign-up fee delta on switches for publishers + * using sign-up fees to express stepped pricing. + * + * The opt-in here uses sign-up fees as a first-period discount rather + * than a real one-time fee, so we do not want publishers to also flip + * the store-wide WooCommerce setting "When switching, prorate the + * sign-up fee" -- that would affect every product on the site, not + * just the stepped-pricing ones. + * + * When the opt-in is active and WCS has not already computed a sign-up + * fee (e.g. because the store-wide setting is "no"), this filter returns + * the delta WCS would have computed if apportionment were enabled: + * max(sign_up_fee_due - sign_up_fee_paid, 0). Combined with the + * extra_to_pay clamp, this yields the correct switch charge regardless + * of the store-wide setting. + * + * @param float $value The sign-up fee amount WCS computed (0 when apportion is "no"). + * @param \WCS_Switch_Cart_Item $switch_item The switch context. + * + * @return float The sign-up fee to charge for the switch. + */ + public static function force_signup_fee_delta_on_paid_trial_switch( $value, $switch_item ) { + // If WCS already computed a non-zero value (store-wide apportion is + // "yes"), respect it and stay out of the way. + if ( (float) $value > 0 ) { + return $value; + } + + if ( ! is_object( $switch_item ) ) { + return $value; + } + + $subscription = $switch_item->subscription ?? null; + $existing_item = $switch_item->existing_item ?? null; + $new_product = $switch_item->product ?? null; + + if ( ! ( $subscription instanceof \WC_Subscription ) ) { + return $value; + } + + if ( ! self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { + return $value; + } + + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) || ! is_object( $new_product ) ) { + return $value; + } + + if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { + return $value; + } + + $sign_up_fee_due = (float) \WC_Subscriptions_Product::get_sign_up_fee( $new_product ); + $sign_up_fee_paid = (float) $subscription->get_items_sign_up_fee( $existing_item ); + + return max( $sign_up_fee_due - $sign_up_fee_paid, 0.0 ); + } + /** * Whether a paid one-time sign-up fee should count toward the switch * proration baseline. diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index d93f531d35..f5d4b4043d 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -123,8 +123,12 @@ public function save() {} class WC_Order_Item_Product { private $data = []; + private $meta = []; public function __construct( $data = [] ) { $this->data = $data; + if ( isset( $data['meta'] ) ) { + $this->meta = $data['meta']; + } } public function get_name() { return $this->data['name'] ?? ''; @@ -143,6 +147,9 @@ public function get_product() { $product_id = $this->data['product_id'] ?? 0; return $products_database[ $product_id ] ?? false; } + public function get_meta( $key, $single = true ) { + return $this->meta[ $key ] ?? ''; + } } class WC_Product { @@ -392,6 +399,16 @@ public function get_formatted_billing_full_name() { public function get_items() { return $this->data['items'] ?? []; } + public function get_items_sign_up_fee( $item, $tax = 'exclusive_of_tax' ) { + global $wcs_mock_items_sign_up_fee; + if ( is_object( $item ) && method_exists( $item, 'get_meta' ) ) { + $meta_value = $item->get_meta( '_subscription_sign_up_fee' ); + if ( $meta_value !== '' && $meta_value !== null ) { + return (float) $meta_value; + } + } + return (float) ( $wcs_mock_items_sign_up_fee ?? 0 ); + } public function save() { return true; } @@ -425,7 +442,19 @@ public static function calculate_total_paid_since_last_order( $subscription, $su } if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { + /** + * Mock of WC_Subscriptions_Product. + * + * The get_sign_up_fee() method reads the `_subscription_sign_up_fee` meta + * from the product so tests can stage variations with specific sign-up fees. + */ class WC_Subscriptions_Product { + public static function get_sign_up_fee( $product ) { + if ( ! is_object( $product ) || ! method_exists( $product, 'get_meta' ) ) { + return 0; + } + return (float) $product->get_meta( '_subscription_sign_up_fee' ); + } } } diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 00d969f821..d8882f46a8 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -21,12 +21,13 @@ class Newspack_Test_WooCommerce_Subscriptions extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); - global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items; + global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items, $wcs_mock_items_sign_up_fee; $subscriptions_database = []; $products_database = []; $wcs_mock_total_paid_including_signup_fee = 0; $wcs_mock_last_calculate_total_paid_args = null; $wcs_mock_order_items = []; + $wcs_mock_items_sign_up_fee = 0; } /** @@ -34,10 +35,11 @@ public function set_up() { * not leak across tests. */ public function tear_down() { - global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items; + global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items, $wcs_mock_items_sign_up_fee; $wcs_mock_total_paid_including_signup_fee = 0; $wcs_mock_last_calculate_total_paid_args = null; $wcs_mock_order_items = []; + $wcs_mock_items_sign_up_fee = 0; remove_all_filters( 'newspack_wc_subs_switch_include_signup_fee' ); parent::tear_down(); } @@ -822,4 +824,115 @@ public function test_clamp_negative_switch_proration_credit_passes_through_witho $this->assertSame( -3.0, $result, 'Without an existing_item the filter cannot compute consumed value and must pass through.' ); } + + /** + * Helper: build a switch_item stub with the subscription, existing item, + * and new product the force-delta filter reads. + * + * @param float $sign_up_fee_paid Sign-up fee already paid on the existing line item. + * @param float $sign_up_fee_due Sign-up fee on the new product variation. + * @return object Minimal stdClass with subscription, existing_item, product properties. + */ + private function stage_switch_item( $sign_up_fee_paid, $sign_up_fee_due ) { + $existing_item = new WC_Order_Item_Product( + [ + 'id' => 1000, + 'product_id' => 100, + 'total' => 5.0, + 'meta' => [ '_subscription_sign_up_fee' => (string) $sign_up_fee_paid ], + ] + ); + + $subscription = new WC_Subscription( + [ + 'id' => 60, + 'status' => 'active', + 'times' => [ + 'trial_end' => time() + ( 15 * DAY_IN_SECONDS ), + ], + ] + ); + + $new_product = wc_create_mock_product( + [ + 'id' => 200, + 'meta' => [ '_subscription_sign_up_fee' => (string) $sign_up_fee_due ], + ] + ); + + return (object) [ + 'subscription' => $subscription, + 'existing_item' => $existing_item, + 'product' => $new_product, + ]; + } + + /** + * When WC's store-wide apportion_sign_up_fee is "no", WCS hands us a + * value of 0. With the opt-in active, we should fill in the apportioned + * delta ourselves so publishers don't need to flip the store-wide + * setting (which would affect every product on the site). + */ + public function test_force_signup_fee_delta_returns_delta_when_optin_active() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item( 3.0, 6.0 ); + + $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, $switch_item ); + + $this->assertSame( 3.0, $result, 'With opt-in active and WCS suppressing the apportionment, the delta ($6-$3) must be forced.' ); + } + + /** + * When WCS already computed a positive value (store-wide apportion is + * "yes"), our filter must stay out of the way -- the publisher already + * gets the right behavior from WCS itself. + */ + public function test_force_signup_fee_delta_respects_existing_wcs_value() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item( 3.0, 6.0 ); + + $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 3.0, $switch_item ); + + $this->assertSame( 3.0, $result, 'A positive value from WCS must be passed through unchanged.' ); + } + + /** + * Without the opt-in, the filter is a no-op -- publishers who have not + * opted in keep WCS default behavior across the board. + */ + public function test_force_signup_fee_delta_passes_through_without_optin() { + $switch_item = $this->stage_switch_item( 3.0, 6.0 ); + + $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, $switch_item ); + + $this->assertSame( 0.0, $result, 'Without the opt-in, 0 must remain 0.' ); + } + + /** + * Downgrades (new fee < paid fee) produce a non-negative result -- we + * never refund or carry credit across switches. + */ + public function test_force_signup_fee_delta_clamps_negative_to_zero() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item( 6.0, 3.0 ); + + $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, $switch_item ); + + $this->assertSame( 0.0, $result, 'Downgrades must not produce a negative sign-up fee credit.' ); + } + + /** + * A non-object switch_item is returned unchanged so a malformed call + * cannot fatal the filter chain. + */ + public function test_force_signup_fee_delta_passes_through_invalid_switch_item() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, null ); + + $this->assertSame( 0.0, $result, 'A non-object switch_item must be returned unchanged.' ); + } } From 876fe8291f3adfd3b433e88838e41277ee363f30 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 25 May 2026 20:16:03 -0300 Subject: [PATCH 13/17] feat(wc-subscriptions): charge full new price on stepped-pricing switch --- .../class-woocommerce-subscriptions.php | 121 +++------- tests/mocks/wc-mocks.php | 6 + .../class-woocommerce-subscriptions.php | 210 ++++++------------ 3 files changed, 102 insertions(+), 235 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 7b0f4ab6b0..d8395c094c 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -24,8 +24,7 @@ public static function init() { add_filter( 'woocommerce_subscriptions_can_item_be_switched', [ __CLASS__, 'allow_migrated_subscription_switch' ], 10, 3 ); add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); add_filter( 'wcs_switch_proration_days_in_old_cycle', [ __CLASS__, 'bound_switch_proration_days_in_old_cycle' ], 10, 2 ); - add_filter( 'wcs_switch_proration_extra_to_pay', [ __CLASS__, 'clamp_negative_switch_proration_credit' ], 10, 3 ); - add_filter( 'wcs_switch_sign_up_fee', [ __CLASS__, 'force_signup_fee_delta_on_paid_trial_switch' ], 10, 2 ); + add_filter( 'wcs_switch_proration_extra_to_pay', [ __CLASS__, 'apply_stepped_pricing_switch_charge' ], 10, 3 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -201,29 +200,28 @@ public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cy } /** - * Replace WCS's manufactured trial credit with the consumed-value charge - * for paid-trial switches. - * - * When switching between two products that both use a sign-up fee + free - * trial (Newspack's stepped-pricing pattern), WCS sees matching trial - * periods and forces new_price_per_day to 0 -- which makes extra_to_pay - * negative by exactly the unconsumed portion of the original sign-up - * fee. WCS then nets that negative credit against the new plan's - * apportioned sign-up fee, charging the reader nothing for what is - * actually an upgrade to a more expensive plan. - * - * The model publishers actually want for stepped pricing: the unconsumed - * portion of what the reader paid for the old plan is credited toward - * the new plan's sign-up fee; the reader pays the remainder. Computed as - * consumed_value = total_paid_so_far - unconsumed_credit, which expressed - * in WCS terms is simply total_paid + (WCS's negative extra_to_pay). This - * sits on top of WCS's sign_up_fee_delta to produce the correct charge: - * sign_up_fee_delta + consumed_value = new_sign_up_fee - unconsumed_credit. - * - * Only fires when the publisher has opted in to counting sign-up fees in - * switch proration, the subscription is in an active trial, and WCS - * produced a negative extra_to_pay. Legitimate downgrade credits on - * non-trial switches are untouched. + * Apply the stepped-pricing switch charge: full new recurring price for + * the first cycle, minus the unconsumed portion of what the reader paid + * for the old plan. + * + * For publishers using a sign-up fee + free trial as a first-period + * discount (Newspack's stepped-pricing pattern), switching ends the + * discount on both sides: the old plan's discount stops accruing value, + * and the new plan is charged at its regular recurring price (not its + * own first-period discount). The unconsumed portion of what the reader + * paid for the old plan is credited toward the new plan's first cycle. + * + * In WCS terms, the matching-trials path produces an extra_to_pay equal + * to -unconsumed_credit (because new_price_per_day is forced to 0). We + * replace it with new_recurring + extra_to_pay, which simplifies to + * new_recurring - unconsumed_credit. WCS's sign_up_fee_delta is left at 0 + * because the new plan's "sign-up fee" is part of the discount being + * ended, not a real one-time fee. + * + * Only fires when the publisher has opted in, the subscription is in an + * active trial, and WCS produced a negative extra_to_pay (the matching- + * trials marker). Legitimate downgrade credits on non-trial switches are + * untouched. * * @param float $extra_to_pay The amount WCS computed as the upgrade cost. * @param \WC_Subscription $subscription The subscription being switched. @@ -231,7 +229,7 @@ public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cy * * @return float The corrected extra_to_pay value. */ - public static function clamp_negative_switch_proration_credit( $extra_to_pay, $subscription, $cart_item ) { + public static function apply_stepped_pricing_switch_charge( $extra_to_pay, $subscription, $cart_item ) { if ( (float) $extra_to_pay >= 0 ) { return $extra_to_pay; } @@ -257,72 +255,21 @@ public static function clamp_negative_switch_proration_credit( $extra_to_pay, $s return $extra_to_pay; } - if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { + // Read the new product's full-cycle recurring price. + $new_product = $cart_item['data'] ?? null; + if ( ! is_object( $new_product ) || ! class_exists( 'WC_Subscriptions_Product' ) ) { return $extra_to_pay; } - $total_paid = self::get_total_paid_including_signup_fee( $subscription, $existing_item ); - - return max( $total_paid + (float) $extra_to_pay, 0.0 ); - } - - /** - * Force the apportioned sign-up fee delta on switches for publishers - * using sign-up fees to express stepped pricing. - * - * The opt-in here uses sign-up fees as a first-period discount rather - * than a real one-time fee, so we do not want publishers to also flip - * the store-wide WooCommerce setting "When switching, prorate the - * sign-up fee" -- that would affect every product on the site, not - * just the stepped-pricing ones. - * - * When the opt-in is active and WCS has not already computed a sign-up - * fee (e.g. because the store-wide setting is "no"), this filter returns - * the delta WCS would have computed if apportionment were enabled: - * max(sign_up_fee_due - sign_up_fee_paid, 0). Combined with the - * extra_to_pay clamp, this yields the correct switch charge regardless - * of the store-wide setting. - * - * @param float $value The sign-up fee amount WCS computed (0 when apportion is "no"). - * @param \WCS_Switch_Cart_Item $switch_item The switch context. - * - * @return float The sign-up fee to charge for the switch. - */ - public static function force_signup_fee_delta_on_paid_trial_switch( $value, $switch_item ) { - // If WCS already computed a non-zero value (store-wide apportion is - // "yes"), respect it and stay out of the way. - if ( (float) $value > 0 ) { - return $value; - } - - if ( ! is_object( $switch_item ) ) { - return $value; - } - - $subscription = $switch_item->subscription ?? null; - $existing_item = $switch_item->existing_item ?? null; - $new_product = $switch_item->product ?? null; - - if ( ! ( $subscription instanceof \WC_Subscription ) ) { - return $value; - } - - if ( ! self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { - return $value; - } - - if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) || ! is_object( $new_product ) ) { - return $value; - } - - if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { - return $value; + $new_recurring = (float) \WC_Subscriptions_Product::get_price( $new_product ); + if ( $new_recurring <= 0 ) { + return $extra_to_pay; } - $sign_up_fee_due = (float) \WC_Subscriptions_Product::get_sign_up_fee( $new_product ); - $sign_up_fee_paid = (float) $subscription->get_items_sign_up_fee( $existing_item ); - - return max( $sign_up_fee_due - $sign_up_fee_paid, 0.0 ); + // extra_to_pay arriving here equals -unconsumed_credit (because + // new_price_per_day was zeroed by the matching-trials path), so + // new_recurring + extra_to_pay = new_recurring - unconsumed_credit. + return max( $new_recurring + (float) $extra_to_pay, 0.0 ); } /** diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index f5d4b4043d..74f1de02bb 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -455,6 +455,12 @@ public static function get_sign_up_fee( $product ) { } return (float) $product->get_meta( '_subscription_sign_up_fee' ); } + public static function get_price( $product ) { + if ( ! is_object( $product ) || ! method_exists( $product, 'get_meta' ) ) { + return 0; + } + return (float) $product->get_meta( '_subscription_price' ); + } } } diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index d8882f46a8..405f49e5a5 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -675,13 +675,14 @@ public function test_bound_switch_proration_days_in_old_cycle_uses_billing_perio } /** - * Helper: stage a paid-trial switch cart context so the clamp filter has - * something to read for the existing item and the WCS total_paid call. + * Helper: stage a paid-trial switch cart context with a new product + * priced at $new_recurring for the upgrade target. * - * @param float $total_paid Amount the reader paid for the old plan, returned by the WCS mock. + * @param float $total_paid Amount the reader paid for the old plan. + * @param float $new_recurring Full-cycle recurring price of the new (upgrade) plan. * @return array { subscription, existing_item, cart_item } tuple. */ - private function stage_paid_trial_switch_context( $total_paid ) { + private function stage_paid_trial_switch_context( $total_paid, $new_recurring = 10.0 ) { global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_order_items; $wcs_mock_total_paid_including_signup_fee = $total_paid; @@ -705,52 +706,76 @@ private function stage_paid_trial_switch_context( $total_paid ) { ] ); - $cart_item = [ 'subscription_switch' => [ 'item_id' => 999 ] ]; + $new_product = wc_create_mock_product( + [ + 'id' => 200, + 'meta' => [ '_subscription_price' => (string) $new_recurring ], + ] + ); + + $cart_item = [ + 'subscription_switch' => [ 'item_id' => 999 ], + 'data' => $new_product, + ]; return [ $subscription, $existing_item, $cart_item ]; } /** - * An immediate switch (day 0 of the trial) has consumed nothing -- the - * full sign-up fee remains as credit. extra_to_pay must come back at 0 - * so the only charge is the sign-up fee delta WCS will apply on top. + * Stepped-pricing immediate switch: nothing consumed, full unconsumed + * credit applied. For Regular ($3 paid) -> Pro ($10/mo), the reader + * pays new_recurring - unconsumed = $10 - $3 = $7. */ - public function test_clamp_negative_switch_proration_credit_returns_zero_for_immediate_switch() { + public function test_apply_stepped_pricing_switch_charge_returns_new_recurring_minus_unconsumed_at_day_0() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - // total_paid = $3 (Regular's sign-up fee); WCS computed extra_to_pay = -$3 (full unconsumed credit). + // total_paid = $3, new_recurring = $10. WCS computed extra_to_pay = -$3 (full unconsumed credit). [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); - $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, $subscription, $cart_item ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, $cart_item ); - $this->assertSame( 0.0, $result, 'Day-0 switch must return extra_to_pay=0 so only the sign-up fee delta is charged.' ); + $this->assertSame( 7.0, $result, 'Day-0 switch charges new_recurring ($10) minus full unconsumed credit ($3).' ); } /** - * A mid-trial switch has consumed part of the original sign-up fee -- - * that consumed portion must be charged on top of the sign-up fee delta. - * - * Example: $3 paid, day 15 of 30 -> unconsumed $1.50, WCS extra_to_pay = - * -$1.50, consumed_value = $3 + (-$1.50) = $1.50. + * Stepped-pricing mid-trial switch: half consumed, half credited. For + * Regular ($3 paid) -> Pro ($10/mo) at day 15 of 30, WCS reports + * extra_to_pay = -$1.50; charge = $10 - $1.50 = $8.50. */ - public function test_clamp_negative_switch_proration_credit_charges_consumed_value_mid_trial() { + public function test_apply_stepped_pricing_switch_charge_at_day_15() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); - $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -1.5, $subscription, $cart_item ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -1.5, $subscription, $cart_item ); - $this->assertSame( 1.5, $result, 'Mid-trial switch must charge for the consumed portion of the original sign-up fee.' ); + $this->assertSame( 8.5, $result, 'Day-15 switch charges new_recurring ($10) minus half-unconsumed ($1.50).' ); + } + + /** + * If the new plan is cheaper (Pro -> Regular), the result is still + * clamped at 0 -- we never refund or carry credit across switches. + */ + public function test_apply_stepped_pricing_switch_charge_clamps_negative_to_zero() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + // new_recurring = $2 (cheap), unconsumed_credit = $3 (-$3 extra_to_pay). + // $2 - $3 = -$1 -> clamped to 0. + [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0, 2.0 ); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, $cart_item ); + + $this->assertSame( 0.0, $result, 'A downgrade whose unconsumed credit exceeds the new recurring must clamp to 0.' ); } /** * Without the opt-in, the manufactured negative credit is left alone -- * publishers who have not opted in get WCS's default behavior. */ - public function test_clamp_negative_switch_proration_credit_passes_through_without_optin() { + public function test_apply_stepped_pricing_switch_charge_passes_through_without_optin() { [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); - $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, $subscription, $cart_item ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, $cart_item ); $this->assertSame( -3.0, $result, 'Without the opt-in, the negative credit must pass through unchanged.' ); } @@ -760,7 +785,7 @@ public function test_clamp_negative_switch_proration_credit_passes_through_witho * filter must not block normal proration refunds when the publisher * downgrades a fully-paid subscription. */ - public function test_clamp_negative_switch_proration_credit_passes_through_outside_trial() { + public function test_apply_stepped_pricing_switch_charge_passes_through_outside_trial() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); $subscription = new WC_Subscription( @@ -770,21 +795,21 @@ public function test_clamp_negative_switch_proration_credit_passes_through_outsi ] ); - $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -5.0, $subscription, [] ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -5.0, $subscription, [] ); $this->assertSame( -5.0, $result, 'Negative credits on non-trial switches are legitimate downgrade refunds and must not be touched.' ); } /** - * A positive extra_to_pay -- a real upgrade charge -- always passes - * through unchanged, regardless of opt-in or trial state. + * A positive extra_to_pay -- a real upgrade charge from WCS -- always + * passes through unchanged, regardless of opt-in or trial state. */ - public function test_clamp_negative_switch_proration_credit_passes_through_positive_value() { + public function test_apply_stepped_pricing_switch_charge_passes_through_positive_value() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); - $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( 7.5, $subscription, $cart_item ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 7.5, $subscription, $cart_item ); $this->assertSame( 7.5, $result, 'A positive extra_to_pay is a real upgrade charge and must be preserved.' ); } @@ -793,20 +818,20 @@ public function test_clamp_negative_switch_proration_credit_passes_through_posit * The filter guards against non-WC_Subscription inputs so it cannot * fatal if a third-party callback supplies an unexpected value. */ - public function test_clamp_negative_switch_proration_credit_passes_through_non_subscription() { + public function test_apply_stepped_pricing_switch_charge_passes_through_non_subscription() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, null, [] ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, null, [] ); $this->assertSame( -3.0, $result, 'A non-WC_Subscription argument must be returned unchanged.' ); } /** - * If the existing item cannot be resolved from the cart context, the - * filter must pass through so we never fabricate a charge from incomplete - * data. Real-world this would happen if the switch metadata is malformed. + * If the new product is missing from the cart_item (malformed switch + * metadata), the filter passes through so we never fabricate a charge + * without knowing the upgrade target's recurring price. */ - public function test_clamp_negative_switch_proration_credit_passes_through_without_existing_item() { + public function test_apply_stepped_pricing_switch_charge_passes_through_without_new_product() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); $subscription = new WC_Subscription( @@ -819,120 +844,9 @@ public function test_clamp_negative_switch_proration_credit_passes_through_witho ] ); - // cart_item missing 'subscription_switch'.item_id -> no existing_item lookup possible. - $result = WooCommerce_Subscriptions::clamp_negative_switch_proration_credit( -3.0, $subscription, [] ); - - $this->assertSame( -3.0, $result, 'Without an existing_item the filter cannot compute consumed value and must pass through.' ); - } - - /** - * Helper: build a switch_item stub with the subscription, existing item, - * and new product the force-delta filter reads. - * - * @param float $sign_up_fee_paid Sign-up fee already paid on the existing line item. - * @param float $sign_up_fee_due Sign-up fee on the new product variation. - * @return object Minimal stdClass with subscription, existing_item, product properties. - */ - private function stage_switch_item( $sign_up_fee_paid, $sign_up_fee_due ) { - $existing_item = new WC_Order_Item_Product( - [ - 'id' => 1000, - 'product_id' => 100, - 'total' => 5.0, - 'meta' => [ '_subscription_sign_up_fee' => (string) $sign_up_fee_paid ], - ] - ); - - $subscription = new WC_Subscription( - [ - 'id' => 60, - 'status' => 'active', - 'times' => [ - 'trial_end' => time() + ( 15 * DAY_IN_SECONDS ), - ], - ] - ); - - $new_product = wc_create_mock_product( - [ - 'id' => 200, - 'meta' => [ '_subscription_sign_up_fee' => (string) $sign_up_fee_due ], - ] - ); - - return (object) [ - 'subscription' => $subscription, - 'existing_item' => $existing_item, - 'product' => $new_product, - ]; - } - - /** - * When WC's store-wide apportion_sign_up_fee is "no", WCS hands us a - * value of 0. With the opt-in active, we should fill in the apportioned - * delta ourselves so publishers don't need to flip the store-wide - * setting (which would affect every product on the site). - */ - public function test_force_signup_fee_delta_returns_delta_when_optin_active() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - $switch_item = $this->stage_switch_item( 3.0, 6.0 ); - - $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, $switch_item ); - - $this->assertSame( 3.0, $result, 'With opt-in active and WCS suppressing the apportionment, the delta ($6-$3) must be forced.' ); - } - - /** - * When WCS already computed a positive value (store-wide apportion is - * "yes"), our filter must stay out of the way -- the publisher already - * gets the right behavior from WCS itself. - */ - public function test_force_signup_fee_delta_respects_existing_wcs_value() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - $switch_item = $this->stage_switch_item( 3.0, 6.0 ); - - $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 3.0, $switch_item ); - - $this->assertSame( 3.0, $result, 'A positive value from WCS must be passed through unchanged.' ); - } - - /** - * Without the opt-in, the filter is a no-op -- publishers who have not - * opted in keep WCS default behavior across the board. - */ - public function test_force_signup_fee_delta_passes_through_without_optin() { - $switch_item = $this->stage_switch_item( 3.0, 6.0 ); - - $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, $switch_item ); - - $this->assertSame( 0.0, $result, 'Without the opt-in, 0 must remain 0.' ); - } - - /** - * Downgrades (new fee < paid fee) produce a non-negative result -- we - * never refund or carry credit across switches. - */ - public function test_force_signup_fee_delta_clamps_negative_to_zero() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - $switch_item = $this->stage_switch_item( 6.0, 3.0 ); - - $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, $switch_item ); - - $this->assertSame( 0.0, $result, 'Downgrades must not produce a negative sign-up fee credit.' ); - } - - /** - * A non-object switch_item is returned unchanged so a malformed call - * cannot fatal the filter chain. - */ - public function test_force_signup_fee_delta_passes_through_invalid_switch_item() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - $result = WooCommerce_Subscriptions::force_signup_fee_delta_on_paid_trial_switch( 0.0, null ); + // cart_item missing 'data' (new product) -> no recurring price lookup possible. + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, [] ); - $this->assertSame( 0.0, $result, 'A non-object switch_item must be returned unchanged.' ); + $this->assertSame( -3.0, $result, 'Without the new product we cannot compute the charge and must pass through.' ); } } From c81bf0c104f52b4809b171f58fd99bd88e878c7b Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 25 May 2026 21:30:11 -0300 Subject: [PATCH 14/17] fix(wc-subscriptions): hook signup-fee instead of extra_to_pay for switch --- .../class-woocommerce-subscriptions.php | 107 +++++++---- tests/mocks/wc-mocks.php | 26 +++ .../class-woocommerce-subscriptions.php | 173 +++++++++--------- 3 files changed, 180 insertions(+), 126 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index d8395c094c..99ae499fc6 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -24,7 +24,7 @@ public static function init() { add_filter( 'woocommerce_subscriptions_can_item_be_switched', [ __CLASS__, 'allow_migrated_subscription_switch' ], 10, 3 ); add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); add_filter( 'wcs_switch_proration_days_in_old_cycle', [ __CLASS__, 'bound_switch_proration_days_in_old_cycle' ], 10, 2 ); - add_filter( 'wcs_switch_proration_extra_to_pay', [ __CLASS__, 'apply_stepped_pricing_switch_charge' ], 10, 3 ); + add_filter( 'wcs_switch_sign_up_fee', [ __CLASS__, 'apply_stepped_pricing_switch_charge' ], 10, 2 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -202,7 +202,7 @@ public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cy /** * Apply the stepped-pricing switch charge: full new recurring price for * the first cycle, minus the unconsumed portion of what the reader paid - * for the old plan. + * for the old plan, exposed to WCS as the apportioned sign-up fee. * * For publishers using a sign-up fee + free trial as a first-period * discount (Newspack's stepped-pricing pattern), switching ends the @@ -211,65 +211,96 @@ public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cy * own first-period discount). The unconsumed portion of what the reader * paid for the old plan is credited toward the new plan's first cycle. * - * In WCS terms, the matching-trials path produces an extra_to_pay equal - * to -unconsumed_credit (because new_price_per_day is forced to 0). We - * replace it with new_recurring + extra_to_pay, which simplifies to - * new_recurring - unconsumed_credit. WCS's sign_up_fee_delta is left at 0 - * because the new plan's "sign-up fee" is part of the discount being - * ended, not a real one-time fee. + * We hook wcs_switch_sign_up_fee (not extra_to_pay) because WCS + * classifies a matching-trials switch as a downgrade -- it forces + * new_price_per_day to 0, then sees old_pp > new_pp and routes through + * extend_prepaid_term, which never calls calculate_upgrade_cost or + * applies wcs_switch_proration_extra_to_pay. wcs_switch_sign_up_fee, by + * contrast, fires in apportion_sign_up_fees before the switch-type + * branching, so it is the only WCS hook reachable for both + * upgrade-as-downgrade and ordinary downgrade paths. + * + * Setting the sign-up fee to (new_recurring - unconsumed_credit) makes + * the cart show the right one-time charge, while WCS continues to set + * up the new plan's recurring schedule from the inherited trial_end. * * Only fires when the publisher has opted in, the subscription is in an - * active trial, and WCS produced a negative extra_to_pay (the matching- - * trials marker). Legitimate downgrade credits on non-trial switches are - * untouched. + * active trial, and the existing line item actually carries a paid + * sign-up fee (the stepped-pricing signature). Real free trials, comps, + * and out-of-trial switches all pass through unchanged. * - * @param float $extra_to_pay The amount WCS computed as the upgrade cost. - * @param \WC_Subscription $subscription The subscription being switched. - * @param array $cart_item The cart item recording the switch. + * @param float $value The sign-up fee WCS computed (delta when apportion=yes, 0 when apportion=no). + * @param \WCS_Switch_Cart_Item $switch_item The WCS switch context. * - * @return float The corrected extra_to_pay value. + * @return float The sign-up fee to charge for the switch. */ - public static function apply_stepped_pricing_switch_charge( $extra_to_pay, $subscription, $cart_item ) { - if ( (float) $extra_to_pay >= 0 ) { - return $extra_to_pay; + public static function apply_stepped_pricing_switch_charge( $value, $switch_item ) { + if ( ! is_object( $switch_item ) ) { + return $value; } + $subscription = $switch_item->subscription ?? null; + $existing_item = $switch_item->existing_item ?? null; + $new_product = $switch_item->product ?? null; + if ( ! ( $subscription instanceof \WC_Subscription ) ) { - return $extra_to_pay; + return $value; } - // Only intervene during an active trial -- the case where WCS's - // matching-trials path forces new_price_per_day to 0 and manufactures - // a negative extra_to_pay. Off-trial negative values are legitimate - // downgrade credits and must pass through unchanged. + // Only intervene during an active trial -- the only time the + // stepped-pricing pattern produces the wrong charge. if ( $subscription->get_time( 'trial_end' ) <= time() ) { - return $extra_to_pay; + return $value; } - $existing_item = null; - if ( isset( $cart_item['subscription_switch']['item_id'] ) && function_exists( 'wcs_get_order_item' ) ) { - $existing_item = wcs_get_order_item( $cart_item['subscription_switch']['item_id'], $subscription ); + if ( ! self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { + return $value; } - if ( ! self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { - return $extra_to_pay; + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) || ! is_object( $new_product ) ) { + return $value; } - // Read the new product's full-cycle recurring price. - $new_product = $cart_item['data'] ?? null; - if ( ! is_object( $new_product ) || ! class_exists( 'WC_Subscriptions_Product' ) ) { - return $extra_to_pay; + // The stepped-pricing signature: the old line item actually carries + // a paid sign-up fee. A real free trial (sign-up fee = 0) is left + // alone so we never invent a charge for a reader who paid nothing. + $paid_sign_up_fee = (float) $subscription->get_items_sign_up_fee( $existing_item ); + if ( $paid_sign_up_fee <= 0 ) { + return $value; + } + + if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { + return $value; } $new_recurring = (float) \WC_Subscriptions_Product::get_price( $new_product ); if ( $new_recurring <= 0 ) { - return $extra_to_pay; + return $value; + } + + // Compute the unconsumed credit from the switch_item's own helpers + // rather than recomputing here -- WCS exposes them publicly and they + // stay in sync with whatever total_paid/cycle adjustments our other + // filters apply. + if ( + ! method_exists( $switch_item, 'get_total_paid_for_current_period' ) + || ! method_exists( $switch_item, 'get_days_in_old_cycle' ) + || ! method_exists( $switch_item, 'get_days_until_next_payment' ) + ) { + return $value; } - // extra_to_pay arriving here equals -unconsumed_credit (because - // new_price_per_day was zeroed by the matching-trials path), so - // new_recurring + extra_to_pay = new_recurring - unconsumed_credit. - return max( $new_recurring + (float) $extra_to_pay, 0.0 ); + $total_paid = (float) $switch_item->get_total_paid_for_current_period(); + $days_in_old_cycle = (int) $switch_item->get_days_in_old_cycle(); + $days_until_next = (int) $switch_item->get_days_until_next_payment(); + + if ( $days_in_old_cycle <= 0 ) { + return $value; + } + + $unconsumed_credit = $total_paid * ( $days_until_next / $days_in_old_cycle ); + + return max( $new_recurring - $unconsumed_credit, 0.0 ); } /** diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index 74f1de02bb..943e7a3f31 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -464,6 +464,32 @@ public static function get_price( $product ) { } } +/** + * Test double for WCS_Switch_Cart_Item exposing only the surface that the + * stepped-pricing sign-up fee filter reads from. + */ +class Mock_WCS_Switch_Cart_Item_For_Stepped_Pricing { + public $subscription; + public $existing_item; + public $product; + private $values; + public function __construct( $sub, $item, $product, $values ) { + $this->subscription = $sub; + $this->existing_item = $item; + $this->product = $product; + $this->values = $values; + } + public function get_total_paid_for_current_period() { + return (float) $this->values['total_paid']; + } + public function get_days_in_old_cycle() { + return (int) $this->values['days_in_old_cycle']; + } + public function get_days_until_next_payment() { + return (int) $this->values['days_until_next']; + } +} + function wc_create_order( $data ) { return new WC_Order( $data ); } diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 405f49e5a5..f6e7e779db 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -675,33 +675,43 @@ public function test_bound_switch_proration_days_in_old_cycle_uses_billing_perio } /** - * Helper: stage a paid-trial switch cart context with a new product - * priced at $new_recurring for the upgrade target. + * Helper: stage a fake WCS_Switch_Cart_Item with the subscription, + * existing line item, new product, and the three numeric getters our + * filter reads (total_paid, days_in_old_cycle, days_until_next_payment). * - * @param float $total_paid Amount the reader paid for the old plan. - * @param float $new_recurring Full-cycle recurring price of the new (upgrade) plan. - * @return array { subscription, existing_item, cart_item } tuple. + * @param array $args Test parameters: paid_sign_up_fee, total_paid, + * new_recurring, days_in_old_cycle, days_until_next, + * trial_active (bool). + * @return object Minimal switch_item stub. */ - private function stage_paid_trial_switch_context( $total_paid, $new_recurring = 10.0 ) { - global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_order_items; - - $wcs_mock_total_paid_including_signup_fee = $total_paid; + private function stage_switch_item( array $args = [] ) { + $args = wp_parse_args( + $args, + [ + 'paid_sign_up_fee' => 3.0, + 'total_paid' => 3.0, + 'new_recurring' => 10.0, + 'days_in_old_cycle' => 30, + 'days_until_next' => 30, + 'trial_active' => true, + ] + ); $existing_item = new WC_Order_Item_Product( [ 'id' => 999, 'product_id' => 100, 'total' => 5.0, + 'meta' => [ '_subscription_sign_up_fee' => (string) $args['paid_sign_up_fee'] ], ] ); - $wcs_mock_order_items[999] = $existing_item; $subscription = new WC_Subscription( [ 'id' => 50, 'status' => 'active', 'times' => [ - 'trial_end' => time() + ( 15 * DAY_IN_SECONDS ), + 'trial_end' => $args['trial_active'] ? time() + ( 15 * DAY_IN_SECONDS ) : 0, ], ] ); @@ -709,144 +719,131 @@ private function stage_paid_trial_switch_context( $total_paid, $new_recurring = $new_product = wc_create_mock_product( [ 'id' => 200, - 'meta' => [ '_subscription_price' => (string) $new_recurring ], + 'meta' => [ '_subscription_price' => (string) $args['new_recurring'] ], ] ); - $cart_item = [ - 'subscription_switch' => [ 'item_id' => 999 ], - 'data' => $new_product, - ]; - - return [ $subscription, $existing_item, $cart_item ]; + return new Mock_WCS_Switch_Cart_Item_For_Stepped_Pricing( + $subscription, + $existing_item, + $new_product, + [ + 'total_paid' => $args['total_paid'], + 'days_in_old_cycle' => $args['days_in_old_cycle'], + 'days_until_next' => $args['days_until_next'], + ] + ); } /** * Stepped-pricing immediate switch: nothing consumed, full unconsumed - * credit applied. For Regular ($3 paid) -> Pro ($10/mo), the reader - * pays new_recurring - unconsumed = $10 - $3 = $7. + * credit applied. For Regular ($3 paid) -> Pro ($10/mo) at day 0, + * unconsumed = $3, charge = $10 - $3 = $7. */ - public function test_apply_stepped_pricing_switch_charge_returns_new_recurring_minus_unconsumed_at_day_0() { + public function test_apply_stepped_pricing_switch_charge_returns_seven_at_day_0() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - // total_paid = $3, new_recurring = $10. WCS computed extra_to_pay = -$3 (full unconsumed credit). - [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); + $switch_item = $this->stage_switch_item( [ 'days_until_next' => 30 ] ); - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, $cart_item ); + // WCS computed sign_up_fee_delta = $3 (apportion=yes); our filter overrides. + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); $this->assertSame( 7.0, $result, 'Day-0 switch charges new_recurring ($10) minus full unconsumed credit ($3).' ); } /** - * Stepped-pricing mid-trial switch: half consumed, half credited. For - * Regular ($3 paid) -> Pro ($10/mo) at day 15 of 30, WCS reports - * extra_to_pay = -$1.50; charge = $10 - $1.50 = $8.50. + * Mid-trial switch: half consumed, half credited. For Regular ($3 paid) + * -> Pro ($10/mo) at day 15 of 30, unconsumed = $1.50, charge = $8.50. */ - public function test_apply_stepped_pricing_switch_charge_at_day_15() { + public function test_apply_stepped_pricing_switch_charge_returns_eight_fifty_at_day_15() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); + $switch_item = $this->stage_switch_item( [ 'days_until_next' => 15 ] ); - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -1.5, $subscription, $cart_item ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); $this->assertSame( 8.5, $result, 'Day-15 switch charges new_recurring ($10) minus half-unconsumed ($1.50).' ); } /** - * If the new plan is cheaper (Pro -> Regular), the result is still - * clamped at 0 -- we never refund or carry credit across switches. + * Downgrade: when the unconsumed credit exceeds the new recurring price + * (Pro -> Regular mid-trial), the charge clamps to 0 -- we do not + * refund or carry credit across switches. */ public function test_apply_stepped_pricing_switch_charge_clamps_negative_to_zero() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - // new_recurring = $2 (cheap), unconsumed_credit = $3 (-$3 extra_to_pay). - // $2 - $3 = -$1 -> clamped to 0. - [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0, 2.0 ); + // New plan is $2/mo, but reader is owed $3 of credit. $2 - $3 = -$1 -> clamped. + $switch_item = $this->stage_switch_item( + [ + 'paid_sign_up_fee' => 6.0, + 'total_paid' => 6.0, + 'new_recurring' => 2.0, + 'days_until_next' => 30, + ] + ); - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, $cart_item ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 0.0, $switch_item ); - $this->assertSame( 0.0, $result, 'A downgrade whose unconsumed credit exceeds the new recurring must clamp to 0.' ); + $this->assertSame( 0.0, $result, 'When unconsumed credit exceeds the new recurring, charge clamps to 0.' ); } /** - * Without the opt-in, the manufactured negative credit is left alone -- - * publishers who have not opted in get WCS's default behavior. + * Without the opt-in, the filter is a no-op -- publishers who have not + * opted in keep WCS default behavior. */ public function test_apply_stepped_pricing_switch_charge_passes_through_without_optin() { - [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); + $switch_item = $this->stage_switch_item(); - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, $cart_item ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); - $this->assertSame( -3.0, $result, 'Without the opt-in, the negative credit must pass through unchanged.' ); + $this->assertSame( 3.0, $result, 'Without the opt-in, WCS-computed value must pass through unchanged.' ); } /** - * A legitimate downgrade credit outside any trial is left alone -- our - * filter must not block normal proration refunds when the publisher - * downgrades a fully-paid subscription. + * Out-of-trial switches are left to WCS's default behavior -- the + * stepped-pricing override is only meaningful during the discount + * period. */ public function test_apply_stepped_pricing_switch_charge_passes_through_outside_trial() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - $subscription = new WC_Subscription( - [ - 'id' => 42, - 'status' => 'active', - ] - ); + $switch_item = $this->stage_switch_item( [ 'trial_active' => false ] ); - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -5.0, $subscription, [] ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); - $this->assertSame( -5.0, $result, 'Negative credits on non-trial switches are legitimate downgrade refunds and must not be touched.' ); + $this->assertSame( 3.0, $result, 'Out-of-trial switches must not be re-priced.' ); } /** - * A positive extra_to_pay -- a real upgrade charge from WCS -- always - * passes through unchanged, regardless of opt-in or trial state. + * If the existing line item has no paid sign-up fee (genuine free + * trial, comp, etc.), the stepped-pricing pattern does not apply and + * the filter must pass through. */ - public function test_apply_stepped_pricing_switch_charge_passes_through_positive_value() { + public function test_apply_stepped_pricing_switch_charge_passes_through_when_no_paid_signup_fee() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - [ $subscription, , $cart_item ] = $this->stage_paid_trial_switch_context( 3.0 ); - - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 7.5, $subscription, $cart_item ); - - $this->assertSame( 7.5, $result, 'A positive extra_to_pay is a real upgrade charge and must be preserved.' ); - } - - /** - * The filter guards against non-WC_Subscription inputs so it cannot - * fatal if a third-party callback supplies an unexpected value. - */ - public function test_apply_stepped_pricing_switch_charge_passes_through_non_subscription() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + $switch_item = $this->stage_switch_item( + [ + 'paid_sign_up_fee' => 0.0, + 'total_paid' => 0.0, + ] + ); - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, null, [] ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 0.0, $switch_item ); - $this->assertSame( -3.0, $result, 'A non-WC_Subscription argument must be returned unchanged.' ); + $this->assertSame( 0.0, $result, 'Without a paid sign-up fee on the existing item, the filter must not intervene.' ); } /** - * If the new product is missing from the cart_item (malformed switch - * metadata), the filter passes through so we never fabricate a charge - * without knowing the upgrade target's recurring price. + * A non-object switch_item is returned unchanged so a malformed call + * cannot fatal the filter chain. */ - public function test_apply_stepped_pricing_switch_charge_passes_through_without_new_product() { + public function test_apply_stepped_pricing_switch_charge_passes_through_invalid_switch_item() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - $subscription = new WC_Subscription( - [ - 'id' => 43, - 'status' => 'active', - 'times' => [ - 'trial_end' => time() + ( 15 * DAY_IN_SECONDS ), - ], - ] - ); - - // cart_item missing 'data' (new product) -> no recurring price lookup possible. - $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( -3.0, $subscription, [] ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, null ); - $this->assertSame( -3.0, $result, 'Without the new product we cannot compute the charge and must pass through.' ); + $this->assertSame( 3.0, $result, 'A non-object switch_item must be returned unchanged.' ); } } From a11fa6c57e0e4039129b5f9ddaecdde19b33bf84 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Tue, 26 May 2026 17:49:47 -0300 Subject: [PATCH 15/17] fix(wc-subscriptions): suppress extra_to_pay on stepped switches --- .../class-woocommerce-subscriptions.php | 71 +++++++++ .../class-woocommerce-subscriptions.php | 141 ++++++++++++++++++ 2 files changed, 212 insertions(+) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 99ae499fc6..0cebdd8a15 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -25,6 +25,7 @@ public static function init() { add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); add_filter( 'wcs_switch_proration_days_in_old_cycle', [ __CLASS__, 'bound_switch_proration_days_in_old_cycle' ], 10, 2 ); add_filter( 'wcs_switch_sign_up_fee', [ __CLASS__, 'apply_stepped_pricing_switch_charge' ], 10, 2 ); + add_filter( 'wcs_switch_proration_extra_to_pay', [ __CLASS__, 'suppress_extra_to_pay_for_stepped_pricing_switch' ], 10, 4 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -303,6 +304,76 @@ public static function apply_stepped_pricing_switch_charge( $value, $switch_item return max( $new_recurring - $unconsumed_credit, 0.0 ); } + /** + * Suppress WCS's apportioned extra-to-pay on stepped-pricing switches + * where apply_stepped_pricing_switch_charge has already expressed the + * full switch cost as the sign-up fee. + * + * When the new product's trial does not match the old product's trial + * (for example switching from a paid-trial product into a no-trial + * product), WCS does not force new_price_per_day to 0, so the switch + * is classified as an upgrade and routed through calculate_upgrade_cost. + * WCS_Switch_Totals_Calculator::set_upgrade_cost() then reads the + * current _subscription_sign_up_fee meta -- which carries our override + * from apply_stepped_pricing_switch_charge -- and adds extra_to_pay on + * top, double-charging the reader. + * + * Returning 0 here keeps the sign-up-fee value authoritative on both + * the matching-trials downgrade path (where extra_to_pay is never + * computed) and the non-matching-trials upgrade path (where it would + * otherwise compound). + * + * @param float $extra_to_pay The upgrade cost WCS computed. + * @param mixed $subscription The subscription being switched. + * @param array $cart_item The cart item recording the switch. + * @param int $days_in_old_cycle The number of days WCS used for the old cycle. + * + * @return float The (possibly zeroed) extra-to-pay. + */ + public static function suppress_extra_to_pay_for_stepped_pricing_switch( $extra_to_pay, $subscription, $cart_item, $days_in_old_cycle ) { + unset( $days_in_old_cycle ); + + if ( (float) $extra_to_pay <= 0 ) { + return $extra_to_pay; + } + + if ( ! ( $subscription instanceof \WC_Subscription ) ) { + return $extra_to_pay; + } + + // Only intervene during an active trial -- the only window in which + // apply_stepped_pricing_switch_charge would have overridden the + // sign-up fee. + if ( $subscription->get_time( 'trial_end' ) <= time() ) { + return $extra_to_pay; + } + + $item_id = isset( $cart_item['subscription_switch']['item_id'] ) ? (int) $cart_item['subscription_switch']['item_id'] : 0; + if ( $item_id <= 0 || ! function_exists( 'wcs_get_order_item' ) ) { + return $extra_to_pay; + } + + $existing_item = wcs_get_order_item( $item_id, $subscription ); + if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { + return $extra_to_pay; + } + + if ( ! self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { + return $extra_to_pay; + } + + // The stepped-pricing signature: the old line item actually carries + // a paid sign-up fee. Mirrors the gate in + // apply_stepped_pricing_switch_charge so both filters intervene on + // exactly the same switches. + $paid_sign_up_fee = (float) $subscription->get_items_sign_up_fee( $existing_item ); + if ( $paid_sign_up_fee <= 0 ) { + return $extra_to_pay; + } + + return 0.0; + } + /** * Whether a paid one-time sign-up fee should count toward the switch * proration baseline. diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index f6e7e779db..858a1ca9a1 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -846,4 +846,145 @@ public function test_apply_stepped_pricing_switch_charge_passes_through_invalid_ $this->assertSame( 3.0, $result, 'A non-object switch_item must be returned unchanged.' ); } + + /** + * Stage a subscription, existing item, and cart_item array suitable for + * driving suppress_extra_to_pay_for_stepped_pricing_switch in tests. + * + * Registers the existing item in the wcs_get_order_item() mock store so + * the filter can resolve it from the cart_item array as it would in WCS. + * + * @param array $args Test parameters: paid_sign_up_fee, trial_active (bool). + * @return array { @type WC_Subscription $subscription, @type array $cart_item } + */ + private function stage_extra_to_pay_context( array $args = [] ) { + global $wcs_mock_order_items; + + $args = wp_parse_args( + $args, + [ + 'paid_sign_up_fee' => 180.0, + 'trial_active' => true, + ] + ); + + $existing_item = new WC_Order_Item_Product( + [ + 'id' => 777, + 'product_id' => 100, + 'total' => 250.0, + 'meta' => [ '_subscription_sign_up_fee' => (string) $args['paid_sign_up_fee'] ], + ] + ); + + $wcs_mock_order_items[777] = $existing_item; + + $subscription = new WC_Subscription( + [ + 'id' => 60, + 'status' => 'active', + 'times' => [ + 'trial_end' => $args['trial_active'] ? time() + ( 284 * DAY_IN_SECONDS ) : 0, + ], + ] + ); + + $cart_item = [ + 'subscription_switch' => [ + 'item_id' => 777, + ], + ]; + + return [ $subscription, $cart_item ]; + } + + /** + * Regression for the non-matching-trials upgrade path: switching from a + * paid-trial plan (with a paid sign-up fee) into a plan without a trial + * promotes the switch to "upgrade" in WCS, which routes through + * calculate_upgrade_cost and would add extra_to_pay onto the sign-up + * fee that apply_stepped_pricing_switch_charge already set to the full + * first-cycle price minus unconsumed credit. Suppress that here. + */ + public function test_suppress_extra_to_pay_zeroes_under_stepped_pricing_conditions() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context(); + + $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); + + $this->assertSame( 0.0, $result, 'Under stepped-pricing conditions, extra_to_pay must be suppressed to avoid double-charging on top of the sign-up fee override.' ); + } + + /** + * Without the opt-in, the suppress filter is a no-op so default WCS + * behavior applies. + */ + public function test_suppress_extra_to_pay_passes_through_without_optin() { + list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context(); + + $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); + + $this->assertSame( 143.95, $result, 'Without the opt-in, extra_to_pay must be returned unchanged.' ); + } + + /** + * Out-of-trial switches are left to WCS's default proration: the + * stepped-pricing override does not apply, so neither does the + * extra-to-pay suppression. + */ + public function test_suppress_extra_to_pay_passes_through_outside_trial() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context( [ 'trial_active' => false ] ); + + $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); + + $this->assertSame( 143.95, $result, 'Out-of-trial switches must not have extra_to_pay suppressed.' ); + } + + /** + * Without a paid sign-up fee on the existing line item, the + * stepped-pricing signature is absent (genuine free trial, comp, etc.) + * and the suppression filter must pass through. + */ + public function test_suppress_extra_to_pay_passes_through_when_no_paid_signup_fee() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context( [ 'paid_sign_up_fee' => 0.0 ] ); + + $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); + + $this->assertSame( 143.95, $result, 'Without a paid sign-up fee on the existing item, extra_to_pay must be returned unchanged.' ); + } + + /** + * A non-positive extra_to_pay (downgrade or no upgrade cost) is already + * harmless; pass it through unchanged so the filter never widens its + * surface area beyond suppressing real double-charges. + */ + public function test_suppress_extra_to_pay_passes_through_when_non_positive() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context(); + + $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 0.0, $subscription, $cart_item, 365 ); + + $this->assertSame( 0.0, $result, 'Non-positive extra_to_pay must be returned unchanged.' ); + } + + /** + * A cart_item missing the subscription_switch.item_id key (malformed or + * non-switch call) must pass through unchanged so the filter cannot + * fatal a malformed pipeline. + */ + public function test_suppress_extra_to_pay_passes_through_when_no_item_id() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + list( $subscription ) = $this->stage_extra_to_pay_context(); + + $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, [], 365 ); + + $this->assertSame( 143.95, $result, 'A cart_item with no item_id must be returned unchanged.' ); + } } From 3d5b18946b00dfec70c36322641d7b41a31178cc Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 27 May 2026 18:22:54 -0300 Subject: [PATCH 16/17] fix(wc-subscriptions): pass switch charge through on mismatched trials --- .../class-woocommerce-subscriptions.php | 85 ++------- tests/mocks/wc-mocks.php | 3 + .../class-woocommerce-subscriptions.php | 166 +++--------------- 3 files changed, 41 insertions(+), 213 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 0cebdd8a15..4dd296da44 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -25,7 +25,6 @@ public static function init() { add_filter( 'wcs_switch_total_paid_for_current_period', [ __CLASS__, 'recover_total_paid_for_switch' ], 10, 3 ); add_filter( 'wcs_switch_proration_days_in_old_cycle', [ __CLASS__, 'bound_switch_proration_days_in_old_cycle' ], 10, 2 ); add_filter( 'wcs_switch_sign_up_fee', [ __CLASS__, 'apply_stepped_pricing_switch_charge' ], 10, 2 ); - add_filter( 'wcs_switch_proration_extra_to_pay', [ __CLASS__, 'suppress_extra_to_pay_for_stepped_pricing_switch' ], 10, 4 ); add_filter( 'wcs_can_user_resubscribe_to_subscription', [ __CLASS__, 'allow_migrated_subscription_to_resubscribe' ], 10, 3 ); } @@ -270,6 +269,20 @@ public static function apply_stepped_pricing_switch_charge( $value, $switch_item return $value; } + // When trial periods do not match between the old and new products + // (for example switching from a paid-trial plan into a no-trial + // plan), WCS does not force new_price_per_day to 0 and classifies + // the switch as an upgrade, then computes extra_to_pay via + // calculate_upgrade_cost(). Given the corrected total_paid baseline + // from recover_total_paid_for_switch, that extra_to_pay equals the + // prorated remaining-term price minus the prorated unconsumed + // credit -- the right answer for switches that inherit the existing + // next-payment date. Pass through here so WCS's default applies and + // we do not double-charge by also overriding the sign-up fee. + if ( method_exists( $switch_item, 'trial_periods_match' ) && ! $switch_item->trial_periods_match() ) { + return $value; + } + if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { return $value; } @@ -304,76 +317,6 @@ public static function apply_stepped_pricing_switch_charge( $value, $switch_item return max( $new_recurring - $unconsumed_credit, 0.0 ); } - /** - * Suppress WCS's apportioned extra-to-pay on stepped-pricing switches - * where apply_stepped_pricing_switch_charge has already expressed the - * full switch cost as the sign-up fee. - * - * When the new product's trial does not match the old product's trial - * (for example switching from a paid-trial product into a no-trial - * product), WCS does not force new_price_per_day to 0, so the switch - * is classified as an upgrade and routed through calculate_upgrade_cost. - * WCS_Switch_Totals_Calculator::set_upgrade_cost() then reads the - * current _subscription_sign_up_fee meta -- which carries our override - * from apply_stepped_pricing_switch_charge -- and adds extra_to_pay on - * top, double-charging the reader. - * - * Returning 0 here keeps the sign-up-fee value authoritative on both - * the matching-trials downgrade path (where extra_to_pay is never - * computed) and the non-matching-trials upgrade path (where it would - * otherwise compound). - * - * @param float $extra_to_pay The upgrade cost WCS computed. - * @param mixed $subscription The subscription being switched. - * @param array $cart_item The cart item recording the switch. - * @param int $days_in_old_cycle The number of days WCS used for the old cycle. - * - * @return float The (possibly zeroed) extra-to-pay. - */ - public static function suppress_extra_to_pay_for_stepped_pricing_switch( $extra_to_pay, $subscription, $cart_item, $days_in_old_cycle ) { - unset( $days_in_old_cycle ); - - if ( (float) $extra_to_pay <= 0 ) { - return $extra_to_pay; - } - - if ( ! ( $subscription instanceof \WC_Subscription ) ) { - return $extra_to_pay; - } - - // Only intervene during an active trial -- the only window in which - // apply_stepped_pricing_switch_charge would have overridden the - // sign-up fee. - if ( $subscription->get_time( 'trial_end' ) <= time() ) { - return $extra_to_pay; - } - - $item_id = isset( $cart_item['subscription_switch']['item_id'] ) ? (int) $cart_item['subscription_switch']['item_id'] : 0; - if ( $item_id <= 0 || ! function_exists( 'wcs_get_order_item' ) ) { - return $extra_to_pay; - } - - $existing_item = wcs_get_order_item( $item_id, $subscription ); - if ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { - return $extra_to_pay; - } - - if ( ! self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { - return $extra_to_pay; - } - - // The stepped-pricing signature: the old line item actually carries - // a paid sign-up fee. Mirrors the gate in - // apply_stepped_pricing_switch_charge so both filters intervene on - // exactly the same switches. - $paid_sign_up_fee = (float) $subscription->get_items_sign_up_fee( $existing_item ); - if ( $paid_sign_up_fee <= 0 ) { - return $extra_to_pay; - } - - return 0.0; - } - /** * Whether a paid one-time sign-up fee should count toward the switch * proration baseline. diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index 943e7a3f31..beef2b5e75 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -488,6 +488,9 @@ public function get_days_in_old_cycle() { public function get_days_until_next_payment() { return (int) $this->values['days_until_next']; } + public function trial_periods_match() { + return ! empty( $this->values['trial_periods_match'] ); + } } function wc_create_order( $data ) { diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 858a1ca9a1..0ac25ff074 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -688,12 +688,13 @@ private function stage_switch_item( array $args = [] ) { $args = wp_parse_args( $args, [ - 'paid_sign_up_fee' => 3.0, - 'total_paid' => 3.0, - 'new_recurring' => 10.0, - 'days_in_old_cycle' => 30, - 'days_until_next' => 30, - 'trial_active' => true, + 'paid_sign_up_fee' => 3.0, + 'total_paid' => 3.0, + 'new_recurring' => 10.0, + 'days_in_old_cycle' => 30, + 'days_until_next' => 30, + 'trial_active' => true, + 'trial_periods_match' => true, ] ); @@ -728,9 +729,10 @@ private function stage_switch_item( array $args = [] ) { $existing_item, $new_product, [ - 'total_paid' => $args['total_paid'], - 'days_in_old_cycle' => $args['days_in_old_cycle'], - 'days_until_next' => $args['days_until_next'], + 'total_paid' => $args['total_paid'], + 'days_in_old_cycle' => $args['days_in_old_cycle'], + 'days_until_next' => $args['days_until_next'], + 'trial_periods_match' => $args['trial_periods_match'], ] ); } @@ -848,143 +850,23 @@ public function test_apply_stepped_pricing_switch_charge_passes_through_invalid_ } /** - * Stage a subscription, existing item, and cart_item array suitable for - * driving suppress_extra_to_pay_for_stepped_pricing_switch in tests. - * - * Registers the existing item in the wcs_get_order_item() mock store so - * the filter can resolve it from the cart_item array as it would in WCS. - * - * @param array $args Test parameters: paid_sign_up_fee, trial_active (bool). - * @return array { @type WC_Subscription $subscription, @type array $cart_item } + * When the new product's trial period does not match the old product's + * trial period (e.g. switching from a paid-trial plan into a no-trial + * plan), WCS does not force new_price_per_day to 0 and computes + * extra_to_pay via calculate_upgrade_cost(), which -- given the + * corrected total_paid baseline from recover_total_paid_for_switch -- + * already equals the prorated remaining-term price minus the prorated + * unconsumed credit. Overriding the sign-up fee on top of that would + * double-charge the reader, so the stepped-pricing override must pass + * through and let WCS's default apply. */ - private function stage_extra_to_pay_context( array $args = [] ) { - global $wcs_mock_order_items; - - $args = wp_parse_args( - $args, - [ - 'paid_sign_up_fee' => 180.0, - 'trial_active' => true, - ] - ); - - $existing_item = new WC_Order_Item_Product( - [ - 'id' => 777, - 'product_id' => 100, - 'total' => 250.0, - 'meta' => [ '_subscription_sign_up_fee' => (string) $args['paid_sign_up_fee'] ], - ] - ); - - $wcs_mock_order_items[777] = $existing_item; - - $subscription = new WC_Subscription( - [ - 'id' => 60, - 'status' => 'active', - 'times' => [ - 'trial_end' => $args['trial_active'] ? time() + ( 284 * DAY_IN_SECONDS ) : 0, - ], - ] - ); - - $cart_item = [ - 'subscription_switch' => [ - 'item_id' => 777, - ], - ]; - - return [ $subscription, $cart_item ]; - } - - /** - * Regression for the non-matching-trials upgrade path: switching from a - * paid-trial plan (with a paid sign-up fee) into a plan without a trial - * promotes the switch to "upgrade" in WCS, which routes through - * calculate_upgrade_cost and would add extra_to_pay onto the sign-up - * fee that apply_stepped_pricing_switch_charge already set to the full - * first-cycle price minus unconsumed credit. Suppress that here. - */ - public function test_suppress_extra_to_pay_zeroes_under_stepped_pricing_conditions() { + public function test_apply_stepped_pricing_switch_charge_passes_through_when_trial_periods_do_not_match() { add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context(); - - $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); + $switch_item = $this->stage_switch_item( [ 'trial_periods_match' => false ] ); - $this->assertSame( 0.0, $result, 'Under stepped-pricing conditions, extra_to_pay must be suppressed to avoid double-charging on top of the sign-up fee override.' ); - } - - /** - * Without the opt-in, the suppress filter is a no-op so default WCS - * behavior applies. - */ - public function test_suppress_extra_to_pay_passes_through_without_optin() { - list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context(); - - $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); - - $this->assertSame( 143.95, $result, 'Without the opt-in, extra_to_pay must be returned unchanged.' ); - } - - /** - * Out-of-trial switches are left to WCS's default proration: the - * stepped-pricing override does not apply, so neither does the - * extra-to-pay suppression. - */ - public function test_suppress_extra_to_pay_passes_through_outside_trial() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context( [ 'trial_active' => false ] ); - - $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); - - $this->assertSame( 143.95, $result, 'Out-of-trial switches must not have extra_to_pay suppressed.' ); - } - - /** - * Without a paid sign-up fee on the existing line item, the - * stepped-pricing signature is absent (genuine free trial, comp, etc.) - * and the suppression filter must pass through. - */ - public function test_suppress_extra_to_pay_passes_through_when_no_paid_signup_fee() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context( [ 'paid_sign_up_fee' => 0.0 ] ); - - $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, $cart_item, 365 ); - - $this->assertSame( 143.95, $result, 'Without a paid sign-up fee on the existing item, extra_to_pay must be returned unchanged.' ); - } - - /** - * A non-positive extra_to_pay (downgrade or no upgrade cost) is already - * harmless; pass it through unchanged so the filter never widens its - * surface area beyond suppressing real double-charges. - */ - public function test_suppress_extra_to_pay_passes_through_when_non_positive() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - list( $subscription, $cart_item ) = $this->stage_extra_to_pay_context(); - - $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 0.0, $subscription, $cart_item, 365 ); - - $this->assertSame( 0.0, $result, 'Non-positive extra_to_pay must be returned unchanged.' ); - } - - /** - * A cart_item missing the subscription_switch.item_id key (malformed or - * non-switch call) must pass through unchanged so the filter cannot - * fatal a malformed pipeline. - */ - public function test_suppress_extra_to_pay_passes_through_when_no_item_id() { - add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); - - list( $subscription ) = $this->stage_extra_to_pay_context(); - - $result = WooCommerce_Subscriptions::suppress_extra_to_pay_for_stepped_pricing_switch( 143.95, $subscription, [], 365 ); + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); - $this->assertSame( 143.95, $result, 'A cart_item with no item_id must be returned unchanged.' ); + $this->assertSame( 3.0, $result, 'When trial periods do not match, the WCS-computed value must pass through so the prorated extra_to_pay is the final charge.' ); } } From 4f529707e8de1ed584ea048051fabe74a9d6b5d3 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 1 Jun 2026 14:24:01 -0300 Subject: [PATCH 17/17] fix(wc-subscriptions): harden switch proration edge cases --- .../class-woocommerce-subscriptions.php | 46 +++- tests/mocks/wc-mocks.php | 38 +++- .../class-woocommerce-subscriptions.php | 214 +++++++++++++++++- 3 files changed, 291 insertions(+), 7 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 4dd296da44..071cfb0790 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -152,6 +152,18 @@ public static function recover_total_paid_for_switch( $total_paid, $subscription // paid, so a switch during the trial sees $0. When the publisher has // opted in, count the sign-up fee the reader actually paid. A free // trial with no sign-up fee, or a comp, yields nothing and no-ops. + // + // Unlike apply_stepped_pricing_switch_charge(), this branch is NOT + // additionally gated on an active trial or a paid sign-up fee, and + // that looser gate is intentional: this is the general baseline that + // feeds every downstream WCS switch calculation, not just the + // active-trial override. The bound is the recovered value itself -- + // get_total_paid_including_signup_fee() returns what WCS's own + // accounting says the reader actually paid (including sign-up fees), + // so it can never fabricate credit beyond a real payment. A comp or + // 100%-discount returns ~0 and the max() leaves total_paid untouched; + // an out-of-trial period the reader paid for already returns a + // positive total_paid above and never reaches here. if ( self::should_count_signup_fee_on_switch( $subscription, $existing_item ) ) { return max( self::get_total_paid_including_signup_fee( $subscription, $existing_item ), (float) $total_paid ); } @@ -220,6 +232,11 @@ public static function bound_switch_proration_days_in_old_cycle( $days_in_old_cy * branching, so it is the only WCS hook reachable for both * upgrade-as-downgrade and ordinary downgrade paths. * + * The exception is a switch into a one-payment (length-1) subscription: + * WCS routes that through set_upgrade_cost() regardless of switch type + * and stacks the apportioned sign-up fee on top of the gap payment, so we + * bail in that case (below) and leave the pricing to WCS. + * * Setting the sign-up fee to (new_recurring - unconsumed_credit) makes * the cart show the right one-time charge, while WCS continues to set * up the new plan's recurring schedule from the inherited trial_end. @@ -264,7 +281,11 @@ public static function apply_stepped_pricing_switch_charge( $value, $switch_item // The stepped-pricing signature: the old line item actually carries // a paid sign-up fee. A real free trial (sign-up fee = 0) is left // alone so we never invent a charge for a reader who paid nothing. - $paid_sign_up_fee = (float) $subscription->get_items_sign_up_fee( $existing_item ); + // Mirror WCS's tax-mode selection (WCS_Switch_Totals_Calculator:: + // apportion_sign_up_fees) so the recovered baseline is dimensionally + // consistent with new_recurring on a tax-inclusive store. + $tax_mode = ( function_exists( 'wc_prices_include_tax' ) && wc_prices_include_tax() ) ? 'inclusive_of_tax' : 'exclusive_of_tax'; + $paid_sign_up_fee = (float) $subscription->get_items_sign_up_fee( $existing_item, $tax_mode ); if ( $paid_sign_up_fee <= 0 ) { return $value; } @@ -279,7 +300,20 @@ public static function apply_stepped_pricing_switch_charge( $value, $switch_item // credit -- the right answer for switches that inherit the existing // next-payment date. Pass through here so WCS's default applies and // we do not double-charge by also overriding the sign-up fee. - if ( method_exists( $switch_item, 'trial_periods_match' ) && ! $switch_item->trial_periods_match() ) { + // Fail safe: when we cannot confirm the trial periods match, pass + // through to WCS's default rather than applying the override on an + // assumption (every other guard here fails to pass-through). + if ( ! method_exists( $switch_item, 'trial_periods_match' ) || ! $switch_item->trial_periods_match() ) { + return $value; + } + + // A switch into a one-payment (length-1) subscription routes through + // WCS's set_upgrade_cost() regardless of switch type + // (WCS_Switch_Totals_Calculator::calculate_prorated_totals), which + // sets the sign-up fee to existing_fee + extra_to_pay -- adding our + // override on top would double-charge. Pass through and let WCS price + // the gap payment. + if ( method_exists( $switch_item, 'is_switch_to_one_payment_subscription' ) && $switch_item->is_switch_to_one_payment_subscription() ) { return $value; } @@ -312,7 +346,13 @@ public static function apply_stepped_pricing_switch_charge( $value, $switch_item return $value; } - $unconsumed_credit = $total_paid * ( $days_until_next / $days_in_old_cycle ); + // Clamp the unconsumed fraction to [0, 1]. days_until_next (WCS ceil) + // can exceed days_in_old_cycle (WCS round) by ~1 day near a cycle + // boundary, and for migrated subs only days_in_old_cycle flows through + // our clamp filter -- either can push the ratio above 1.0 and credit + // the reader more than they paid. + $unconsumed_ratio = min( 1.0, max( 0.0, $days_until_next / $days_in_old_cycle ) ); + $unconsumed_credit = $total_paid * $unconsumed_ratio; return max( $new_recurring - $unconsumed_credit, 0.0 ); } diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index beef2b5e75..c863ca435b 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -400,7 +400,8 @@ public function get_items() { return $this->data['items'] ?? []; } public function get_items_sign_up_fee( $item, $tax = 'exclusive_of_tax' ) { - global $wcs_mock_items_sign_up_fee; + global $wcs_mock_items_sign_up_fee, $wcs_mock_last_items_sign_up_fee_tax; + $wcs_mock_last_items_sign_up_fee_tax = $tax; if ( is_object( $item ) && method_exists( $item, 'get_meta' ) ) { $meta_value = $item->get_meta( '_subscription_sign_up_fee' ); if ( $meta_value !== '' && $meta_value !== null ) { @@ -491,6 +492,37 @@ public function get_days_until_next_payment() { public function trial_periods_match() { return ! empty( $this->values['trial_periods_match'] ); } + public function is_switch_to_one_payment_subscription() { + return ! empty( $this->values['one_payment'] ); + } +} + +/** + * Test double for an older WCS_Switch_Cart_Item that predates the + * trial_periods_match() and is_switch_to_one_payment_subscription() methods. + * Used to verify the integration fails safe (passes through) when it cannot + * confirm those conditions on the running WCS version. + */ +class Mock_WCS_Switch_Cart_Item_Legacy { + public $subscription; + public $existing_item; + public $product; + private $values; + public function __construct( $sub, $item, $product, $values = [] ) { + $this->subscription = $sub; + $this->existing_item = $item; + $this->product = $product; + $this->values = $values; + } + public function get_total_paid_for_current_period() { + return (float) ( $this->values['total_paid'] ?? 0 ); + } + public function get_days_in_old_cycle() { + return (int) ( $this->values['days_in_old_cycle'] ?? 30 ); + } + public function get_days_until_next_payment() { + return (int) ( $this->values['days_until_next'] ?? 30 ); + } } function wc_create_order( $data ) { @@ -603,6 +635,10 @@ function wc_string_to_bool( $string ) { function wc_bool_to_string( $bool ) { return $bool ? 'yes' : 'no'; } +function wc_prices_include_tax() { + global $wcs_mock_prices_include_tax; + return ! empty( $wcs_mock_prices_include_tax ); +} function wc_get_orders( $args ) { global $orders_database; // For simplicity, this mock will only return a single page of results. diff --git a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 0ac25ff074..c55488b086 100644 --- a/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -21,13 +21,15 @@ class Newspack_Test_WooCommerce_Subscriptions extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); - global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items, $wcs_mock_items_sign_up_fee; + global $subscriptions_database, $products_database, $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items, $wcs_mock_items_sign_up_fee, $wcs_mock_prices_include_tax, $wcs_mock_last_items_sign_up_fee_tax; $subscriptions_database = []; $products_database = []; $wcs_mock_total_paid_including_signup_fee = 0; $wcs_mock_last_calculate_total_paid_args = null; $wcs_mock_order_items = []; $wcs_mock_items_sign_up_fee = 0; + $wcs_mock_prices_include_tax = false; + $wcs_mock_last_items_sign_up_fee_tax = null; } /** @@ -35,11 +37,13 @@ public function set_up() { * not leak across tests. */ public function tear_down() { - global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items, $wcs_mock_items_sign_up_fee; + global $wcs_mock_total_paid_including_signup_fee, $wcs_mock_last_calculate_total_paid_args, $wcs_mock_order_items, $wcs_mock_items_sign_up_fee, $wcs_mock_prices_include_tax, $wcs_mock_last_items_sign_up_fee_tax; $wcs_mock_total_paid_including_signup_fee = 0; $wcs_mock_last_calculate_total_paid_args = null; $wcs_mock_order_items = []; $wcs_mock_items_sign_up_fee = 0; + $wcs_mock_prices_include_tax = false; + $wcs_mock_last_items_sign_up_fee_tax = null; remove_all_filters( 'newspack_wc_subs_switch_include_signup_fee' ); parent::tear_down(); } @@ -681,7 +685,8 @@ public function test_bound_switch_proration_days_in_old_cycle_uses_billing_perio * * @param array $args Test parameters: paid_sign_up_fee, total_paid, * new_recurring, days_in_old_cycle, days_until_next, - * trial_active (bool). + * trial_active (bool), trial_periods_match (bool), + * one_payment (bool). * @return object Minimal switch_item stub. */ private function stage_switch_item( array $args = [] ) { @@ -695,6 +700,7 @@ private function stage_switch_item( array $args = [] ) { 'days_until_next' => 30, 'trial_active' => true, 'trial_periods_match' => true, + 'one_payment' => false, ] ); @@ -733,6 +739,7 @@ private function stage_switch_item( array $args = [] ) { 'days_in_old_cycle' => $args['days_in_old_cycle'], 'days_until_next' => $args['days_until_next'], 'trial_periods_match' => $args['trial_periods_match'], + 'one_payment' => $args['one_payment'], ] ); } @@ -869,4 +876,205 @@ public function test_apply_stepped_pricing_switch_charge_passes_through_when_tri $this->assertSame( 3.0, $result, 'When trial periods do not match, the WCS-computed value must pass through so the prorated extra_to_pay is the final charge.' ); } + + /** + * Near a cycle boundary, days_until_next (WCS ceil) can exceed + * days_in_old_cycle (WCS round) by ~1 day, pushing the unconsumed fraction + * above 1.0. The credit must clamp so the reader is never credited more + * than they paid (and therefore never under-charged). For Regular ($3 + * paid) -> Pro ($10/mo) with 31 days until next over a 30-day cycle, the + * unclamped credit would be $3.10 (charge $6.90); clamped it is $3 + * (charge $7.00). + */ + public function test_apply_stepped_pricing_switch_charge_clamps_unconsumed_ratio_at_cycle_boundary() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item( + [ + 'days_in_old_cycle' => 30, + 'days_until_next' => 31, + ] + ); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $this->assertSame( 7.0, $result, 'The unconsumed fraction must clamp to 1.0 so credit never exceeds the amount paid.' ); + } + + /** + * A switch into a one-payment (length-1) subscription routes through WCS's + * set_upgrade_cost(), which stacks the apportioned sign-up fee on top of + * the gap payment. The override must bail so we do not double-charge. + */ + public function test_apply_stepped_pricing_switch_charge_passes_through_for_one_payment_target() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item( [ 'one_payment' => true ] ); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $this->assertSame( 3.0, $result, 'A switch into a one-payment subscription must pass through to WCS.' ); + } + + /** + * On a WCS version that predates trial_periods_match() / + * is_switch_to_one_payment_subscription(), the override must fail safe and + * pass through rather than apply on an unverifiable assumption. + */ + public function test_apply_stepped_pricing_switch_charge_fails_safe_when_methods_absent() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $existing_item = new WC_Order_Item_Product( + [ + 'id' => 999, + 'product_id' => 100, + 'total' => 5.0, + 'meta' => [ '_subscription_sign_up_fee' => '3.0' ], + ] + ); + $subscription = new WC_Subscription( + [ + 'id' => 60, + 'status' => 'active', + 'times' => [ 'trial_end' => time() + ( 15 * DAY_IN_SECONDS ) ], + ] + ); + $new_product = wc_create_mock_product( + [ + 'id' => 200, + 'meta' => [ '_subscription_price' => '10.0' ], + ] + ); + + $switch_item = new Mock_WCS_Switch_Cart_Item_Legacy( $subscription, $existing_item, $new_product ); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $this->assertSame( 3.0, $result, 'When trial_periods_match() is unavailable, the override must pass through.' ); + } + + /** + * On a tax-inclusive store the recovered sign-up fee must be read in the + * same tax mode WCS uses (WCS_Switch_Totals_Calculator:: + * apportion_sign_up_fees), otherwise the baseline is dimensionally + * mismatched against new_recurring. + */ + public function test_apply_stepped_pricing_switch_charge_reads_signup_fee_inclusive_of_tax_on_tax_inclusive_store() { + global $wcs_mock_prices_include_tax, $wcs_mock_last_items_sign_up_fee_tax; + $wcs_mock_prices_include_tax = true; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item(); + + WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $this->assertSame( 'inclusive_of_tax', $wcs_mock_last_items_sign_up_fee_tax, 'On a tax-inclusive store the sign-up fee must be read inclusive_of_tax to match WCS.' ); + } + + /** + * On a tax-exclusive store (the default) the sign-up fee is read + * exclusive_of_tax, matching WCS. + */ + public function test_apply_stepped_pricing_switch_charge_reads_signup_fee_exclusive_of_tax_by_default() { + global $wcs_mock_last_items_sign_up_fee_tax; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item(); + + WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $this->assertSame( 'exclusive_of_tax', $wcs_mock_last_items_sign_up_fee_tax, 'On a tax-exclusive store the sign-up fee must be read exclusive_of_tax.' ); + } + + /** + * Documents the intentional looser gate on recover_total_paid_for_switch + * branch 2: with the opt-in on it recovers even outside an active trial, + * but the recovery is bounded by what the reader actually paid (WCS's own + * accounting), so it can never fabricate credit. Here WCS reports $15 + * actually paid and that -- not more -- becomes the baseline. + */ + public function test_recover_total_paid_optin_recovery_is_bounded_by_actual_payment() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 15.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + // No trial set: out of any active trial. + $subscription = new WC_Subscription( + [ + 'id' => 70, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 15.0, $result, 'Recovery is bounded by the actual amount paid, never more.' ); + } + + /** + * No-op: a genuine free trial (opt-in on, but nothing actually paid) + * recovers nothing. + */ + public function test_recover_total_paid_optin_genuine_free_trial_is_noop() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 0.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 71, + 'status' => 'active', + 'times' => [ 'trial_end' => time() + DAY_IN_SECONDS ], + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 0.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 0.0, $subscription, $existing_item ); + + $this->assertSame( 0.0, $result, 'A genuine free trial with nothing paid must not gain a recovered baseline.' ); + } + + /** + * No-op: a subscription with normal order history (WCS reports a positive + * amount paid) returns early and never reaches branch 2, even with the + * opt-in on. + */ + public function test_recover_total_paid_optin_leaves_normal_history_positive_value_untouched() { + global $wcs_mock_total_paid_including_signup_fee; + $wcs_mock_total_paid_including_signup_fee = 999.0; + + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $subscription = new WC_Subscription( + [ + 'id' => 72, + 'status' => 'active', + ] + ); + $existing_item = new WC_Order_Item_Product( + [ + 'product_id' => 100, + 'total' => 50.0, + ] + ); + + $result = WooCommerce_Subscriptions::recover_total_paid_for_switch( 8.0, $subscription, $existing_item ); + + $this->assertSame( 8.0, $result, 'A positive WCS value (normal history) must be returned untouched even with the opt-in on.' ); + } }