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.

Python and Node.js Runs on a schedule Safe by default (dry run)
A wooden block spelling switch on a table
Photo by Markus Winkler on Unsplash
The short answer

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.

Plan A, $20/mo day 1 of cycle Switch 1 to Plan B, $40 prorates correctly: charge $13 day 10, same cycle Baseline used: original Plan A price, not Plan B Switch 2 to Plan C, $60 wrong credit or charge Order total wrong does not match Stripe charge
The first switch prorates against the true starting price and is correct. The second switch, in the same cycle, prorates against that same original price again instead of the price the first switch set, so the order total no longer matches Stripe.

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:

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 key insight

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.

Scheduled job once a day List recent switch orders (last 7 days) Rebuild expected proration from orders Totals and Stripe agree? yes, skip no Flag for review note with expected vs actual
The detector rebuilds what the switch should have cost from the subscription's own order history, then only flags a switch order when both the recorded total and the actual Stripe charge disagree with that figure.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

step2.py
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
step2.js
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++;
  }
}
3

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.

step3.py
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()
step3.js
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();
}
4

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.

decide.py
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)
decide.js
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];
}
5

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.

apply.py
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()
apply.js
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.",
    }),
  });
}
6

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.

Run it safe

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_switch_proration.py
"""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-switch-proration.js
/**
 * 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.

test_proration_decide.py
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
proration-decide.test.js
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

Upgrade path

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.

Downgrade path

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.

What good looks like

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:

  1. WooCommerce Subscriptions docs: how switching, upgrading, and downgrading a subscription is prorated. woocommerce.com/document/subscriptions/switching-guide
  2. 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
  3. WooCommerce developer docs: subscription and switch order meta data reference. woocommerce.com/document/subscriptions/develop/functions

On the solution:

  1. Stripe API: retrieve a PaymentIntent to confirm the amount actually collected. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe docs: working in the smallest currency unit to avoid floating point rounding errors. docs.stripe.com/currencies
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes