Skip to content

feat(wc-subscriptions): recover switch proration when no amount paid#4745

Open
miguelpeixe wants to merge 16 commits into
releasefrom
nppm-2837-switching-between-variations-in-separate-products
Open

feat(wc-subscriptions): recover switch proration when no amount paid#4745
miguelpeixe wants to merge 16 commits into
releasefrom
nppm-2837-switching-between-variations-in-separate-products

Conversation

@miguelpeixe
Copy link
Copy Markdown
Member

@miguelpeixe miguelpeixe commented May 19, 2026

All Submissions:

Changes proposed in this Pull Request:

When a reader switches subscription plans, WooCommerce Subscriptions prorates the change from the amount paid for the current billing period — which it derives by summing the matching line item across the subscription's related orders. For two populations that sum is $0, which makes WCS treat the old subscription as $0/day, misclassify a downgrade as an upgrade, and charge the full prorated price of the new plan as a sign-up fee instead of crediting the remaining term.

This PR corrects the switch behavior for those two cases via WCS filters:

1. Migrated subscriptions. Subscriptions migrated into WooCommerce from another platform (Piano, Stripe) have no Woo order history, so WCS sees $0. Newspack already opts these into switching via allow_migrated_subscription_switch(). The wcs_switch_total_paid_for_current_period filter falls back to the subscription line item's recurring total — one billing period's recurring charge, dimensionally what WCS divides by the billing-cycle length. Active free trials are skipped (a migrated sub still in a trial has paid nothing). This applies on all migrated subscriptions.

A companion wcs_switch_proration_days_in_old_cycle filter clamps the denominator to one billing cycle for migrated subscriptions: without it, WCS's (next_payment - subscription_start) / DAY_IN_SECONDS calculation spans the entire time since the original platform sign-up (often years), which would make old_price_per_day artificially low and still misclassify a downgrade as an upgrade even after the amount-paid recovery above. Non-migrated subscriptions are left to WCS's default behavior.

2. Paid-trial subscriptions (opt-in). Some publishers express stepped pricing as a one-time sign-up fee plus a free trial — the sign-up fee is functionally a first-period discount, not a real one-time fee. Switching between two such products at WCS's defaults is broken: the matching-trials path forces new_price_per_day to 0 and routes the switch through extend_prepaid_term, so the reader is upgraded to a more expensive plan for free (or for just the sign-up-fee delta, depending on store settings).

When the publisher opts in by defining NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE in wp-config.php, the integration:

  • Substitutes the sign-up fee into the proration baseline via wcs_switch_total_paid_for_current_period (so the old price_per_day reflects the discount the reader actually paid).
  • Overrides the new plan's apportioned sign-up fee via wcs_switch_sign_up_fee to max(new_recurring − unconsumed_credit, 0) — the model is "switch ends the first-period discount on both sides; charge the new plan at its regular recurring price for the first cycle, crediting any unconsumed portion of what the reader paid". This is the only WCS hook reachable on the matching-trials downgrade path; wcs_switch_proration_extra_to_pay is never called because WCS routes through extend_prepaid_term instead of calculate_upgrade_cost.

Off by default; only opted-in publishers are affected. The opt-in is also exposed as the newspack_wc_subs_switch_include_signup_fee filter for finer-grained control (e.g. per-subscription or per-product) — callbacks receive the subscription and line item alongside the enabled flag. The override only intervenes during an active trial when the existing line item carries a paid sign-up fee (the stepped-pricing signature); real free trials, comps, and out-of-trial switches all pass through to WCS's default behavior. Works regardless of WC's store-wide apportion_sign_up_fee setting.

Scope is intentionally narrow. Every other zero-paid case — 100%-discount purchases, comps, ordinary unpaid states — is left to WCS's default switching behavior on purpose: we do not carry discounts or comps across switches. Subscriptions with normal order history are untouched (WCS already returns a positive value and the filter no-ops).

Closes NPPM-2837.

How to test the changes in this Pull Request:

  1. On a site with WooCommerce + WooCommerce Subscriptions, create a variable subscription product with two switchable variations that share the same trial period — for example: Regular at $5/month + $3 sign-up fee + 1 month trial, Pro at $10/month + $6 sign-up fee + 1 month trial.
  2. Migrated subscription: create an active subscription with no Woo order history and stamp it with migration meta (_piano_subscription_id or _stripe_subscription_id). Backdate the subscription start so it predates the next payment by more than one billing cycle (this surfaces the days_in_old_cycle clamp). Switch it to a cheaper plan and expand the transaction details. Verify the switch is correctly classified as a downgrade — the term should extend / credit the remaining period rather than charging a full prorated sign-up fee.
  3. Regression — normal paid subscription: repeat with a customer whose subscription was purchased at full price with a real order. Confirm the switch proration is unchanged from release (the filter no-ops because WCS returns a positive value).
  4. Paid-trial, opt-in enabled, immediate switch: add define( 'NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE', true ); to wp-config.php. Purchase Regular (reader pays the $3 sign-up fee, enters the trial). Immediately switch to Pro and expand the transaction details. Verify the charge equals new_recurring − unconsumed_credit — for $10 recurring with $3 unconsumed, that is $7 (plus transaction fee, if any).
  5. Paid-trial, opt-in enabled, mid-trial switch: repeat step 5 but advance time so roughly half the trial has elapsed (e.g. backdate the subscription's trial_end by 15 days using WP-CLI). Switch to Pro. Verify the charge scales with consumed time — for half-consumed, the charge is roughly $10 − $1.50 = $8.50.
  6. Regression — paid-trial, opt-in disabled: remove the define() (or set it false) and repeat step 5. Confirm the switch behaves like release does today — the integration does not intervene and WCS's default switching cost applies.
  7. Regression — comped / 100%-discount: create a subscription via an order paid with a 100% coupon (no migration meta, no sign-up fee). Confirm switching it behaves like release — the filter does not activate. We intentionally do not carry discounts across switches.
  8. Regression — genuine free trial: create a subscription with an active free trial and no sign-up fee. Confirm switching during the trial behaves like release (the integration does not intervene because the existing line item carries no paid sign-up fee).

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your changes, as applicable?
  • Have you successfully ran tests with your changes locally?

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses incorrect proration behavior when switching WooCommerce Subscriptions plans in cases where WCS computes a non-positive “total paid for current period” (e.g., migrated subscriptions, fully-discounted/comped orders, or broken switch chains). It introduces a fallback baseline based on the subscription line item’s recurring total so downgrades are not misclassified as upgrades and don’t incur an erroneous prorated sign-up fee.

Changes:

  • Hook wcs_switch_total_paid_for_current_period and, when WCS returns a non-positive amount, fall back to the subscription line item’s recurring total.
  • Add unit tests covering zero baseline recovery, pass-through for positive values, genuinely free subscriptions, and unexpected item types.
  • Extend the WooCommerce test mock WC_Order_Item_Product to support get_total().

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
tests/unit-tests/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php Adds unit tests for the new “recover total paid” switch proration fallback behavior.
tests/mocks/wc-mocks.php Adds WC_Order_Item_Product::get_total() to support the new logic and tests.
includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php Adds the wcs_switch_total_paid_for_current_period filter handler that restores a proration baseline when WCS reports zero/non-positive paid amount.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@miguelpeixe miguelpeixe self-assigned this May 19, 2026
@miguelpeixe miguelpeixe changed the title fix(wc-subscriptions): recover switch proration when no amount paid feat(wc-subscriptions): recover switch proration when no amount paid May 25, 2026
@miguelpeixe miguelpeixe requested a review from Copilot May 25, 2026 16:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php Outdated
@miguelpeixe miguelpeixe force-pushed the nppm-2837-switching-between-variations-in-separate-products branch from 4171f68 to 2cd7405 Compare May 25, 2026 18:58
@miguelpeixe miguelpeixe changed the base branch from trunk to release May 25, 2026 18:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@miguelpeixe miguelpeixe marked this pull request as ready for review May 26, 2026 17:52
@miguelpeixe miguelpeixe requested a review from a team as a code owner May 26, 2026 17:52
@miguelpeixe miguelpeixe added the [Status] Needs Review The issue or pull request needs to be reviewed label May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Status] Needs Review The issue or pull request needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants