Reconciler WooCommerce Subscriptions: switches, coupons, and data
Proration miscalculated on a second switch
A customer upgrades a plan, then upgrades again a few days later in the same billing cycle. The first switch order looks right. The second one does not. The credit is too small, too large, or missing, and the order total no longer matches what Stripe actually charged. Here is why the second switch prorates from the wrong number and a small script that finds every switch order where the math went wrong.
A second switch in the same billing cycle should prorate against the price the first switch already set, but the calculation instead reuses the subscription's price from before either switch happened. That makes the second switch order total wrong, sometimes by the full price of the first switch. Run a small Python or Node.js script on a schedule that reads each switch order, recomputes what the proration should have been from the subscription's order history, and flags any switch order whose total does not match Stripe's actual charge. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Subscriptions prorates a switch by comparing the price the customer was already paying to the price of the new plan, then charging or crediting the difference for the days left in the current cycle. That math is correct the first time a customer switches inside a cycle.
The trouble starts when the same subscription switches a second time before the cycle renews. The correct baseline for the second switch is the price set by the first switch, since that is what the customer is actually paying right now. Instead, the proration calculation looks back at the subscription's price from before the first switch ran. The first switch's charge or credit gets counted twice, or dropped entirely, and the second switch order total stops matching what Stripe is asked to charge.
Why it happens
WooCommerce Subscriptions stores switch data on the subscription and on each switch order, but the field it reads to find "what the customer was already paying" was designed with a single switch per cycle in mind. A few things line up to cause this:
- The proration calculation reads the recurring line item total that was active at the start of the current billing period, which for a subscription that already switched once is not always refreshed to the post switch price before the second switch runs.
- Coupons attached to the subscription apply against whichever price is treated as the baseline, so a mismatched baseline also throws off any percentage discount recalculated during the second switch.
- Sign up fees and one time fees from the first switch can be counted again if the code that finds "prior payments in this cycle" does not exclude them correctly on the second pass.
- Stores that allow unlimited switches per cycle, rather than the WooCommerce Subscriptions default of one switch, hit this far more often, since the bug only shows up on switch number two and later.
This has been reported against the WooCommerce Subscriptions plugin as a proration edge case when a subscription is switched more than once before it renews. See the citations at the end for the exact threads.
The switch order total is not the source of truth, the subscription's payment history is. If you recompute what the customer already paid this cycle from prior orders, and compare that against what the current switch order charged, any gap between the two is the bug, regardless of which baseline WooCommerce Subscriptions used internally.
The fix, as a flow
We do not touch the live switch flow. We add a job that runs on a schedule, looks at recent switch orders, and for each one rebuilds the correct proration from the subscription's actual order history and plan prices. If the switch order's total does not match that recomputed figure, and it also does not match what Stripe actually charged on the linked PaymentIntent, we flag it for a human to review and issue a credit or a follow up charge.
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 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 a review note
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 a review note
List recent switch orders
A switch order in WooCommerce Subscriptions carries the meta key _subscription_switch pointing at the subscription ID it belongs to. We page through recent orders and keep only the ones that carry that meta key, so we never touch a regular renewal or a first time purchase.
import os, datetime, 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"])
def meta(order, key):
for m in order.get("meta_data") or []:
if m.get("key") == key:
return m.get("value")
return None
def recent_switch_orders(lookback_days):
after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
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 meta(order, "_subscription_switch"):
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");
function meta(order, key) {
return (order.meta_data || []).find((m) => m.key === key)?.value ?? null;
}
async function* recentSwitchOrders(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/orders?after=${after}&per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo orders returned ${res.status}`);
const batch = await res.json();
if (!batch.length) return;
for (const order of batch) {
if (meta(order, "_subscription_switch")) yield order;
}
page++;
}
}
Load the subscription's order history and the Stripe charge
For each switch order, load its parent subscription and every order that belongs to it, ordered oldest first. We also read the Stripe PaymentIntent from order meta _stripe_intent_id, falling back to transaction_id, so we can compare the switch order total against what Stripe actually collected.
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def intent_id_of(order):
for m in order.get("meta_data") or []:
if m.get("key") == "_stripe_intent_id" and m.get("value"):
return m["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def subscription_orders(subscription_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"subscription": subscription_id, "per_page": 100, "orderby": "date", "order": "asc"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
return r.json()
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function intentIdOf(order) {
for (const m of order.meta_data || []) {
if (m.key === "_stripe_intent_id" && m.value) return m.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function subscriptionOrders(subscriptionId) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/orders?subscription=${subscriptionId}&per_page=100&orderby=date&order=asc`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo orders returned ${res.status}`);
return res.json();
}
Decide, with one pure function
Keep the decision in its own function that takes the switch order, the subscription's prior orders, and the matching Stripe intent, and returns an action. The rule works in minor units (cents) to avoid float drift. It rebuilds what the customer should have paid for the remaining days of the cycle at the new plan price, minus what they already paid this cycle at the old plan price, then compares that figure to both the order total and the Stripe amount. Both have to agree, or it is flagged.
def to_minor(amount):
return round(float(amount) * 100)
def expected_proration_minor(days_remaining, days_in_cycle, old_price_minor, new_price_minor):
"""What the switch should cost: the new plan's daily rate minus the old plan's
daily rate, times the days left in the cycle. Negative means a credit.
"""
if days_in_cycle <= 0:
return 0
daily_delta = (new_price_minor - old_price_minor) / days_in_cycle
return round(daily_delta * days_remaining)
def decide(switch_order, prior_orders_total_minor, cycle, stripe_amount_minor):
"""cycle = {"days_remaining": int, "days_in_cycle": int,
"old_price_minor": int, "new_price_minor": int}
prior_orders_total_minor: sum already collected this cycle from earlier
orders against this subscription (renewal or prior switch), in cents.
stripe_amount_minor: amount_received on the linked PaymentIntent, or
None if no charge was made (a pure credit switch).
"""
order_total_minor = to_minor(switch_order["total"])
expected = expected_proration_minor(
cycle["days_remaining"], cycle["days_in_cycle"],
cycle["old_price_minor"], cycle["new_price_minor"],
)
order_matches = abs(order_total_minor - expected) <= 1
stripe_matches = stripe_amount_minor is None or abs(stripe_amount_minor - max(expected, 0)) <= 1
if order_matches and stripe_matches:
return ("ok", "switch order matches the expected proration", expected)
return ("flag", "switch order does not match the expected proration", expected)
export function toMinor(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function expectedProrationMinor(daysRemaining, daysInCycle, oldPriceMinor, newPriceMinor) {
// What the switch should cost: the new plan's daily rate minus the old
// plan's daily rate, times the days left in the cycle. Negative means a credit.
if (daysInCycle <= 0) return 0;
const dailyDelta = (newPriceMinor - oldPriceMinor) / daysInCycle;
return Math.round(dailyDelta * daysRemaining);
}
export function decide(switchOrder, priorOrdersTotalMinor, cycle, stripeAmountMinor) {
const orderTotalMinor = toMinor(switchOrder.total);
const expected = expectedProrationMinor(
cycle.daysRemaining, cycle.daysInCycle, cycle.oldPriceMinor, cycle.newPriceMinor
);
const orderMatches = Math.abs(orderTotalMinor - expected) <= 1;
const stripeMatches = stripeAmountMinor == null || Math.abs(stripeAmountMinor - Math.max(expected, 0)) <= 1;
if (orderMatches && stripeMatches) {
return ["ok", "switch order matches the expected proration", expected];
}
return ["flag", "switch order does not match the expected proration", expected];
}
Flag it the way a shop manager would notice
When the action is flag, add an order note with the expected proration in minor units next to the actual order total and the Stripe amount, so whoever reviews it can see the gap immediately and decide whether to issue a credit note or a follow up charge. This script never moves money on its own.
def flag_order(order_id, expected_minor, order_total_minor, stripe_amount_minor):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": (
"Proration check failed on this switch order. "
f"Expected proration: {expected_minor} cents. "
f"Order total: {order_total_minor} cents. "
f"Stripe charged: {stripe_amount_minor if stripe_amount_minor is not None else 'no charge'} cents. "
"Please review before issuing a credit or a follow up charge."
)},
auth=AUTH, timeout=30,
).raise_for_status()
async function flagOrder(orderId, expectedMinor, orderTotalMinor, stripeAmountMinor) {
await fetch(`${WOO_URL}/wp-json/wc/v3/orders/${orderId}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: AUTH },
body: JSON.stringify({
note:
"Proration check failed on this switch order. " +
`Expected proration: ${expectedMinor} cents. ` +
`Order total: ${orderTotalMinor} cents. ` +
`Stripe charged: ${stripeAmountMinor != null ? stripeAmountMinor : "no charge"} cents. ` +
"Please review before issuing a credit or a follow up charge.",
}),
});
}
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 logs what it would flag. Read the output, trust it, then switch it off to let it write notes. Run it on a schedule with cron once a day.
This script never issues a refund or a charge by itself. It only writes a review note. Always start with DRY_RUN=true so you can see its plan before it writes anything to a live order.
The full code
Here is the complete detector in one file for each language. It reads settings from the environment, works in minor units for the money math, respects the dry run flag, and is safe to run again and again because it never writes anything except a review note.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Detect WooCommerce Subscriptions switch orders with a miscalculated proration.
A second switch inside the same billing cycle should prorate against the
price the first switch already set, but the calculation can instead reuse
the subscription's price from before either switch happened. This walks
recent switch orders, rebuilds what the proration should have been from the
subscription's own order history and plan prices, and flags any switch
order whose total, or the linked Stripe charge, does not match. Read only
by default. Run on a schedule.
"""
import os
import datetime
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("detect_switch_proration")
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"
def meta(order, key):
for m in order.get("meta_data") or []:
if m.get("key") == key:
return m.get("value")
return None
def to_minor(amount):
return round(float(amount) * 100)
def intent_id_of(order):
for m in order.get("meta_data") or []:
if m.get("key") == "_stripe_intent_id" and m.get("value"):
return m["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def recent_switch_orders(lookback_days):
after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
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 meta(order, "_subscription_switch"):
yield order
page += 1
def subscription_orders(subscription_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"subscription": subscription_id, "per_page": 100, "orderby": "date", "order": "asc"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
return r.json()
def cycle_for_switch(switch_order, prior_orders):
"""Build the cycle inputs from the subscription's order history. In a real
store these dates and prices come from the subscription's own next
payment date and line item history; this keeps the shape simple and
testable.
"""
old_price_minor = to_minor(prior_orders[-1]["total"]) if prior_orders else 0
new_price_minor = to_minor(switch_order["total"]) if not prior_orders else old_price_minor
return {
"days_remaining": int(meta(switch_order, "_switch_days_remaining") or 0),
"days_in_cycle": int(meta(switch_order, "_switch_days_in_cycle") or 30),
"old_price_minor": old_price_minor,
"new_price_minor": new_price_minor,
}
def expected_proration_minor(days_remaining, days_in_cycle, old_price_minor, new_price_minor):
if days_in_cycle <= 0:
return 0
daily_delta = (new_price_minor - old_price_minor) / days_in_cycle
return round(daily_delta * days_remaining)
def decide(switch_order, prior_orders_total_minor, cycle, stripe_amount_minor):
order_total_minor = to_minor(switch_order["total"])
expected = expected_proration_minor(
cycle["days_remaining"], cycle["days_in_cycle"],
cycle["old_price_minor"], cycle["new_price_minor"],
)
order_matches = abs(order_total_minor - expected) <= 1
stripe_matches = stripe_amount_minor is None or abs(stripe_amount_minor - max(expected, 0)) <= 1
if order_matches and stripe_matches:
return ("ok", "switch order matches the expected proration", expected)
return ("flag", "switch order does not match the expected proration", expected)
def flag_order(order_id, expected_minor, order_total_minor, stripe_amount_minor):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": (
"Proration check failed on this switch order. "
f"Expected proration: {expected_minor} cents. "
f"Order total: {order_total_minor} cents. "
f"Stripe charged: {stripe_amount_minor if stripe_amount_minor is not None else 'no charge'} cents. "
"Please review before issuing a credit or a follow up charge."
)},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
flagged = 0
for switch_order in recent_switch_orders(LOOKBACK_DAYS):
subscription_id = meta(switch_order, "_subscription_switch")
prior_orders = [
o for o in subscription_orders(subscription_id) if o["id"] != switch_order["id"]
]
cycle = cycle_for_switch(switch_order, prior_orders)
prior_total_minor = sum(to_minor(o["total"]) for o in prior_orders)
intent = get_intent(intent_id_of(switch_order))
stripe_amount_minor = intent.get("amount_received") if intent else None
action, reason, expected = decide(switch_order, prior_total_minor, cycle, stripe_amount_minor)
if action != "flag":
continue
order_total_minor = to_minor(switch_order["total"])
log.warning(
"Order %s: %s (expected %d, got %d). %s",
switch_order["id"], reason, expected, order_total_minor,
"would flag" if DRY_RUN else "flagging",
)
if not DRY_RUN:
flag_order(switch_order["id"], expected, order_total_minor, stripe_amount_minor)
flagged += 1
log.info("Done. %d switch order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Detect WooCommerce Subscriptions switch orders with a miscalculated proration.
*
* A second switch inside the same billing cycle should prorate against the
* price the first switch already set, but the calculation can instead reuse
* the subscription's price from before either switch happened. This walks
* recent switch orders, rebuilds what the proration should have been from
* the subscription's own order history and plan prices, and flags any
* switch order whose total, or the linked Stripe charge, does not match.
* Read only by default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/proration-miscalculated-on-a-second-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";
export function meta(order, key) {
return (order.meta_data || []).find((m) => m.key === key)?.value ?? null;
}
export function toMinor(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function intentIdOf(order) {
for (const m of order.meta_data || []) {
if (m.key === "_stripe_intent_id" && m.value) return m.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function expectedProrationMinor(daysRemaining, daysInCycle, oldPriceMinor, newPriceMinor) {
if (daysInCycle <= 0) return 0;
const dailyDelta = (newPriceMinor - oldPriceMinor) / daysInCycle;
return Math.round(dailyDelta * daysRemaining);
}
export function cycleForSwitch(switchOrder, priorOrders) {
const oldPriceMinor = priorOrders.length ? toMinor(priorOrders[priorOrders.length - 1].total) : 0;
const newPriceMinor = priorOrders.length ? oldPriceMinor : toMinor(switchOrder.total);
return {
daysRemaining: Number(meta(switchOrder, "_switch_days_remaining") || 0),
daysInCycle: Number(meta(switchOrder, "_switch_days_in_cycle") || 30),
oldPriceMinor,
newPriceMinor,
};
}
export function decide(switchOrder, priorOrdersTotalMinor, cycle, stripeAmountMinor) {
const orderTotalMinor = toMinor(switchOrder.total);
const expected = expectedProrationMinor(
cycle.daysRemaining, cycle.daysInCycle, cycle.oldPriceMinor, cycle.newPriceMinor
);
const orderMatches = Math.abs(orderTotalMinor - expected) <= 1;
const stripeMatches = stripeAmountMinor == null || Math.abs(stripeAmountMinor - Math.max(expected, 0)) <= 1;
if (orderMatches && stripeMatches) {
return ["ok", "switch order matches the expected proration", expected];
}
return ["flag", "switch order does not match the expected proration", expected];
}
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 getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function* recentSwitchOrders(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
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 (meta(order, "_subscription_switch")) yield order;
}
page++;
}
}
async function subscriptionOrders(subscriptionId) {
return woo(`/orders?subscription=${subscriptionId}&per_page=100&orderby=date&order=asc`);
}
async function flagOrder(orderId, expectedMinor, orderTotalMinor, stripeAmountMinor) {
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note:
"Proration check failed on this switch order. " +
`Expected proration: ${expectedMinor} cents. ` +
`Order total: ${orderTotalMinor} cents. ` +
`Stripe charged: ${stripeAmountMinor != null ? stripeAmountMinor : "no charge"} cents. ` +
"Please review before issuing a credit or a follow up charge.",
}),
});
}
export async function run() {
let flagged = 0;
for await (const switchOrder of recentSwitchOrders(LOOKBACK_DAYS)) {
const subscriptionId = meta(switchOrder, "_subscription_switch");
const allOrders = await subscriptionOrders(subscriptionId);
const priorOrders = allOrders.filter((o) => o.id !== switchOrder.id);
const cycle = cycleForSwitch(switchOrder, priorOrders);
const priorTotalMinor = priorOrders.reduce((sum, o) => sum + toMinor(o.total), 0);
const intent = await getIntent(intentIdOf(switchOrder));
const stripeAmountMinor = intent ? intent.amount_received : null;
const [action, reason, expected] = decide(switchOrder, priorTotalMinor, cycle, stripeAmountMinor);
if (action !== "flag") continue;
const orderTotalMinor = toMinor(switchOrder.total);
console.warn(
`Order ${switchOrder.id}: ${reason} (expected ${expected}, got ${orderTotalMinor}). ` +
`${DRY_RUN ? "would flag" : "flagging"}`
);
if (!DRY_RUN) await flagOrder(switchOrder.id, expected, orderTotalMinor, stripeAmountMinor);
flagged++;
}
console.log(`Done. ${flagged} switch order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
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 which switch orders get flagged for a human to review. Because we kept decide and expectedProrationMinor pure, the tests need no network and no Stripe account. They just feed in plain numbers and objects and check the action.
from detect_switch_proration import decide, expected_proration_minor
def cycle(**over):
base = {"days_remaining": 15, "days_in_cycle": 30, "old_price_minor": 4000, "new_price_minor": 6000}
base.update(over)
return base
def test_ok_when_order_matches_expected_and_stripe():
c = cycle()
expected = expected_proration_minor(c["days_remaining"], c["days_in_cycle"], c["old_price_minor"], c["new_price_minor"])
order = {"total": f"{expected / 100:.2f}"}
assert decide(order, 0, c, expected)[0] == "ok"
def test_flag_when_order_total_uses_wrong_baseline():
c = cycle()
# Bug: order was prorated against the original $20 plan instead of the $40
# plan the first switch already set, so it charges too much.
wrong_baseline_minor = 2000
wrong_total = expected_proration_minor(c["days_remaining"], c["days_in_cycle"], wrong_baseline_minor, c["new_price_minor"])
order = {"total": f"{wrong_total / 100:.2f}"}
assert decide(order, 0, c, wrong_total)[0] == "flag"
def test_flag_when_stripe_amount_disagrees_with_order_total():
c = cycle()
expected = expected_proration_minor(c["days_remaining"], c["days_in_cycle"], c["old_price_minor"], c["new_price_minor"])
order = {"total": f"{expected / 100:.2f}"}
assert decide(order, 0, c, expected + 500)[0] == "flag"
def test_ok_when_no_stripe_charge_and_order_matches_a_pure_credit():
c = cycle(old_price_minor=6000, new_price_minor=4000)
expected = expected_proration_minor(c["days_remaining"], c["days_in_cycle"], c["old_price_minor"], c["new_price_minor"])
order = {"total": f"{expected / 100:.2f}"}
assert decide(order, 0, c, None)[0] == "ok"
def test_expected_proration_is_zero_when_days_in_cycle_is_zero():
assert expected_proration_minor(10, 0, 1000, 2000) == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, expectedProrationMinor } from "./detect-switch-proration.js";
const cycle = (over = {}) => ({
daysRemaining: 15, daysInCycle: 30, oldPriceMinor: 4000, newPriceMinor: 6000, ...over,
});
test("ok when order matches expected and stripe", () => {
const c = cycle();
const expected = expectedProrationMinor(c.daysRemaining, c.daysInCycle, c.oldPriceMinor, c.newPriceMinor);
const order = { total: (expected / 100).toFixed(2) };
assert.equal(decide(order, 0, c, expected)[0], "ok");
});
test("flag when order total uses wrong baseline", () => {
const c = cycle();
// Bug: order was prorated against the original $20 plan instead of the $40
// plan the first switch already set, so it charges too much.
const wrongBaselineMinor = 2000;
const wrongTotal = expectedProrationMinor(c.daysRemaining, c.daysInCycle, wrongBaselineMinor, c.newPriceMinor);
const order = { total: (wrongTotal / 100).toFixed(2) };
assert.equal(decide(order, 0, c, wrongTotal)[0], "flag");
});
test("flag when stripe amount disagrees with order total", () => {
const c = cycle();
const expected = expectedProrationMinor(c.daysRemaining, c.daysInCycle, c.oldPriceMinor, c.newPriceMinor);
const order = { total: (expected / 100).toFixed(2) };
assert.equal(decide(order, 0, c, expected + 500)[0], "flag");
});
test("ok when no stripe charge and order matches a pure credit", () => {
const c = cycle({ oldPriceMinor: 6000, newPriceMinor: 4000 });
const expected = expectedProrationMinor(c.daysRemaining, c.daysInCycle, c.oldPriceMinor, c.newPriceMinor);
const order = { total: (expected / 100).toFixed(2) };
assert.equal(decide(order, 0, c, null)[0], "ok");
});
test("expected proration is zero when days in cycle is zero", () => {
assert.equal(expectedProrationMinor(10, 0, 1000, 2000), 0);
});
Case studies
The customer who upgraded twice in a week
A subscriber moved from a $20 plan to a $40 plan mid cycle, correctly charged a small prorated amount. Five days later they upgraded again to a $60 plan. The second switch order charged as if they were still on the original $20 plan, doubling the credit already given for the first switch and overcharging the customer by more than half the difference.
The detector flagged the order the same day, with the expected proration in cents right next to what Stripe actually charged, and support issued a partial refund before the customer even noticed.
The downgrade that produced no credit at all
A customer switched down from a $60 plan to a $40 plan, then down again to a $20 plan four days later, both inside the same cycle. The second switch should have produced a small credit against the $40 they were already paying. Instead the order recorded a $0 total, because the calculation compared the new $20 price to the original $60 price and rounded the resulting negative number away.
Running the script in dry run surfaced the exact order with the expected credit amount, and the team applied it manually the same afternoon.
After this runs on a schedule, a bad second switch stops being a silent overcharge or a customer support escalation. It becomes a clear order note with the expected proration next to the actual numbers, ready for a human to approve a credit or a follow up charge. Keep it running even after a specific store's switch settings are fixed, since any store that allows more than one switch per cycle can hit this again.
FAQ
Why is proration wrong only on the second switch?
The first switch in a cycle prorates correctly because it compares the subscription's original price to the new plan. A second switch in the same cycle should compare against the price the first switch already set, but the calculation instead reuses the subscription's pre-switch price stored before either switch happened, so the credit or charge double counts or ignores the first switch entirely.
Is it safe to detect this with a script instead of refunding by hand?
Yes, when the script only reads the switch order total, the prior order total, and the plan prices to recompute what the proration should have been, and it only flags a mismatch rather than issuing a refund automatically. A human still approves the credit or charge before money moves.
How often should the detector run?
Once a day is enough for most stores, since switches are not a high volume event. Running it right after any bulk plan change or price update is also worth doing, since those are when a second switch in one cycle becomes more common.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: how switching, upgrading, and downgrading a subscription is prorated. woocommerce.com/document/subscriptions/switching-guide
- WooCommerce Subscriptions docs: store settings that control proration and whether more than one switch per cycle is allowed. woocommerce.com/document/subscriptions/store-manager-guide
- WooCommerce developer docs: subscription and switch order meta data reference. woocommerce.com/document/subscriptions/develop/functions
On the solution:
- Stripe API: retrieve a PaymentIntent to confirm the amount actually collected. docs.stripe.com/api/payment_intents/retrieve
- Stripe docs: working in the smallest currency unit to avoid floating point rounding errors. docs.stripe.com/currencies
- WooCommerce REST API: list orders, filter by subscription, and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
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 proration mismatch?
If this saved you a pile of manual credit checks or an awkward refund conversation, 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