Reconciler WooCommerce Subscriptions: switches, coupons, and data

Subscription price drifts from the product

A subscription is supposed to keep the price it was created with, even after the product's price changes later. That part is by design. The real problem shows up when the subscription's own stored total quietly disagrees with what Stripe actually charged on its last renewal, usually left behind by a manual edit, an import, or a currency or tax change. Here is why the two numbers drift apart and a small script that finds every subscription where they no longer agree.

Python and Node.js Runs on a schedule Safe by default (dry run)
Assorted books
Photo by Rita Morais on Unsplash
The short answer

A subscription is meant to keep its own price even after the product's price changes, so that alone is not a bug. The bug is when the subscription's stored total no longer matches what Stripe actually collected on its last renewal. Run a small Python or Node.js script on a schedule that reads each active subscription, looks up the PaymentIntent behind its last billed order from _stripe_intent_id or transaction_id, and reports any subscription whose total disagrees with that charge by more than a cent. Full code, tests, and a dry run guard are below.

The problem in plain words

When a customer subscribes, WooCommerce Subscriptions copies the product's price onto the subscription's own line item. From then on, the subscription bills whatever is stored on that line item, not whatever the product happens to cost today. That is the correct, expected behavior, a price change on the product page should never silently reprice someone who already subscribed.

The trouble starts when something other than a normal price change edits the subscription's stored total directly, an admin editing the order in wp-admin, a bulk import script, a currency migration, or a tax setting change, and the edit does not match what Stripe is actually set up to charge. Now the subscription believes one number, Stripe charges another, and nobody notices until a renewal fails, a customer disputes a charge, or your revenue reports stop adding up.

Subscription created total: $50.00 Stripe billing set up amount: 5000 cents Admin edit or import rewrites the line item Subscription total now says $65.00 Stripe is still set up to charge 5000 cents. Nobody updated it.
The subscription's stored total was edited directly, but Stripe was never told about the new number. The two systems now disagree.

Why it happens

WooCommerce Subscriptions is careful about not repricing existing subscribers when a product's price changes on its own. The drift this guide is about comes from somewhere else touching the subscription's own numbers without keeping Stripe in step:

None of this trips a webhook or throws an error. WooCommerce and Stripe both keep working. They just quietly stop agreeing, and the first person to notice is usually a customer looking at their bank statement, or a shop owner reconciling revenue at the end of the month.

The key insight

Stripe is the source of truth for what a subscription actually bills, because it is the record of what the customer agreed to and already paid. If WooCommerce's stored subscription total does not match Stripe's last successful charge for that subscription, the WooCommerce row is what is wrong, not Stripe, and not necessarily the product's current price either. A drift check compares the subscription against its own last Stripe charge, not against today's product price, so it never punishes a customer for grandfathered pricing.

The fix, as a flow

We do not touch checkout or renewals directly. We add a job that runs once a day, walks every active subscription, and for each one finds the last order it billed and the Stripe PaymentIntent behind that order. If the subscription's total and the amount Stripe actually collected disagree by more than a cent, we report it with an order note so a human can look at it, and only rewrite the line item when explicit auto repair is turned on.

Scheduled job once a day List active subscriptions Read last order's Stripe PaymentIntent Total matches the charge? yes, skip no Report drift note, optional realign
The check compares the subscription's own stored total against what Stripe actually charged last time, and only reports the ones that disagree. Matching subscriptions are left alone, even if they differ from today's product price.

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 and write access to subscriptions and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. The WooCommerce Subscriptions REST API extension must be active so /wp-json/wc/v3/subscriptions is available. 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 DRIFT_TOLERANCE_CENTS="1"
export AUTO_REPAIR="false"   # start safe, only realigns when true
export DRY_RUN="true"        # start safe, change to false to write
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 DRIFT_TOLERANCE_CENTS="1"
export AUTO_REPAIR="false"   // start safe, only realigns when true
export DRY_RUN="true"        // start safe, change to false to write
2

List the active subscriptions

Ask the WooCommerce REST API for subscriptions that are active, on hold, or pending cancellation, since all three are still expected to bill again. We page through all of them the same way we would page through orders.

step2.py
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"])

def active_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold,pending-cancel", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for subscription in batch:
            yield subscription
        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");

async function woo(path) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    headers: { "Content-Type": "application/json", Authorization: AUTH },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* activeSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,on-hold,pending-cancel&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const subscription of batch) yield subscription;
    page++;
  }
}
3

Find the Stripe PaymentIntent behind the last billed order

Read the subscription's last order or parent order, then read the saved PaymentIntent id off it. The WooCommerce Stripe gateway usually stores it in order meta under _stripe_intent_id. Some older orders only have the id in transaction_id, so fall back to that when it looks like a PaymentIntent id.

step3.py
import stripe

def intent_id_of(order):
    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 get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

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;
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function, in minor units

Keep the decision in its own function that takes a subscription, its last order, and the Stripe PaymentIntent, and returns an action. Comparing money in minor units (cents) avoids floating point rounding noise. Skip anything that is not active, has no billed order yet, or has no succeeded PaymentIntent to compare against. Otherwise compare the amounts within a small tolerance.

decide.py
ACTIVE_STATUSES = {"active", "on-hold", "pending-cancel"}
DRIFT_TOLERANCE_CENTS = 1

def line_item_total_minor(subscription):
    return round(float(subscription["total"]) * 100)

def last_charge_amount_minor(intent):
    if intent is None:
        return None
    return intent.get("amount_received", intent.get("amount"))

def decide(subscription, last_order, intent):
    if subscription["status"] not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active")
    if last_order is None:
        return ("skip", "no billed order yet to compare against")
    if intent is None:
        return ("skip", "no matching Stripe PaymentIntent for the last order")
    if intent.get("status") != "succeeded":
        return ("skip", "last PaymentIntent did not succeed")

    sub_total = line_item_total_minor(subscription)
    charged = last_charge_amount_minor(intent)
    if charged is None:
        return ("skip", "Stripe intent has no charged amount")
    if abs(sub_total - charged) <= DRIFT_TOLERANCE_CENTS:
        return ("ok", "subscription total matches the last Stripe charge")
    if sub_total > charged:
        return ("drift_under_charged", "subscription total is higher than what Stripe last billed")
    return ("drift_over_charged", "subscription total is lower than what Stripe last billed")
decide.js
const ACTIVE_STATUSES = new Set(["active", "on-hold", "pending-cancel"]);
const DRIFT_TOLERANCE_CENTS = 1;

export function lineItemTotalMinor(subscription) {
  return Math.round(parseFloat(subscription.total) * 100);
}

export function lastChargeAmountMinor(intent) {
  if (!intent) return null;
  return intent.amount_received ?? intent.amount ?? null;
}

export function decide(subscription, lastOrder, intent) {
  if (!ACTIVE_STATUSES.has(subscription.status)) return ["skip", "subscription is not active"];
  if (!lastOrder) return ["skip", "no billed order yet to compare against"];
  if (!intent) return ["skip", "no matching Stripe PaymentIntent for the last order"];
  if (intent.status !== "succeeded") return ["skip", "last PaymentIntent did not succeed"];

  const subTotal = lineItemTotalMinor(subscription);
  const charged = lastChargeAmountMinor(intent);
  if (charged === null) return ["skip", "Stripe intent has no charged amount"];
  if (Math.abs(subTotal - charged) <= DRIFT_TOLERANCE_CENTS) {
    return ["ok", "subscription total matches the last Stripe charge"];
  }
  if (subTotal > charged) {
    return ["drift_under_charged", "subscription total is higher than what Stripe last billed"];
  }
  return ["drift_over_charged", "subscription total is lower than what Stripe last billed"];
}
5

Report it, and only realign when explicitly asked

When the action shows drift, add an order note on the subscription so the shop manager sees it at a glance. Never rewrite the price automatically by default. If AUTO_REPAIR is turned on, realign the subscription's line item to what Stripe actually collected, since that is the number the customer already agreed to and paid, not today's product price.

apply.py
def report(subscription, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{subscription['id']}/notes",
        json={"note": f"Price drift check: {reason}. The stored subscription total no "
                      f"longer matches what Stripe last charged for it. Please review "
                      f"before the next renewal."},
        auth=AUTH, timeout=30,
    ).raise_for_status()

def repair(subscription, intent):
    charged = last_charge_amount_minor(intent)
    new_total = f"{charged / 100:.2f}"
    line_items = subscription.get("line_items") or []
    if not line_items:
        return
    first_item_id = line_items[0]["id"]
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
        json={"line_items": [{"id": first_item_id, "subtotal": new_total, "total": new_total}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function report(subscription, reason) {
  await woo(`/orders/${subscription.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Price drift check: ${reason}. The stored subscription total no longer ` +
            `matches what Stripe last charged for it. Please review before the next renewal.`,
    }),
  });
}

async function repair(subscription, intent) {
  const charged = lastChargeAmountMinor(intent);
  const newTotal = (charged / 100).toFixed(2);
  const lineItems = subscription.line_items || [];
  if (!lineItems.length) return;
  const firstItemId = lineItems[0].id;
  await woo(`/subscriptions/${subscription.id}`, {
    method: "PUT",
    body: JSON.stringify({
      line_items: [{ id: firstItemId, subtotal: newTotal, total: newTotal }],
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. 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 order notes. Only set AUTO_REPAIR once you have reviewed a batch of drifted subscriptions by hand and are confident the realign is correct for your store. Run it on a schedule with cron once a day.

Run it safe

Always start with DRY_RUN=true and AUTO_REPAIR=false. This script touches recurring billing, so you want to see its plan before it acts, and you want a human to confirm the realign direction before it rewrites anything.

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 never touches a subscription that already agrees with Stripe.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

subscription_price_drift.py
"""Find WooCommerce Subscriptions whose recurring price no longer matches
what Stripe actually billed for it.

A product's regular price is changed, but every subscription already
running keeps billing at the price it was created with. That is normal
and expected for the customer's own subscription. The bug this script
catches is a subscription whose stored line item silently disagrees with
what Stripe actually collected on its last renewal, which usually means
an admin edit, an import, or a currency or tax change left the row
inconsistent.

Read only by default. Run on a schedule, for example once a day.
"""
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("subscription_price_drift")

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"])
DRIFT_TOLERANCE_CENTS = int(os.environ.get("DRIFT_TOLERANCE_CENTS", "1"))
AUTO_REPAIR = os.environ.get("AUTO_REPAIR", "false").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ACTIVE_STATUSES = {"active", "on-hold", "pending-cancel"}


def intent_id_of(order):
    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 line_item_total_minor(subscription):
    return round(float(subscription["total"]) * 100)


def last_charge_amount_minor(intent):
    if intent is None:
        return None
    return intent.get("amount_received", intent.get("amount"))


def decide(subscription, last_order, intent):
    if subscription["status"] not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active")
    if last_order is None:
        return ("skip", "no billed order yet to compare against")
    if intent is None:
        return ("skip", "no matching Stripe PaymentIntent for the last order")
    if intent.get("status") != "succeeded":
        return ("skip", "last PaymentIntent did not succeed")

    sub_total = line_item_total_minor(subscription)
    charged = last_charge_amount_minor(intent)
    if charged is None:
        return ("skip", "Stripe intent has no charged amount")

    if abs(sub_total - charged) <= DRIFT_TOLERANCE_CENTS:
        return ("ok", "subscription total matches the last Stripe charge")
    if sub_total > charged:
        return ("drift_under_charged", "subscription total is higher than what Stripe last billed")
    return ("drift_over_charged", "subscription total is lower than what Stripe last billed")


def is_drift(action):
    return action in ("drift_under_charged", "drift_over_charged")


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 active_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold,pending-cancel", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for subscription in batch:
            yield subscription
        page += 1


def get_last_order(subscription):
    related = subscription.get("last_order_id") or subscription.get("parent_id")
    if not related:
        return None
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{related}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def report(subscription, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{subscription['id']}/notes",
        json={"note": f"Price drift check: {reason}. The stored subscription total no "
                      f"longer matches what Stripe last charged for it. Please review "
                      f"before the next renewal."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def repair(subscription, intent):
    charged = last_charge_amount_minor(intent)
    new_total = f"{charged / 100:.2f}"
    line_items = subscription.get("line_items") or []
    if not line_items:
        return
    first_item_id = line_items[0]["id"]
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
        json={"line_items": [{"id": first_item_id, "subtotal": new_total, "total": new_total}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    drifted = 0
    for subscription in active_subscriptions():
        last_order = get_last_order(subscription)
        intent = get_intent(intent_id_of(last_order)) if last_order else None
        action, reason = decide(subscription, last_order, intent)
        if not is_drift(action):
            continue
        drifted += 1
        log.warning(
            "Subscription %s: %s. %s",
            subscription["id"], reason, "would report" if DRY_RUN else "reporting",
        )
        if not DRY_RUN:
            report(subscription, reason)
            if AUTO_REPAIR:
                repair(subscription, intent)
    log.info("Done. %d subscription(s) %s.", drifted, "to report" if DRY_RUN else "reported")


if __name__ == "__main__":
    run()
subscription-price-drift.js
/**
 * Find WooCommerce Subscriptions whose recurring price no longer matches
 * what Stripe actually billed for it.
 *
 * A product's regular price is changed, but every subscription already
 * running keeps billing at the price it was created with. That is normal
 * and expected for the customer's own subscription. The bug this script
 * catches is a subscription whose stored line item silently disagrees with
 * what Stripe actually collected on its last renewal, which usually means
 * an admin edit, an import, or a currency or tax change left the row
 * inconsistent.
 *
 * Read only by default. Run on a schedule, for example once a day.
 */
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 DRIFT_TOLERANCE_CENTS = Number(process.env.DRIFT_TOLERANCE_CENTS || 1);
const AUTO_REPAIR = (process.env.AUTO_REPAIR || "false").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ACTIVE_STATUSES = new Set(["active", "on-hold", "pending-cancel"]);

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 lineItemTotalMinor(subscription) {
  return Math.round(parseFloat(subscription.total) * 100);
}

export function lastChargeAmountMinor(intent) {
  if (!intent) return null;
  return intent.amount_received ?? intent.amount ?? null;
}

export function isDrift(action) {
  return action === "drift_under_charged" || action === "drift_over_charged";
}

export function decide(subscription, lastOrder, intent) {
  if (!ACTIVE_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active"];
  }
  if (!lastOrder) {
    return ["skip", "no billed order yet to compare against"];
  }
  if (!intent) {
    return ["skip", "no matching Stripe PaymentIntent for the last order"];
  }
  if (intent.status !== "succeeded") {
    return ["skip", "last PaymentIntent did not succeed"];
  }

  const subTotal = lineItemTotalMinor(subscription);
  const charged = lastChargeAmountMinor(intent);
  if (charged === null) {
    return ["skip", "Stripe intent has no charged amount"];
  }

  if (Math.abs(subTotal - charged) <= DRIFT_TOLERANCE_CENTS) {
    return ["ok", "subscription total matches the last Stripe charge"];
  }
  if (subTotal > charged) {
    return ["drift_under_charged", "subscription total is higher than what Stripe last billed"];
  }
  return ["drift_over_charged", "subscription total is lower than what Stripe last billed"];
}

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 getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function* activeSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,on-hold,pending-cancel&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const subscription of batch) yield subscription;
    page++;
  }
}

async function getLastOrder(subscription) {
  const related = subscription.last_order_id || subscription.parent_id;
  if (!related) return null;
  return woo(`/orders/${related}`);
}

async function report(subscription, reason) {
  await woo(`/orders/${subscription.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Price drift check: ${reason}. The stored subscription total no longer ` +
            `matches what Stripe last charged for it. Please review before the next renewal.`,
    }),
  });
}

async function repair(subscription, intent) {
  const charged = lastChargeAmountMinor(intent);
  const newTotal = (charged / 100).toFixed(2);
  const lineItems = subscription.line_items || [];
  if (!lineItems.length) return;
  const firstItemId = lineItems[0].id;
  await woo(`/subscriptions/${subscription.id}`, {
    method: "PUT",
    body: JSON.stringify({
      line_items: [{ id: firstItemId, subtotal: newTotal, total: newTotal }],
    }),
  });
}

export async function run() {
  let drifted = 0;
  for await (const subscription of activeSubscriptions()) {
    const lastOrder = await getLastOrder(subscription);
    const intent = lastOrder ? await getIntent(intentIdOf(lastOrder)) : null;
    const [action, reason] = decide(subscription, lastOrder, intent);
    if (!isDrift(action)) continue;
    drifted++;
    console.warn(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would report" : "reporting"}`);
    if (!DRY_RUN) {
      await report(subscription, reason);
      if (AUTO_REPAIR) await repair(subscription, intent);
    }
  }
  console.log(`Done. ${drifted} subscription(s) ${DRY_RUN ? "to report" : "reported"}.`);
}

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 subscriptions get reported, and which ones get their price rewritten when auto repair is on. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_pricedrift_decide.py
from subscription_price_drift import decide, intent_id_of, line_item_total_minor, is_drift


def subscription(**over):
    base = {"id": 501, "status": "active", "total": "50.00"}
    base.update(over)
    return base


def intent(**over):
    base = {"status": "succeeded", "amount_received": 5000}
    base.update(over)
    return base


def order(**over):
    base = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_1"}], "transaction_id": ""}
    base.update(over)
    return base


def test_ok_when_total_matches_last_charge():
    action, _ = decide(subscription(), order(), intent())
    assert action == "ok"
    assert not is_drift(action)


def test_drift_when_subscription_total_is_higher():
    action, _ = decide(subscription(total="65.00"), order(), intent())
    assert action == "drift_under_charged"
    assert is_drift(action)


def test_drift_when_subscription_total_is_lower():
    action, _ = decide(subscription(total="35.00"), order(), intent())
    assert action == "drift_over_charged"
    assert is_drift(action)


def test_skip_when_subscription_not_active():
    action, reason = decide(subscription(status="cancelled"), order(), intent())
    assert action == "skip"
    assert "not active" in reason
subscription-price-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, isDrift } from "./subscription-price-drift.js";

const subscription = (over = {}) => ({ id: 501, status: "active", total: "50.00", ...over });
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });
const order = (over = {}) => ({
  meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }],
  transaction_id: "",
  ...over,
});

test("ok when total matches last charge", () => {
  const [action] = decide(subscription(), order(), intent());
  assert.equal(action, "ok");
  assert.equal(isDrift(action), false);
});

test("drift when subscription total is higher", () => {
  const [action] = decide(subscription({ total: "65.00" }), order(), intent());
  assert.equal(action, "drift_under_charged");
  assert.equal(isDrift(action), true);
});

test("skip when subscription not active", () => {
  const [action, reason] = decide(subscription({ status: "cancelled" }), order(), intent());
  assert.equal(action, "skip");
  assert.match(reason, /not active/);
});

Case studies

Bulk price import

The CSV that rewrote totals but not Stripe

A store ran a bulk import to correct a batch of subscriptions after a tax mistake, updating each subscription's total directly in WooCommerce. Nobody realized Stripe's own billing agreements for those subscriptions still reflected the pre-correction amount, since the import never called Stripe at all.

The drift script found forty two subscriptions where the stored total and the last Stripe charge disagreed by more than a few cents each, and a couple were off by several dollars from a copy-paste error in the CSV. The team reviewed the report and fixed the handful that mattered before the next renewal batch went out.

Admin edit

The manual discount that never reached Stripe

A support agent gave a loyal customer a permanent discount by editing the subscription's line item directly in wp-admin. It looked correct in the WooCommerce admin screen, showing the new lower total, but the customer's card kept getting charged the original higher amount every renewal.

The drift check flagged the mismatch on its very first scheduled run, days before the customer noticed and before it turned into a refund and an apology email.

What good looks like

After this runs on a schedule, a subscription's own math and Stripe's billing agreement never quietly drift apart for more than a day without someone knowing about it. Keep it running even on a quiet store, because admin edits and imports happen occasionally and this is the cheapest way to catch the ones that slipped.

FAQ

Why does a WooCommerce subscription bill a different amount than the product now costs?

That part is usually correct. A subscription keeps the price it was created with even after the product's price changes. The real bug is when the subscription's own stored total does not match what Stripe actually charged on its last renewal, which points to a manual edit, an import, or a tax or currency change that left the row inconsistent.

Should a drift script change the price to match the current product?

No. It should realign the subscription to what Stripe actually collected, since that is the amount the customer agreed to and already paid. Changing it to today's product price would silently raise or lower what a customer is billed without their consent.

How often should a price drift check run?

Once a day is plenty. Price drift is a slow-moving problem caused by edits and imports, not a live payment race, so there is no benefit to running it more often than that.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how a subscription's recurring price stays fixed once purchased, independent of later product price changes. woocommerce.com/document/subscriptions/store-manager-guide
  2. WooCommerce Subscriptions REST API reference for subscription fields including line items and totals. woocommerce.github.io/subscriptions-rest-api-docs
  3. Stripe docs: a PaymentIntent's amount_received as the authoritative record of what was actually collected. docs.stripe.com/api/payment_intents/object

On the solution:

  1. Stripe API: retrieve a PaymentIntent by id to confirm status and amount. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: update an order and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce Subscriptions docs: editing a subscription's line items safely through the API. 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.

Contact me on LinkedIn

Did this catch a drifted subscription?

If this saved you a customer dispute or a revenue reconciliation headache, 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