diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php index 7d06aadc82..071cfb0790 100644 --- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php +++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php @@ -22,9 +22,31 @@ 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_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_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. * @@ -47,16 +69,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; } @@ -79,6 +92,341 @@ public static function allow_migrated_subscription_switch( $can_switch, $item, $ return $can_switch; } + /** + * 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. 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. + * @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 ( ! ( $existing_item instanceof \WC_Order_Item_Product ) ) { + 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. 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->get_time( 'trial_end' ) > time() ) { + return $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 + // 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. + // + // 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 ); + } + + 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 ); + } + + /** + * 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, 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 + * 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. + * + * 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. + * + * 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. + * + * Only fires when the publisher has opted in, the subscription is in an + * 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 $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 sign-up fee to charge for the switch. + */ + 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 $value; + } + + // 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 $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; + } + + // 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. + // 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; + } + + // 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. + // 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; + } + + if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { + return $value; + } + + $new_recurring = (float) \WC_Subscriptions_Product::get_price( $new_product ); + if ( $new_recurring <= 0 ) { + 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; + } + + $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; + } + + // 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 ); + } + + /** + * 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, 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). + * + * @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( $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. + * + * 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, $subscription, $existing_item ); + } + + /** + * 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; + } + + // 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, + 'include_sign_up_fees' + ); + } + /** * Initialize WooCommerce Subscriptions Integration. */ @@ -378,14 +726,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 c973c2272d..c863ca435b 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'] ?? ''; @@ -135,11 +139,17 @@ 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; return $products_database[ $product_id ] ?? false; } + public function get_meta( $key, $single = true ) { + return $this->meta[ $key ] ?? ''; + } } class WC_Product { @@ -362,6 +372,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(); @@ -386,6 +399,17 @@ 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, $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 ) { + return (float) $meta_value; + } + } + return (float) ( $wcs_mock_items_sign_up_fee ?? 0 ); + } public function save() { return true; } @@ -394,8 +418,110 @@ public function save() { class WC_Subscriptions { } +if ( ! class_exists( 'WC_Subscriptions_Switcher' ) ) { + /** + * Mock of WC_Subscriptions_Switcher. + * + * 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, $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; + } + } +} + 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' ); + } + public static function get_price( $product ) { + if ( ! is_object( $product ) || ! method_exists( $product, 'get_meta' ) ) { + return 0; + } + return (float) $product->get_meta( '_subscription_price' ); + } + } +} + +/** + * 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']; + } + 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 ); } } @@ -490,12 +616,29 @@ 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 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 ) ); } 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 74d37600b9..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,9 +21,31 @@ 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, $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; + } + + /** + * Reset any filters or mock state added by individual tests so they do + * 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, $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(); } /** @@ -160,4 +182,899 @@ 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', + 'meta' => [ '_piano_subscription_id' => 'piano-1' ], + ] + ); + $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', + 'meta' => [ '_piano_subscription_id' => 'piano-3' ], + ] + ); + $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.' ); + } + + /** + * 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.' ); + } + + /** + * 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, + ], + 'meta' => [ '_piano_subscription_id' => 'piano-5' ], + ] + ); + $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.' ); + } + + /** + * 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.' ); + } + + /** + * 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.' ); + } + + /** + * 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.' ); + } + + /** + * 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 + * 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.' ); + } + + /** + * 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 array $args Test parameters: paid_sign_up_fee, total_paid, + * new_recurring, days_in_old_cycle, days_until_next, + * trial_active (bool), trial_periods_match (bool), + * one_payment (bool). + * @return object Minimal switch_item stub. + */ + 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, + 'trial_periods_match' => true, + 'one_payment' => false, + ] + ); + + $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'] ], + ] + ); + + $subscription = new WC_Subscription( + [ + 'id' => 50, + 'status' => 'active', + 'times' => [ + 'trial_end' => $args['trial_active'] ? time() + ( 15 * DAY_IN_SECONDS ) : 0, + ], + ] + ); + + $new_product = wc_create_mock_product( + [ + 'id' => 200, + 'meta' => [ '_subscription_price' => (string) $args['new_recurring'] ], + ] + ); + + 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'], + 'trial_periods_match' => $args['trial_periods_match'], + 'one_payment' => $args['one_payment'], + ] + ); + } + + /** + * Stepped-pricing immediate switch: nothing consumed, full unconsumed + * 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_seven_at_day_0() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item( [ 'days_until_next' => 30 ] ); + + // 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).' ); + } + + /** + * 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_returns_eight_fifty_at_day_15() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $switch_item = $this->stage_switch_item( [ 'days_until_next' => 15 ] ); + + $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).' ); + } + + /** + * 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 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( 0.0, $switch_item ); + + $this->assertSame( 0.0, $result, 'When unconsumed credit exceeds the new recurring, charge clamps to 0.' ); + } + + /** + * 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() { + $switch_item = $this->stage_switch_item(); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $this->assertSame( 3.0, $result, 'Without the opt-in, WCS-computed value must pass through unchanged.' ); + } + + /** + * 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' ); + + $switch_item = $this->stage_switch_item( [ 'trial_active' => false ] ); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $this->assertSame( 3.0, $result, 'Out-of-trial switches must not be re-priced.' ); + } + + /** + * 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_when_no_paid_signup_fee() { + 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( 0.0, $switch_item ); + + $this->assertSame( 0.0, $result, 'Without a paid sign-up fee on the existing item, the filter must not intervene.' ); + } + + /** + * 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_invalid_switch_item() { + add_filter( 'newspack_wc_subs_switch_include_signup_fee', '__return_true' ); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, null ); + + $this->assertSame( 3.0, $result, 'A non-object switch_item must be returned unchanged.' ); + } + + /** + * 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. + */ + 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' ); + + $switch_item = $this->stage_switch_item( [ 'trial_periods_match' => false ] ); + + $result = WooCommerce_Subscriptions::apply_stepped_pricing_switch_charge( 3.0, $switch_item ); + + $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.' ); + } }