Repair WooCommerce Subscriptions: switches, coupons, and data
Recurring coupon dropped on switch
A customer had a recurring discount on their plan for months. Then they upgraded, downgraded, or moved to a different variation, and the discount quietly stopped applying. The switch order looks fine. The new subscription looks fine. But every renewal after the switch bills the full price, and nobody notices until the customer does. Here is why the switch drops the coupon and a small script that finds it and puts it back.
A subscription switch rebuilds the line items on the resulting subscription, and that rebuild does not carry over a recurring coupon that was applied before the switch. Run a small Python or Node.js script on a schedule that compares the recurring coupon codes on the subscription before and after a switch, confirms the switch order actually has a succeeded Stripe payment behind it, and reapplies any coupon the switch dropped. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Subscriptions lets a customer switch their plan: upgrade to a bigger tier, downgrade to a smaller one, or move between variations of the same product. Behind the scenes, a switch is really a special order that closes out the old line items and opens new ones on the same subscription, then prorates the difference for the current term.
That rebuild step is where the trouble starts. A recurring coupon, one that was set to discount every renewal and not just the first payment, lives on the subscription as its own coupon line. When the switch rewrites the line items, it does not always re-run the logic that reattaches an existing recurring coupon to the new totals. The coupon record can be quietly dropped, and the next renewal is calculated at full price with no error, no warning, and no failed payment to draw attention to it.
Why it happens
The WooCommerce Subscriptions switch process is built around swapping products and recalculating totals, not around preserving every coupon relationship. A few concrete reasons the recurring coupon does not survive:
- The switch creates fresh subscription line items for the new product or variation, and the coupon line from before the switch is tied to the old line items, so it is not automatically reattached.
- Some coupons are scoped to a specific product. If the switch moves the customer to a different product or variation, WooCommerce can decide the coupon no longer applies to anything on the new subscription and drops it during recalculation.
- A custom checkout, a page builder form, or a third-party plugin handles the switch request directly through the REST API and never resends the coupon code that was active before, so it is left off the rebuilt subscription entirely.
- The store has a mix of one-time and recurring coupon logic in a custom function that was written for new subscriptions and was never tested against a switch.
This is a known gap in how switches recalculate totals, and it shows up most on stores that give long-time customers a loyalty or retention discount, then let them move between plans.
The subscription's own coupon lines are the source of truth for what discount should apply to every renewal. If a recurring coupon was present right before a switch and is missing right after it, and the switch itself was actually paid for, the subscription is wrong, not the coupon. A reconciler is a safety net that runs on a schedule, compares before and after, and puts the coupon back.
The fix, as a flow
We do not change how the switch works. We add a job that runs on a schedule, looks at recent switch orders, and for each one checks whether the subscription had a recurring coupon before the switch that is missing after it. If the switch order itself was paid for in Stripe, we reapply the dropped coupon to the subscription and leave a note, the same way a correct switch would have kept it.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and subscriptions. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" // start safe, change to false to write
Find recent switch orders
WooCommerce Subscriptions tags a switch order with meta key _subscription_switch. We page through recent orders and keep only the ones carrying that flag, so we never touch a normal renewal or a plain new-subscription order by mistake.
import os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
SWITCH_ORDER_KEY = "_subscription_switch"
def switch_orders(after):
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
if any(m.get("key") == SWITCH_ORDER_KEY for m in order.get("meta_data") or []):
yield order
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const SWITCH_ORDER_KEY = "_subscription_switch";
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* switchOrders(after) {
let page = 1;
while (true) {
const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) {
if ((order.meta_data || []).some((m) => m.key === SWITCH_ORDER_KEY)) yield order;
}
page++;
}
}
Read the recurring coupons, before and after
When a switch is requested, we snapshot the recurring coupon codes that were on the subscription onto the switch order's own meta, under key _switch_recurring_coupons. After the switch finishes, the current recurring coupons live on the subscription's coupon_lines, the same way they do on an order. Comparing the two lists tells us exactly what, if anything, was dropped.
RECURRING_COUPON_META = "_switch_recurring_coupons"
def before_codes_of(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == RECURRING_COUPON_META and meta.get("value"):
return sorted(meta["value"])
return []
def recurring_coupon_codes(subscription):
return sorted(
line["code"]
for line in subscription.get("coupon_lines") or []
if line.get("code")
)
def get_subscription(subscription_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
const RECURRING_COUPON_META = "_switch_recurring_coupons";
function beforeCodesOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === RECURRING_COUPON_META && meta.value) return [...meta.value].sort();
}
return [];
}
function recurringCouponCodes(subscription) {
return (subscription.coupon_lines || [])
.map((line) => line.code)
.filter(Boolean)
.sort();
}
async function getSubscription(subscriptionId) {
return woo(`/subscriptions/${subscriptionId}`);
}
Decide, with one pure function
Keep the decision in its own function that takes the coupon codes from before the switch, the codes from after, and the Stripe PaymentIntent for the switch order, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If nothing was dropped, skip it. If something was dropped but the switch was never actually paid for, skip it too, since there is nothing to repair yet. Otherwise, reapply the dropped coupon.
def decide(before_codes, after_codes, switch_intent):
dropped = sorted(set(before_codes) - set(after_codes))
if not dropped:
return ("skip", "no coupon was dropped", dropped)
if switch_intent is None:
return ("skip", "no Stripe payment found for the switch order", dropped)
if switch_intent.get("status") != "succeeded":
return ("skip", "switch payment did not succeed, nothing to repair yet", dropped)
return ("reapply", "switch succeeded but a recurring coupon was dropped", dropped)
export function decide(beforeCodes, afterCodes, switchIntent) {
const afterSet = new Set(afterCodes);
const dropped = beforeCodes.filter((code) => !afterSet.has(code)).sort();
if (dropped.length === 0) return ["skip", "no coupon was dropped", dropped];
if (!switchIntent) return ["skip", "no Stripe payment found for the switch order", dropped];
if (switchIntent.status !== "succeeded") {
return ["skip", "switch payment did not succeed, nothing to repair yet", dropped];
}
return ["reapply", "switch succeeded but a recurring coupon was dropped", dropped];
}
Reapply the dropped coupon and note it
When the action is reapply, call the subscription's coupon endpoint with the dropped codes so WooCommerce Subscriptions recalculates the recurring total the same way it would if the coupon had never left. Then add a subscription note so the shop manager can see it was repaired and why.
def reapply_coupons(subscription_id, codes):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/coupons",
json={"coupons": [{"code": code} for code in codes]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Reapplied recurring coupon(s) {', '.join(codes)} that the last "
f"plan switch dropped. Applied by the coupon reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
async function reapplyCoupons(subscriptionId, codes) {
await woo(`/subscriptions/${subscriptionId}/coupons`, {
method: "POST",
body: JSON.stringify({ coupons: codes.map((code) => ({ code })) }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reapplied recurring coupon(s) ${codes.join(", ")} that the last plan switch ` +
`dropped. Applied by the coupon reconciler.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day.
Always start with DRY_RUN=true. This script writes to real subscriptions and changes what a customer is billed on the next renewal, so you want to see its plan before it acts. Once the report looks right for a few days, turn it off.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a subscription that has not actually had a recurring coupon dropped.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Reapply a recurring coupon that a subscription switch dropped.
When a customer switches a subscription (upgrade, downgrade, or a plan change),
WooCommerce Subscriptions builds a new set of line items for the resulting
subscription but does not carry over a recurring coupon that was active on the
old one. The switch order itself can look correct, since the one-time proration
is right, but every renewal after the switch bills the full price. This walks
recent switch orders, compares the recurring coupons on the parent subscription
before and after, and reapplies any recurring coupon the switch dropped. It
also cross-checks the Stripe PaymentIntent tied to the switch order so we only
touch subscriptions where the switch itself actually succeeded. Safe by
default. Run on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reapply_switch_coupon")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SWITCH_ORDER_KEY = "_subscription_switch"
RECURRING_COUPON_META = "_switch_recurring_coupons"
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def recurring_coupon_codes(subscription):
"""Coupon codes on a subscription that apply to recurring totals, not just the
one-time switch proration. WooCommerce Subscriptions stores every coupon on
the `coupon_lines` array the same way an order does."""
return sorted(
line["code"]
for line in subscription.get("coupon_lines") or []
if line.get("code")
)
def decide(before_codes, after_codes, switch_intent):
"""Pure decision: should we reapply a dropped recurring coupon to the
subscription that came out of a switch?
before_codes: recurring coupon codes on the subscription before the switch.
after_codes: recurring coupon codes on the subscription after the switch.
switch_intent: the Stripe PaymentIntent dict for the switch order, or None.
"""
dropped = sorted(set(before_codes) - set(after_codes))
if not dropped:
return ("skip", "no coupon was dropped", dropped)
if switch_intent is None:
return ("skip", "no Stripe payment found for the switch order", dropped)
if switch_intent.get("status") != "succeeded":
return ("skip", "switch payment did not succeed, nothing to repair yet", dropped)
return ("reapply", "switch succeeded but a recurring coupon was dropped", dropped)
def get_switch_intent(order):
"""Confirm the switch order itself has a real charge behind it before we
touch the subscription's coupons."""
intent_id = intent_id_of(order)
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def get_subscription(subscription_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}", auth=AUTH, timeout=30
)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def get_order(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def switch_orders():
"""Orders created in the lookback window that WooCommerce Subscriptions
tagged as a switch order."""
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
if any(m.get("key") == SWITCH_ORDER_KEY for m in order.get("meta_data") or []):
yield order
page += 1
def before_codes_of(order):
"""The recurring coupon codes that were on the subscription before the
switch. WooCommerce Subscriptions snapshots them onto the switch order
meta at the moment the switch is requested."""
for meta in order.get("meta_data") or []:
if meta.get("key") == RECURRING_COUPON_META and meta.get("value"):
return sorted(meta["value"])
return []
def reapply_coupons(subscription_id, codes):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/coupons",
json={"coupons": [{"code": code} for code in codes]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Reapplied recurring coupon(s) {', '.join(codes)} that the last "
f"plan switch dropped. Applied by the coupon reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
for order in switch_orders():
subscription_id = order.get("subscription_renewal") or order.get("id")
subscription = get_subscription(subscription_id)
if subscription is None:
log.warning("Switch order %s points to missing subscription %s", order["id"], subscription_id)
continue
before = before_codes_of(order)
after = recurring_coupon_codes(subscription)
intent = get_switch_intent(order)
action, reason, dropped = decide(before, after, intent)
if action == "skip":
continue
log.info(
"Subscription %s: %s (%s). %s",
subscription_id, reason, ", ".join(dropped), "would reapply" if DRY_RUN else "reapplying",
)
if not DRY_RUN:
reapply_coupons(subscription_id, dropped)
fixed += 1
log.info("Done. %d subscription(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Reapply a recurring coupon that a subscription switch dropped.
*
* When a customer switches a subscription (upgrade, downgrade, or a plan
* change), WooCommerce Subscriptions builds a new set of line items for the
* resulting subscription but does not carry over a recurring coupon that was
* active on the old one. The switch order itself can look correct, since the
* one-time proration is right, but every renewal after the switch bills the
* full price. This walks recent switch orders, compares the recurring coupons
* on the parent subscription before and after, and reapplies any recurring
* coupon the switch dropped. It also cross-checks the Stripe PaymentIntent
* tied to the switch order so we only touch subscriptions where the switch
* itself actually succeeded. Safe by default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/recurring-coupon-dropped-on-switch/
*/
import Stripe from "stripe";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SWITCH_ORDER_KEY = "_subscription_switch";
const RECURRING_COUPON_META = "_switch_recurring_coupons";
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function recurringCouponCodes(subscription) {
return (subscription.coupon_lines || [])
.map((line) => line.code)
.filter(Boolean)
.sort();
}
export function beforeCodesOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === RECURRING_COUPON_META && meta.value) return [...meta.value].sort();
}
return [];
}
export function decide(beforeCodes, afterCodes, switchIntent) {
const afterSet = new Set(afterCodes);
const dropped = beforeCodes.filter((code) => !afterSet.has(code)).sort();
if (dropped.length === 0) return ["skip", "no coupon was dropped", dropped];
if (!switchIntent) return ["skip", "no Stripe payment found for the switch order", dropped];
if (switchIntent.status !== "succeeded") {
return ["skip", "switch payment did not succeed, nothing to repair yet", dropped];
}
return ["reapply", "switch succeeded but a recurring coupon was dropped", dropped];
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function getSwitchIntent(order) {
const intentId = intentIdOf(order);
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function* switchOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const order of batch) {
if ((order.meta_data || []).some((m) => m.key === SWITCH_ORDER_KEY)) yield order;
}
page++;
}
}
async function reapplyCoupons(subscriptionId, codes) {
await woo(`/subscriptions/${subscriptionId}/coupons`, {
method: "POST",
body: JSON.stringify({ coupons: codes.map((code) => ({ code })) }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reapplied recurring coupon(s) ${codes.join(", ")} that the last plan switch ` +
`dropped. Applied by the coupon reconciler.`,
}),
});
}
export async function run() {
let fixed = 0;
for await (const order of switchOrders()) {
const subscriptionId = order.subscription_renewal || order.id;
const subscription = await woo(`/subscriptions/${subscriptionId}`);
if (!subscription) {
console.warn(`Switch order ${order.id} points to missing subscription ${subscriptionId}`);
continue;
}
const before = beforeCodesOf(order);
const after = recurringCouponCodes(subscription);
const intent = await getSwitchIntent(order);
const [action, reason, dropped] = decide(before, after, intent);
if (action === "skip") continue;
console.log(
`Subscription ${subscriptionId}: ${reason} (${dropped.join(", ")}). ` +
`${DRY_RUN ? "would reapply" : "reapplying"}`
);
if (!DRY_RUN) await reapplyCoupons(subscriptionId, dropped);
fixed++;
}
console.log(`Done. ${fixed} subscription(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a live subscription's billing gets changed. Because we kept decide pure, the test needs no network, no Stripe account, and no WooCommerce store. It just feeds in plain values and checks the action.
from reapply_switch_coupon import decide, before_codes_of, recurring_coupon_codes, intent_id_of
def intent(**over):
base = {"status": "succeeded", "id": "pi_1"}
base.update(over)
return base
def test_reapply_when_coupon_dropped_and_switch_succeeded():
action, reason, dropped = decide(["vip10"], [], intent())
assert action == "reapply"
assert dropped == ["vip10"]
def test_skip_when_no_coupon_was_dropped():
action, reason, dropped = decide(["vip10"], ["vip10"], intent())
assert action == "skip"
assert dropped == []
def test_skip_when_no_stripe_payment_found():
action, reason, dropped = decide(["vip10"], [], None)
assert action == "skip"
def test_skip_when_switch_payment_not_succeeded():
action, reason, dropped = decide(["vip10"], [], intent(status="requires_action"))
assert action == "skip"
def test_multiple_dropped_coupons_are_all_reported():
action, reason, dropped = decide(["vip10", "loyalty5"], [], intent())
assert action == "reapply"
assert dropped == ["loyalty5", "vip10"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./reapply-switch-coupon.js";
const intent = (over = {}) => ({ status: "succeeded", id: "pi_1", ...over });
test("reapply when coupon dropped and switch succeeded", () => {
const [action, , dropped] = decide(["vip10"], [], intent());
assert.equal(action, "reapply");
assert.deepEqual(dropped, ["vip10"]);
});
test("skip when no coupon was dropped", () => {
const [action, , dropped] = decide(["vip10"], ["vip10"], intent());
assert.equal(action, "skip");
assert.deepEqual(dropped, []);
});
test("skip when no Stripe payment found", () => {
const [action] = decide(["vip10"], [], null);
assert.equal(action, "skip");
});
test("skip when switch payment did not succeed", () => {
const [action] = decide(["vip10"], [], intent({ status: "requires_action" }));
assert.equal(action, "skip");
});
test("multiple dropped coupons are all reported", () => {
const [action, , dropped] = decide(["vip10", "loyalty5"], [], intent());
assert.equal(action, "reapply");
assert.deepEqual(dropped, ["loyalty5", "vip10"]);
});
Case studies
The long-time customer who quietly lost their rate
A store gave a customer of three years a permanent 10 percent recurring coupon as a retention offer. The customer later upgraded to a bigger plan through the account page. The upgrade order was paid correctly, but the coupon line never made it onto the new subscription.
Two renewals went out at full price before the customer wrote in confused about the change. The reconciler, run afterward in dry run, found the exact subscription and reapplied the coupon so the next renewal matched what they were promised.
The plan-scoped coupon that did not follow the variation change
A SaaS-style store sold a product with monthly and annual variations, each eligible for a limited-time recurring coupon. When customers switched from monthly to annual mid-term, the coupon was scoped to the monthly variation and was dropped on the switch, even though the annual price should have honored it too.
Running the script once a day caught every affected switch within twenty-four hours, well before the next annual renewal, and reapplied the coupon with a clear note on each subscription for support to reference.
After this runs on a schedule, a subscription switch no longer means a silent price increase for the customer. The worst case becomes a short delay of a day before the reconciler notices and puts the coupon back. Keep it running even after you patch the checkout flow, since a plugin update or a new switch path can reintroduce the same gap.
FAQ
Why did my customer's recurring coupon disappear after a subscription switch?
WooCommerce Subscriptions rebuilds the line items on the subscription during a switch, and that rebuild does not carry over a recurring coupon that was applied before the switch. The one-time switch order can be correct while every renewal after it bills full price.
Is it safe to reapply a coupon to a live subscription with a script?
Yes, when the script only reapplies a coupon that was present before the switch and missing after it, and it first confirms the switch order has a succeeded Stripe payment behind it. Start in dry run mode to review the list before it writes.
How often should the coupon reconciler run?
Once a day is enough for most stores, since switches are not a high frequency event. Running it more often does no harm, because it only acts on subscriptions where a recurring coupon was actually dropped.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: how switching a subscription recalculates line items and totals. woocommerce.com/document/subscriptions/switching-guide
- WooCommerce Subscriptions docs: how coupons apply to recurring totals versus a one-time total. woocommerce.com/document/subscriptions/store-manager-guide
- WooCommerce developer docs: coupon data and coupon line items on orders and subscriptions. developer.woocommerce.com/docs/category/coupons
On the solution:
- WooCommerce REST API: Subscriptions endpoints, including reading and updating a subscription. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent to confirm a charge succeeded. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce Subscriptions developer docs: order and subscription meta keys used during a switch. woocommerce.com/document/subscriptions/develop/functions
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your dropped coupon?
If this saved you an awkward refund conversation or a pile of support tickets, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi