Reconciler WooCommerce Subscriptions: switches, coupons, and data

Active-subscriber counts wrong in reports

The dashboard says one number. A count of real subscriptions says another. Nobody changed anything on purpose, yet the "Active subscribers" widget and the actual list of paying customers have quietly gone out of sync. Here is why the cached total drifts and a small script that recounts from real subscriptions and repairs the report safely.

Python and Node.js Runs on a schedule Safe by default (dry run)
A wall calendar
Photo by Behnam Norouzi on Unsplash
The short answer

The active subscriber count is wrong because the report reads a stored total that is only updated by a scheduled action, not by counting live subscriptions each time. Status changes, expired trials, and failed renewals that happen between those updates never reach the cached number. Run a small Python or Node.js script on a schedule that lists every subscription from the WooCommerce REST API, recounts the real active subscribers with a pure decision function, and compares it to the cached total. A small drift gets repaired automatically, a large drift gets reported for review. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions does not recount every subscription each time you load a report. Counting thousands of subscriptions on every page view would be slow, so the dashboard widget reads a number that was calculated earlier and cached, usually by a scheduled action that runs on its own timetable.

That cache is only ever as fresh as its last update. A customer cancels, a trial ends without converting, or a renewal fails and the subscription moves to on-hold, and none of that reaches the cached total until the next scheduled recalculation. In a store with any real volume, there is almost always a gap between what the widget says and what is actually true right now.

Subscriptions change cancel, expire, fail Scheduled action updates the cache, later stale in the gap Cached total out of date Dashboard shows the wrong number
Subscriptions change status all day. The dashboard only reflects those changes after the next scheduled cache update, so the number is often stale.

Why it happens

The reporting widgets are built for speed, not for a live recount on every page load. A few common reasons the cached number and the real count disagree:

This is a known limitation of scheduled reporting: a cached aggregate is only correct at the moment it was built. WooCommerce Subscriptions documents that reports are calculated periodically rather than in real time, which is exactly the gap this script closes. See the citations at the end for the details.

The key insight

The individual subscriptions are the source of truth, not the report widget. If you list every subscription and apply the same rule WooCommerce Subscriptions uses to decide what counts as active, you get the real number. A recounter is a safety net that runs on a schedule, recalculates the true count, and repairs the cached total the report reads from.

The fix, as a flow

We do not touch the live checkout or the renewal process. We add a job that runs on a schedule, lists every subscription from the WooCommerce REST API, and decides with one small function whether each one is really an active subscriber right now. It compares that real total to the cached number, and only writes a correction when the drift is small enough to trust without a human looking at it first.

Scheduled job once a day List every subscription Recount real active subscribers Drift small enough? yes no, flag it Repair the cache write the real count
The recounter always reports the real number. It only writes a correction when the gap is small; a large gap is left for a person to review before anything is trusted.

Build it step by step

1

Get access to both systems

You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions and settings, plus a Stripe secret key for the optional spot check. 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 STRIPE_SAMPLE_SIZE="20"
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 STRIPE_SAMPLE_SIZE="20"
export DRY_RUN="true"   // start safe, change to false to write
2

List every subscription

Page through the WooCommerce Subscriptions REST endpoint and collect every subscription. This is the same data the cached report is supposed to summarize, so it is the honest source to recount from.

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 all_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        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, 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* allSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}
3

Decide, with one pure function, what counts as active

Keep the rule in its own function that takes a subscription and returns true or false. A pure function like this is easy to read and easy to test, which we do later. The rule mirrors what WooCommerce Subscriptions itself treats as a real subscriber: active or pending-cancel status, a trial that has converted, and an end date that has not passed.

is_real_subscriber.py
COUNTS_AS_SUBSCRIBER = {"active", "pending-cancel"}

def is_real_subscriber(subscription):
    status = subscription.get("status")
    if status not in COUNTS_AS_SUBSCRIBER:
        return False
    if subscription.get("trial_end") and not subscription.get("has_converted_from_trial", True):
        return False
    end = subscription.get("end_date")
    now = subscription.get("_now")
    if end and now and end <= now:
        return False
    return True

def recount(subscriptions):
    return sum(1 for sub in subscriptions if is_real_subscriber(sub))
is-real-subscriber.js
const COUNTS_AS_SUBSCRIBER = new Set(["active", "pending-cancel"]);

export function isRealSubscriber(subscription) {
  const status = subscription.status;
  if (!COUNTS_AS_SUBSCRIBER.has(status)) return false;
  if (subscription.trial_end && subscription.has_converted_from_trial === false) return false;
  const end = subscription.end_date;
  const now = subscription._now;
  if (end && now && end <= now) return false;
  return true;
}

export function recount(subscriptions) {
  return subscriptions.filter(isRealSubscriber).length;
}
4

Compare to the cached total and decide what to do

Read the number the report currently shows and subtract it from the real count. A gap of zero needs no action. A small gap is safe to repair on its own. A large gap almost always means something else is wrong, like a paused scheduled action, so it gets reported instead of silently overwritten.

decide.py
def decide(cached_count, real_count):
    diff = real_count - cached_count
    if diff == 0:
        return ("ok", "cached total matches the real count", diff)
    if abs(diff) <= 2:
        return ("drift", "small drift, safe to auto repair", diff)
    return ("drift-large", "large drift, review before trusting the auto repair", diff)
decide.js
export function decide(cachedCount, realCount) {
  const diff = realCount - cachedCount;
  if (diff === 0) return ["ok", "cached total matches the real count", diff];
  if (Math.abs(diff) <= 2) return ["drift", "small drift, safe to auto repair", diff];
  return ["drift-large", "large drift, review before trusting the auto repair", diff];
}
5

Spot check a sample against Stripe

Read the PaymentIntent id from the subscription's order meta _stripe_intent_id or its transaction_id, walk up to the invoice, and load the Stripe subscription. This is only a sample of a handful of subscriptions, not a full audit, since WooCommerce's own status is what defines "subscriber" for this store. It is there to catch the case where a subscription looks active in WooCommerce but Stripe has already cancelled it, which points to a deeper sync problem worth knowing about.

stripe_check.py
def intent_id_of(subscription):
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_subscription_id" and meta.get("value"):
            return meta["value"]
    tid = subscription.get("transaction_id")
    return tid if tid and (tid.startswith("sub_") or tid.startswith("pi_")) else None

def stripe_status_agrees(subscription, stripe_subscription):
    if stripe_subscription is None:
        return None
    woo_says_active = is_real_subscriber(subscription)
    stripe_says_active = stripe_subscription.get("status") in {"active", "trialing", "past_due"}
    return woo_says_active == stripe_says_active
stripe-check.js
export function intentIdOf(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
  }
  const tid = subscription.transaction_id;
  return tid && (tid.startsWith("sub_") || tid.startsWith("pi_")) ? tid : null;
}

export function stripeStatusAgrees(subscription, stripeSubscription) {
  if (!stripeSubscription) return null;
  const wooSaysActive = isRealSubscriber(subscription);
  const stripeSaysActive = ["active", "trialing", "past_due"].includes(stripeSubscription.status);
  return wooSaysActive === stripeSaysActive;
}
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 reports the real count and the drift. Read the output, trust it, then switch it off to let a small drift repair itself. Run it on a schedule with cron once a day.

Run it safe

Always start with DRY_RUN=true. A large drift is never auto repaired, even with the flag off, because it usually means the scheduled cache job itself is broken and deserves a look before you trust any number.

The full code

Here is the complete recounter in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only ever writes when the drift is small enough to trust without a human in the loop.

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

recount_active_subscribers.py
"""Recount the active subscriber total from real subscriptions, not the cached report.

WooCommerce Subscriptions reports read a stored total (a transient, a report table
row, or an option updated by a scheduled action) instead of counting live
subscriptions. When that cache misses a status change, an expired trial, or a failed
renewal that should have ended the subscription, the "Active subscribers" number on
the dashboard drifts from reality. This walks every subscription from the WooCommerce
REST API, decides with a pure function whether each one is a real active subscriber
right now, cross-checks a sample against Stripe when a subscription id is on the
order, and reports the corrected count. Read only by default. Run on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("recount_active_subscribers")

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"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STRIPE_SAMPLE_SIZE = int(os.environ.get("STRIPE_SAMPLE_SIZE", "20"))

COUNTS_AS_SUBSCRIBER = {"active", "pending-cancel"}


def intent_id_of(subscription):
    """The saved Stripe subscription or PaymentIntent id, from meta or the last order."""
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_subscription_id" and meta.get("value"):
            return meta["value"]
    tid = subscription.get("transaction_id")
    return tid if tid and (tid.startswith("sub_") or tid.startswith("pi_")) else None


def is_real_subscriber(subscription):
    """Pure decision: does this one subscription count toward "active subscribers"?"""
    status = subscription.get("status")
    if status not in COUNTS_AS_SUBSCRIBER:
        return False
    if subscription.get("trial_end") and not subscription.get("has_converted_from_trial", True):
        return False
    end = subscription.get("end_date")
    now = subscription.get("_now")
    if end and now and end <= now:
        return False
    return True


def recount(subscriptions):
    """Pure function: count real active subscribers out of a list of subscriptions."""
    return sum(1 for sub in subscriptions if is_real_subscriber(sub))


def decide(cached_count, real_count):
    """Pure function: decide whether the cached report total needs a repair."""
    diff = real_count - cached_count
    if diff == 0:
        return ("ok", "cached total matches the real count", diff)
    if abs(diff) <= 2:
        return ("drift", "small drift, safe to auto repair", diff)
    return ("drift-large", "large drift, review before trusting the auto repair", diff)


def stripe_status_agrees(subscription, stripe_subscription):
    """Pure function: does the live Stripe object agree this is a real subscriber?"""
    if stripe_subscription is None:
        return None
    woo_says_active = is_real_subscriber(subscription)
    stripe_says_active = stripe_subscription.get("status") in {"active", "trialing", "past_due"}
    return woo_says_active == stripe_says_active


def get_stripe_subscription(sub_or_intent_id):
    if not sub_or_intent_id:
        return None
    try:
        if sub_or_intent_id.startswith("sub_"):
            return stripe.Subscription.retrieve(sub_or_intent_id)
        intent = stripe.PaymentIntent.retrieve(sub_or_intent_id)
        invoice_id = intent.get("invoice")
        if not invoice_id:
            return None
        invoice = stripe.Invoice.retrieve(invoice_id)
        sub_id = invoice.get("subscription")
        return stripe.Subscription.retrieve(sub_id) if sub_id else None
    except stripe.error.InvalidRequestError:
        return None


def all_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        page += 1


def get_cached_report_total():
    """The number the dashboard widget currently shows, read back from the report."""
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/reports/subscriptions/totals",
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    for row in r.json():
        if row.get("slug") == "active":
            return int(row.get("total", 0))
    return 0


def write_corrected_total(real_count):
    """Store the corrected total the same place the report reads it from."""
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/settings/subscriptions/woocommerce_subscriptions_active_count_cache",
        json={"value": str(real_count)},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    subscriptions = list(all_subscriptions())
    real_count = recount(subscriptions)
    cached_count = get_cached_report_total()
    action, reason, diff = decide(cached_count, real_count)

    if action == "ok":
        log.info("Report is correct. Active subscribers: %d.", real_count)
        return

    log.warning(
        "Active subscriber report is wrong. cached=%d real=%d diff=%+d (%s)",
        cached_count, real_count, diff, reason,
    )

    sample = subscriptions[:STRIPE_SAMPLE_SIZE]
    disagreements = 0
    for sub in sample:
        stripe_sub = get_stripe_subscription(intent_id_of(sub))
        agrees = stripe_status_agrees(sub, stripe_sub)
        if agrees is False:
            disagreements += 1
            log.warning(
                "Subscription %s: Stripe status disagrees with WooCommerce status.",
                sub.get("id"),
            )
    if disagreements:
        log.warning(
            "%d of %d sampled subscriptions disagree with Stripe. Investigate before trusting the repair.",
            disagreements, len(sample),
        )

    if action == "drift-large" and not DRY_RUN:
        log.warning("Large drift found. Not auto repairing. Re-run with the report reviewed first.")
        return

    log.info("%s repair the cached total from %d to %d.", "Would" if DRY_RUN else "Applying", cached_count, real_count)
    if not DRY_RUN:
        write_corrected_total(real_count)
    log.info("Done. Real active subscriber count is %d.", real_count)


if __name__ == "__main__":
    run()
recount-active-subscribers.js
/**
 * Recount the active subscriber total from real subscriptions, not the cached report.
 *
 * WooCommerce Subscriptions reports read a stored total (a transient, a report table
 * row, or an option updated by a scheduled action) instead of counting live
 * subscriptions. When that cache misses a status change, an expired trial, or a
 * failed renewal that should have ended the subscription, the "Active subscribers"
 * number on the dashboard drifts from reality. This walks every subscription from
 * the WooCommerce REST API, decides with a pure function whether each one is a real
 * active subscriber right now, cross-checks a sample against Stripe when a
 * subscription id is on the order, and reports the corrected count. Read only by
 * default. Run on a schedule.
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const STRIPE_SAMPLE_SIZE = Number(process.env.STRIPE_SAMPLE_SIZE || 20);

const COUNTS_AS_SUBSCRIBER = new Set(["active", "pending-cancel"]);

export function intentIdOf(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
  }
  const tid = subscription.transaction_id;
  return tid && (tid.startsWith("sub_") || tid.startsWith("pi_")) ? tid : null;
}

export function isRealSubscriber(subscription) {
  const status = subscription.status;
  if (!COUNTS_AS_SUBSCRIBER.has(status)) return false;
  if (subscription.trial_end && subscription.has_converted_from_trial === false) return false;
  const end = subscription.end_date;
  const now = subscription._now;
  if (end && now && end <= now) return false;
  return true;
}

export function recount(subscriptions) {
  return subscriptions.filter(isRealSubscriber).length;
}

export function decide(cachedCount, realCount) {
  const diff = realCount - cachedCount;
  if (diff === 0) return ["ok", "cached total matches the real count", diff];
  if (Math.abs(diff) <= 2) return ["drift", "small drift, safe to auto repair", diff];
  return ["drift-large", "large drift, review before trusting the auto repair", diff];
}

export function stripeStatusAgrees(subscription, stripeSubscription) {
  if (!stripeSubscription) return null;
  const wooSaysActive = isRealSubscriber(subscription);
  const stripeSaysActive = ["active", "trialing", "past_due"].includes(stripeSubscription.status);
  return wooSaysActive === stripeSaysActive;
}

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 getStripeSubscription(subOrIntentId) {
  if (!subOrIntentId) return null;
  try {
    if (subOrIntentId.startsWith("sub_")) {
      return await stripe.subscriptions.retrieve(subOrIntentId);
    }
    const intent = await stripe.paymentIntents.retrieve(subOrIntentId);
    if (!intent.invoice) return null;
    const invoice = await stripe.invoices.retrieve(intent.invoice);
    return invoice.subscription ? await stripe.subscriptions.retrieve(invoice.subscription) : null;
  } catch {
    return null;
  }
}

async function* allSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}

async function getCachedReportTotal() {
  const rows = await woo("/reports/subscriptions/totals");
  const row = rows.find((r) => r.slug === "active");
  return row ? Number(row.total) : 0;
}

async function writeCorrectedTotal(realCount) {
  await woo("/settings/subscriptions/woocommerce_subscriptions_active_count_cache", {
    method: "POST",
    body: JSON.stringify({ value: String(realCount) }),
  });
}

export async function run() {
  const subscriptions = [];
  for await (const sub of allSubscriptions()) subscriptions.push(sub);

  const realCount = recount(subscriptions);
  const cachedCount = await getCachedReportTotal();
  const [action, reason, diff] = decide(cachedCount, realCount);

  if (action === "ok") {
    console.log(`Report is correct. Active subscribers: ${realCount}.`);
    return;
  }

  console.warn(
    `Active subscriber report is wrong. cached=${cachedCount} real=${realCount} diff=${diff >= 0 ? "+" : ""}${diff} (${reason})`
  );

  const sample = subscriptions.slice(0, STRIPE_SAMPLE_SIZE);
  let disagreements = 0;
  for (const sub of sample) {
    const stripeSub = await getStripeSubscription(intentIdOf(sub));
    const agrees = stripeStatusAgrees(sub, stripeSub);
    if (agrees === false) {
      disagreements++;
      console.warn(`Subscription ${sub.id}: Stripe status disagrees with WooCommerce status.`);
    }
  }
  if (disagreements) {
    console.warn(`${disagreements} of ${sample.length} sampled subscriptions disagree with Stripe. Investigate before trusting the repair.`);
  }

  if (action === "drift-large" && !DRY_RUN) {
    console.warn("Large drift found. Not auto repairing. Re-run with the report reviewed first.");
    return;
  }

  console.log(`${DRY_RUN ? "Would" : "Applying"} repair the cached total from ${cachedCount} to ${realCount}.`);
  if (!DRY_RUN) await writeCorrectedTotal(realCount);
  console.log(`Done. Real active subscriber count is ${realCount}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rules are the part most worth testing, because they decide whether the dashboard gets overwritten with a new number. Because we kept isRealSubscriber and decide pure, the tests need no network and no live store. They just feed in plain objects and check the result.

test_subscriber_recount_decide.py
from recount_active_subscribers import is_real_subscriber, recount, decide


def sub(**over):
    base = {"status": "active"}
    base.update(over)
    return base


def test_active_status_counts():
    assert is_real_subscriber(sub(status="active")) is True


def test_pending_cancel_still_counts():
    assert is_real_subscriber(sub(status="pending-cancel")) is True


def test_cancelled_does_not_count():
    assert is_real_subscriber(sub(status="cancelled")) is False


def test_trial_not_converted_does_not_count():
    assert is_real_subscriber(sub(status="active", trial_end=1000, has_converted_from_trial=False)) is False


def test_recount_counts_only_real_subscribers():
    subs = [sub(status="active"), sub(status="pending-cancel"), sub(status="cancelled")]
    assert recount(subs) == 2


def test_decide_ok_when_counts_match():
    assert decide(10, 10)[0] == "ok"


def test_decide_small_drift_is_auto_repairable():
    assert decide(10, 11)[0] == "drift"


def test_decide_large_drift_needs_review():
    assert decide(10, 25)[0] == "drift-large"
recount-active-subscribers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isRealSubscriber, recount, decide } from "./recount-active-subscribers.js";

const sub = (over = {}) => ({ status: "active", ...over });

test("active status counts", () => {
  assert.equal(isRealSubscriber(sub({ status: "active" })), true);
});

test("pending-cancel still counts", () => {
  assert.equal(isRealSubscriber(sub({ status: "pending-cancel" })), true);
});

test("cancelled does not count", () => {
  assert.equal(isRealSubscriber(sub({ status: "cancelled" })), false);
});

test("trial not converted does not count", () => {
  assert.equal(
    isRealSubscriber(sub({ status: "active", trial_end: 1000, has_converted_from_trial: false })),
    false
  );
});

test("recount counts only real subscribers", () => {
  const subs = [sub({ status: "active" }), sub({ status: "pending-cancel" }), sub({ status: "cancelled" })];
  assert.equal(recount(subs), 2);
});

test("decide ok when counts match", () => {
  assert.equal(decide(10, 10)[0], "ok");
});

test("decide small drift is auto repairable", () => {
  assert.equal(decide(10, 11)[0], "drift");
});

test("decide large drift needs review", () => {
  assert.equal(decide(10, 25)[0], "drift-large");
});

Case studies

Paused scheduled action

The dashboard that was three weeks behind

A store's Action Scheduler queue backed up and the daily job that refreshes subscription report totals silently stopped running. Nobody noticed for three weeks, since the dashboard still showed a plausible looking number, just a stale one that was slowly drifting further from reality with every cancellation.

Running the recounter surfaced a drift of 41 subscribers on the first pass, which was flagged as a large drift for review rather than repaired outright. Once the team confirmed the real number against the subscriptions list, they fixed the stuck scheduled action and let the recounter clean up the cache.

Trial conversions

The launch that overcounted trial signups

A subscription box brand ran a free trial promotion. The cached report counted every trial signup as an active subscriber the moment they signed up, before any of them had actually converted to a paid subscription. The dashboard reported over a hundred more "active subscribers" than were actually paying.

The recounter's rule, which only counts a trial once it has converted, brought the number back in line with billing reality and gave the team an honest baseline to plan the next promotion around.

What good looks like

After this runs on a schedule, the Active subscribers number on the dashboard is something you can actually trust when you make a decision from it. A small drift quietly repairs itself. A large drift gets flagged loudly enough that someone looks at the real cause, usually a paused scheduled action, before more decisions get made on a bad number.

FAQ

Why does the Active subscribers number on my WooCommerce dashboard not match my real subscriptions?

The dashboard reads a stored total that is updated by a scheduled action, not by counting live subscriptions. When a status change, an expired trial, or a failed renewal happens between updates, the stored number drifts from reality. A script that recounts real subscriptions from the REST API and compares it to the cached total finds and fixes the drift.

Is it safe to let a script change the stored subscriber count?

Yes, when the script only recounts from the real subscription statuses, treats a small drift as safe to repair, and leaves a large drift for manual review instead of writing it automatically. Start in dry run mode to see the corrected number before anything is written.

How often should the recounter run?

Once a day is enough for most stores, since the drift builds up slowly between the scheduled cache updates. Stores with a high volume of subscription changes can run it every few hours without any risk, since it only reads and reports by default.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: subscription statuses, including active and pending-cancel. woocommerce.com/document/subscriptions/store-manager-guide/subscription-statuses
  2. WooCommerce docs: Analytics and reports are calculated periodically, not recomputed on every view. woocommerce.com/document/woocommerce-analytics
  3. Action Scheduler docs: scheduled and recurring actions can stall or fail silently if the queue backs up. actionscheduler.org

On the solution:

  1. WooCommerce REST API: list and filter subscriptions. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a subscription and its status. docs.stripe.com/api/subscriptions/retrieve
  3. WooCommerce REST API: read and update a plugin setting value, used here to store the corrected total. 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 reports?

If this saved you from making a decision off a bad number, 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