feat(wc-subscriptions): recover switch proration when no amount paid#4745
Conversation
There was a problem hiding this comment.
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_periodand, 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_Productto supportget_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.
4171f68 to
2cd7405
Compare
There was a problem hiding this comment.
I think it's worth outlining exactly what I saw during manual testing as there's math involved and I want to make sure that the numbers look correct to you. Here's what I saw. Some other minor comments inline, but can be deferred if necessary.
Setup: test products created
- PR4745 Membership: a variable subscription with two tiers:
- Regular - $5 / month, $3 sign-up fee, 1 month free trial
- Pro - $10 / month, $6 sign-up fee, 1 month free trial
- PR4745 Annual Pass: a simple subscription, $50 / month with no sign-up fee or free trial period
- PR4745 Free Trial: a simple subscription, $10 / month with no sign-up fee, 1 month free trial period
Step 2: Switch to a lower-price variation ("downgrade") with a migrated subscription
- Created a migrated subscription (no order history, stamped with
_stripe_subscription_idmeta value) with the Pro product - Logged in as the subscription owner, clicked "Change subscription" in the My Account page, selected the Regular product
- Transaction details were:
| Product | Subtotal |
|---|---|
| PR4745 Membership - Regular × 1 | $5.00 / month and a $3.00 sign-up fee |
| Subtotal | $3.00 |
| Total | $3.00 |
| Recurring totals | |
| Subtotal | $5.00 / month |
| Recurring total | $5.00 / month First renewal: July 30, 2026 |
Verdict: ✅ Passes. The $3.00 sign-up fee is expected. From the PR description: "Active free trials are skipped (a migrated sub still in a trial has paid nothing)"
Step 3: Switch to a lower-price variation with a regular / non-migrated subscription
- As a reader, purchased a Pro subscription
- Backdated the start date to the same start date as the migrated subscription in step 2
- In the My Account subscription page, started the switch flow via "Change subscription"
- Transaction details were:
| Product | Subtotal |
|---|---|
| PR4745 Membership - Regular × 1 | $5.00 / month and a $3.00 sign-up fee |
| Subtotal | $0.00 |
| Total | $0.00 |
| Recurring totals | |
| Subtotal | $5.00 / month |
| Recurring total | $5.00 / month First renewal: June 29, 2026 |
Verdict: ✅ Passes. The $0.00 sign-up fee is expected. From the PR description: "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." The sign-fee for the Pro plan is $6, the fee for Regular is $3, so the user is credited $3 for a total sign-up fee of $0.
Step 4: Paid-trial, opt-in enabled, immediate switch (matching trials)
- Purchased a Regular subscription
- In My Account, switched to Pro
- Transaction details:
| Product | Subtotal |
|---|---|
| PR4745 Membership - Pro × 1 | $10.00 / month and a $7.00 sign-up fee |
| Subtotal | $7.00 |
| Total | $7.00 |
| Recurring totals | |
| Subtotal | $10.00 / month |
| Recurring total | $10.00 / month First renewal: June 29, 2026 |
Verdict: ✅ Passes. Total = new_recurring − unconsumed_credit — for $10 recurring with $3 unconsumed, that is $7 (plus transaction fee, if any).
Step 5: Paid-trial, opt-in enabled, mid-trial switch (matching trials)
- Adjusted the trial end, next payment, start, and last payment dates for the subscription purchased in step 4 to 14 days earlier.
- In My Account, switched to Pro
- Transaction details:
| Product | Subtotal |
|---|---|
| PR4745 Membership - Pro × 1 | $10.00 / month and a $8.41 sign-up fee |
| Subtotal | $8.41 |
| Total | $8.41 |
| Recurring totals | |
| Subtotal | $10.00 / month |
| Recurring total | $10.00 / month First renewal: June 29, 2026 |
Verdict: ✅ Passes, with a caveat: the important date here is apparently the date of the last order, not the trial end or next payment dates. Until I backdated the last payment date, the total was still showing $7. Functionally identical in a real-world scenario without manually-edited dates.
Step 6: Paid-trial, opt-in enabled, switch into a non-matching plan
- Switched the same subscription from steps 4 and 5 to the PR4745 Annual Pass product
- Transaction details:
| Product | Subtotal |
|---|---|
| PR4745 Annual Pass × 1 | $50.00 / year and a $0.74 sign-up fee |
| Subtotal | $0.74 |
| Total | $0.74 |
| Recurring totals | |
| Subtotal | $50.00 / year |
| Recurring total | $50.00 / year First renewal: June 15, 2026 |
Verdict: ✅ Passes. This one threw me for a bit of a loop, so I had to break it down to make sure it was correct:
The formula: days_until_next × (new_price_per_day − old_price_per_day), with old_price_per_day = paid_sign_up_fee / days_in_old_cycle.
days_in_old_cycle= 32 →old_price_per_day= $3 / 32 = $0.09375new_price_per_day= $50 / 365.25 = $0.13689days_until_next= 17- charge = 17 × ($0.13689 − $0.09375) = $0.733… → $0.73–0.74
Step 7: Regression — paid-trial, opt-in disabled
- Deleted the
NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEEconstant - Repeated the switch in step 5
- Transaction details:
| Product | Subtotal |
|---|---|
| PR4745 Membership - Pro × 1 | $10.00 / month and a $3.00 sign-up fee |
| Subtotal | $3.00 |
| Total | $3.00 |
| Recurring totals | |
| Subtotal | $10.00 / month |
| Recurring total | $10.00 / month First renewal: June 15, 2026 |
Verdict: ✅ Passes. No change from release behavior. From PR description: "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)."
Step 8: Regression — comped / 100%-discount
- Restored the
NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEEconstant - As a new reader, purchased a Regular subscription with a 100% coupon code
- In My Account, switched to Pro
- Transaction details:
| Product | Subtotal |
|---|---|
| PR4745 Membership - Pro × 1 | $10.00 / month and a $6.00 sign-up fee |
| Subtotal | $6.00 |
| Total | $6.00 |
| Recurring totals | |
| Subtotal | $10.00 / month |
| Recurring total | $10.00 / month First renewal: June 29, 2026 |
Verdict: ✅ Passes. No change from release; filter is a no-op.
Step 9: Regression — genuine free trial
- As a new reader, purchased a PR4745 Free Trial subscription
- In My Account, switched to Pro
- Transaction details:
| Product | Subtotal |
|---|---|
| PR4745 Membership - Pro × 1 | $10.00 / month and a $6.00 sign-up fee |
| Subtotal | $6.00 |
| Total | $6.00 |
| Recurring totals | |
| Subtotal | $10.00 / month |
| Recurring total | $10.00 / month First renewal: June 29, 2026 |
Verdict: ✅ Passes. No change from release; filter is a no-op.
|
Thank you for thoroughly testing this, @dkoo! |
dkoo
left a comment
There was a problem hiding this comment.
Thanks for the changes, @miguelpeixe
|
Hey @miguelpeixe, good job getting this PR merged! 🎉 Now, the Please check if this PR needs to be included in the "Upcoming Changes" and "Release Notes" doc. If it doesn't, simply remove the label. If it does, please add an entry to our shared document, with screenshots and testing instructions if applicable, then remove the label. Thank you! ❤️ |
# [6.42.0](v6.41.3...v6.42.0) (2026-06-01) ### Features * **wc-subscriptions:** recover switch proration when no amount paid ([#4745](#4745)) ([c21009d](c21009d))
|
🎉 This PR is included in version 6.42.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
# [6.42.0](Automattic/newspack-plugin@v6.41.3...v6.42.0) (2026-06-01) ### Features * **wc-subscriptions:** recover switch proration when no amount paid ([#4745](Automattic/newspack-plugin#4745)) ([f9db7a7](Automattic/newspack-plugin@f9db7a7))
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 viaallow_migrated_subscription_switch(). Thewcs_switch_total_paid_for_current_periodfilter 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_cyclefilter clamps the denominator to one billing cycle for migrated subscriptions: without it, WCS's(next_payment - subscription_start) / DAY_IN_SECONDScalculation spans the entire time since the original platform sign-up (often years), which would makeold_price_per_dayartificially 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_dayto0and routes the switch throughextend_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_FEEinwp-config.php, the integration:wcs_switch_total_paid_for_current_period(so the oldprice_per_dayreflects the discount the reader actually paid). This applies on every stepped-pricing switch and feeds the rest of WCS's calculations a correct baseline.wcs_switch_sign_up_feetomax(new_recurring − unconsumed_credit, 0)only when the old and new products share a trial period — 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." Matching trials forcenew_price_per_dayto0, so WCS routes the switch throughextend_prepaid_termand never callswcs_switch_proration_extra_to_pay— the sign-up fee is the only hook that fires.calculate_upgrade_cost, which — given the correctedtotal_paidbaseline above — already equals the prorated remaining-term price minus the prorated unconsumed credit. That is the right charge for switches that inherit the existing next-payment date.Off by default; only opted-in publishers are affected. The opt-in is also exposed as the
newspack_wc_subs_switch_include_signup_feefilter 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-wideapportion_sign_up_feesetting.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:
$5/month + $3 sign-up fee + 1 month trial, Pro at$10/month + $6 sign-up fee + 1 month trial._piano_subscription_idor_stripe_subscription_id). Backdate the subscription start so it predates the next payment by more than one billing cycle (this surfaces thedays_in_old_cycleclamp). 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.define( 'NEWSPACK_WC_SUBS_SWITCH_INCLUDE_SIGNUP_FEE', true );towp-config.php. Purchase Regular (reader pays the$3sign-up fee, enters the trial). Immediately switch to Pro and expand the transaction details. Verify the charge equalsnew_recurring − unconsumed_credit— for$10recurring with$3unconsumed, that is$7(plus transaction fee, if any).trial_endby 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.$10 / 4 weeks, no sign-up fee, no trialproduct, or$50/year, no trial. Verify the upfront charge equals WCS's standard prorated upgrade cost:days_until_next × (new_price_per_day − old_price_per_day), whereold_price_per_day = paid_sign_up_fee / days_in_old_cycle. Confirm the next-payment date is inherited from the old subscription (no reset). This exercises the pass-through path where WCS's ownextra_to_payis correct.define()(or set itfalse) and repeat step 5. Confirm the switch behaves like release does today — the integration does not intervene and WCS's default switching cost applies.Other information: