Repair WooCommerce core: tax, totals, and analytics
WooCommerce trashed orders still counted in Analytics stats
You trash a batch of test orders, or delete the junk left over from a spam wave, and everything looks clean in the order list. Then the revenue chart in Analytics still shows the same total it did before. Nothing in the order list explains it, because the order list is not where that number comes from. Here is why trashed orders keep counting and a small script that excludes the ones that should not, without ever hiding a real sale.
WooCommerce Analytics does not total your orders live. It reads a separate lookup table that is only updated for an order when the normal "move to Trash" action runs and WooCommerce sets its own _exclude_from_stats meta on that order. A direct database delete, a cleanup cron, or a plugin that trashes orders by writing the status column directly can skip that step, so the stored total keeps counting an order that looks gone everywhere else. Run a small Python or Node.js script on a schedule that finds orders with status trash, checks with Stripe that there is no live, unrefunded charge behind each one, and excludes only the ones that are safe to exclude. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Analytics does not scan your orders every time you open a report. That would be far too slow on a store with any real history. Instead, it keeps its own summary table of totals, and that table is updated by hooks that fire when an order's status changes through the normal WooCommerce code path, things like completing checkout, refunding, or clicking Trash in the admin screen.
Moving an order to Trash the normal way fires that hook and WooCommerce quietly marks the order as excluded from stats. But orders do not always get trashed the normal way. A GDPR eraser, a housekeeping cron that clears old test orders, a migration script, or a plugin that trims the database can change an order's status directly without ever calling the WooCommerce function that flips the exclusion flag. The order shows as trashed in the order list, because that list reads the status column directly. Analytics still shows the old total, because its summary table never got the memo.
Why it happens
WooCommerce Analytics is built on its own set of database tables, separate from the standard order and post tables, precisely so reports stay fast on stores with years of history. Keeping that summary table correct depends entirely on WooCommerce's own code running whenever an order changes state. A few common ways that code gets bypassed:
- A privacy or GDPR cleanup tool trashes old orders by updating the status column in bulk, without running each order through
wp_trash_post()or the equivalent HPOS status change. - A custom cron job or a database maintenance script trashes stale test and abandoned orders directly in SQL for speed, skipping WordPress and WooCommerce entirely.
- A migration or import tool moves orders into trash as part of a bulk cleanup step, and the tool was written against the post table only, before HPOS order tables existed.
- A plugin conflict or a fatal error interrupts the trash action partway through, so the status changes but the follow up hook that updates Analytics never fires.
The WooCommerce Analytics report queries filter out orders using the _exclude_from_stats meta and a `wc_order_stats.status` column that is supposed to mirror the real order status. When those two drift apart, a trashed order keeps contributing to revenue, order count, and average order value even though nobody can find it in the order list to double check.
Being trashed and being excluded from stats are two different facts stored in two different places. WooCommerce assumes they always move together, because in the normal admin flow they do. A repair script only needs to check that assumption for every order already sitting in Trash, and fix the ones where it broke.
The fix, as a flow
We never touch a live order. We only look at orders that are already trashed, so there is no risk to anything still selling. For each one, we check Stripe as a safety net before we exclude it, because a trashed order that still has a real, unrefunded charge behind it deserves a second look from a person, not a silent hide. Everything else, a trashed order with no charge, a failed charge, or a fully refunded one, gets its _exclude_from_stats flag set so the reports finally agree with the order list.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true" // start safe, change to false to write
List orders that are already trashed
Ask the WooCommerce REST API for orders with status trash inside your lookback window. We page through all of them. This is the same list an admin would see if they searched the Trash filter on the Orders screen, so nothing here is a live or active order.
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"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
def trashed_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "trash", "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:
yield order
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
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* trashedOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=trash&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Read the Stripe PaymentIntent behind the order
The WooCommerce Stripe plugin saves the PaymentIntent id as order meta _stripe_intent_id, or occasionally as the order's transaction_id when it starts with pi_. We look up that intent on Stripe so we know the real state of the money, not just what WooCommerce last recorded.
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
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;
}
}
Decide, with one pure function
Keep the decision in its own function that takes an order and its Stripe intent and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. Skip anything not trashed or already excluded. Repair anything trashed with no live charge behind it. Hold anything trashed that still has real, unrefunded money on Stripe, so a person looks at it instead of the script hiding it.
EXCLUDE_META_KEY = "_exclude_from_stats"
def is_excluded(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == EXCLUDE_META_KEY:
return str(meta.get("value")) in ("yes", "1", "true")
return False
def decide(order, intent):
if order.get("status") != "trash":
return ("skip", "order is not in trash")
if is_excluded(order):
return ("skip", "already excluded from stats")
if intent is None:
return ("repair", "trashed with no Stripe charge on record")
if intent.get("status") != "succeeded":
return ("repair", "trashed and Stripe charge did not succeed")
if intent.get("amount_refunded", 0) >= intent.get("amount_received", 0) and intent.get("amount_received", 0) > 0:
return ("repair", "trashed and the Stripe charge was fully refunded")
return ("hold", "trashed but Stripe still shows a live, unrefunded charge")
const EXCLUDE_META_KEY = "_exclude_from_stats";
export function isExcluded(order) {
for (const meta of order.meta_data || []) {
if (meta.key === EXCLUDE_META_KEY) {
return ["yes", "1", "true"].includes(String(meta.value));
}
}
return false;
}
export function decide(order, intent) {
if (order.status !== "trash") return ["skip", "order is not in trash"];
if (isExcluded(order)) return ["skip", "already excluded from stats"];
if (!intent) return ["repair", "trashed with no Stripe charge on record"];
if (intent.status !== "succeeded") return ["repair", "trashed and Stripe charge did not succeed"];
const received = intent.amount_received || 0;
const refunded = intent.amount_refunded || 0;
if (received > 0 && refunded >= received) {
return ["repair", "trashed and the Stripe charge was fully refunded"];
}
return ["hold", "trashed but Stripe still shows a live, unrefunded charge"];
}
Exclude the order, or hold it for review
When the action is repair, write _exclude_from_stats as yes through the order's meta_data and add a note explaining why. This is the exact flag WooCommerce sets when Trash works normally, so the Analytics report starts treating the order the way it always should have. When the action is hold, we never change the order, we only add a note asking a person to check it before it is deleted for good.
def exclude_from_stats(order):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"meta_data": [{"key": EXCLUDE_META_KEY, "value": "yes"}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": "Excluded from Analytics: order is trashed and Stripe confirms "
"there is no live, unrefunded charge behind it."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag_for_review(order, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Stats check held: {reason}. This order is trashed but Stripe "
f"still shows a real, unrefunded charge. Please review."},
auth=AUTH, timeout=30,
).raise_for_status()
async function excludeFromStats(order) {
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: EXCLUDE_META_KEY, value: "yes" }] }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Excluded from Analytics: order is trashed and Stripe confirms there is " +
"no live, unrefunded charge behind it.",
}),
});
}
async function flagForReview(order, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Stats check held: ${reason}. This order is trashed but Stripe still shows ` +
`a real, unrefunded charge. Please review.`,
}),
});
}
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. Run it once a day, or right after any bulk cleanup that trashes a batch of orders.
Always start with DRY_RUN=true. The script only ever writes to orders that are already trashed, and it holds anything with a live, unrefunded Stripe charge instead of excluding it. Even so, review the plan once before you let it write.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because an order that is already excluded is simply skipped.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Exclude trashed WooCommerce orders that are still counted in Analytics.
WooCommerce Analytics reads its totals from a lookup table (wc_order_stats), not
straight from the order list. An order is only pulled out of that table when the
normal "move to Trash" action fires and WooCommerce sets its own `_exclude_from_stats`
meta. A direct database delete, a cleanup cron, or a plugin that trashes orders by
writing the status column directly can skip that step, so a trashed order keeps
contributing to revenue and order count totals. This walks orders with status
`trash`, cross-checks the Stripe PaymentIntent as a safety net so a good order is
never silently hidden, and repairs the ones that should be excluded by setting
`_exclude_from_stats` to `yes`. Safe by default. Run on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("exclude_trashed_from_stats")
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", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EXCLUDE_META_KEY = "_exclude_from_stats"
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def is_excluded(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == EXCLUDE_META_KEY:
return str(meta.get("value")) in ("yes", "1", "true")
return False
def decide(order, intent):
"""Pure decision function. No I/O. Returns (action, reason).
action is one of: "skip", "repair", "hold".
- skip: nothing to do, order is not trashed or is already excluded.
- repair: trashed and not excluded, and Stripe agrees there is nothing live
to protect (no succeeded charge, or the charge was refunded), so it is
safe to mark it excluded from stats.
- hold: trashed and not excluded, but Stripe still shows a succeeded,
unrefunded charge. Do not silently hide real revenue. Flag for a human.
"""
if order.get("status") != "trash":
return ("skip", "order is not in trash")
if is_excluded(order):
return ("skip", "already excluded from stats")
if intent is None:
return ("repair", "trashed with no Stripe charge on record")
if intent.get("status") != "succeeded":
return ("repair", "trashed and Stripe charge did not succeed")
if intent.get("amount_refunded", 0) >= intent.get("amount_received", 0) and intent.get("amount_received", 0) > 0:
return ("repair", "trashed and the Stripe charge was fully refunded")
return ("hold", "trashed but Stripe still shows a live, unrefunded charge")
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 trashed_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "trash", "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:
yield order
page += 1
def exclude_from_stats(order):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"meta_data": [{"key": EXCLUDE_META_KEY, "value": "yes"}]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": "Excluded from Analytics: order is trashed and Stripe confirms "
"there is no live, unrefunded charge behind it. Set by the "
"trashed-orders-still-counted-in-stats script."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag_for_review(order, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Stats check held: {reason}. This order is trashed but Stripe "
f"still shows a real, unrefunded charge. Not excluding it "
f"automatically. Please review before it is deleted for good."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
repaired = 0
held = 0
for order in trashed_orders():
intent = get_intent(intent_id_of(order))
action, reason = decide(order, intent)
if action == "skip":
continue
if action == "hold":
log.warning("Order %s held: %s", order["id"], reason)
if not DRY_RUN:
flag_for_review(order, reason)
held += 1
continue
log.info("Order %s: %s. %s", order["id"], reason, "would exclude" if DRY_RUN else "excluding")
if not DRY_RUN:
exclude_from_stats(order)
repaired += 1
log.info("Done. %d order(s) %s, %d held for review.",
repaired, "to exclude" if DRY_RUN else "excluded", held)
if __name__ == "__main__":
run()
/**
* Exclude trashed WooCommerce orders that are still counted in Analytics.
*
* WooCommerce Analytics reads its totals from a lookup table (wc_order_stats), not
* straight from the order list. An order is only pulled out of that table when the
* normal "move to Trash" action fires and WooCommerce sets its own `_exclude_from_stats`
* meta. A direct database delete, a cleanup cron, or a plugin that trashes orders by
* writing the status column directly can skip that step, so a trashed order keeps
* contributing to revenue and order count totals. This walks orders with status
* `trash`, cross-checks the Stripe PaymentIntent as a safety net so a good order is
* never silently hidden, and repairs the ones that should be excluded by setting
* `_exclude_from_stats` to `yes`. Safe by default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/trashed-orders-still-counted-in-stats/
*/
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 || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EXCLUDE_META_KEY = "_exclude_from_stats";
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 isExcluded(order) {
for (const meta of order.meta_data || []) {
if (meta.key === EXCLUDE_META_KEY) {
return ["yes", "1", "true"].includes(String(meta.value));
}
}
return false;
}
/**
* Pure decision function. No I/O. Returns [action, reason].
*
* action is one of: "skip", "repair", "hold".
* - skip: nothing to do, order is not trashed or is already excluded.
* - repair: trashed and not excluded, and Stripe agrees there is nothing live
* to protect (no succeeded charge, or the charge was refunded), so it is
* safe to mark it excluded from stats.
* - hold: trashed and not excluded, but Stripe still shows a succeeded,
* unrefunded charge. Do not silently hide real revenue. Flag for a human.
*/
export function decide(order, intent) {
if (order.status !== "trash") return ["skip", "order is not in trash"];
if (isExcluded(order)) return ["skip", "already excluded from stats"];
if (!intent) return ["repair", "trashed with no Stripe charge on record"];
if (intent.status !== "succeeded") return ["repair", "trashed and Stripe charge did not succeed"];
const received = intent.amount_received || 0;
const refunded = intent.amount_refunded || 0;
if (received > 0 && refunded >= received) {
return ["repair", "trashed and the Stripe charge was fully refunded"];
}
return ["hold", "trashed but Stripe still shows a live, unrefunded charge"];
}
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* trashedOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=trash&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function excludeFromStats(order) {
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: EXCLUDE_META_KEY, value: "yes" }] }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Excluded from Analytics: order is trashed and Stripe confirms there is " +
"no live, unrefunded charge behind it. Set by the " +
"trashed-orders-still-counted-in-stats script.",
}),
});
}
async function flagForReview(order, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Stats check held: ${reason}. This order is trashed but Stripe still shows ` +
`a real, unrefunded charge. Not excluding it automatically. Please review ` +
`before it is deleted for good.`,
}),
});
}
export async function run() {
let repaired = 0;
let held = 0;
for await (const order of trashedOrders()) {
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decide(order, intent);
if (action === "skip") continue;
if (action === "hold") {
console.warn(`Order ${order.id} held: ${reason}`);
if (!DRY_RUN) await flagForReview(order, reason);
held++;
continue;
}
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would exclude" : "excluding"}`);
if (!DRY_RUN) await excludeFromStats(order);
repaired++;
}
console.log(`Done. ${repaired} order(s) ${DRY_RUN ? "to exclude" : "excluded"}, ${held} held for review.`);
}
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 orders quietly disappear from your revenue reports. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from exclude_trashed_from_stats import decide, intent_id_of, is_excluded
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000, "amount_refunded": 0}
base.update(over)
return base
def test_skip_when_not_trashed():
order = {"status": "processing"}
assert decide(order, intent())[0] == "skip"
def test_skip_when_already_excluded():
order = {"status": "trash", "meta_data": [{"key": "_exclude_from_stats", "value": "yes"}]}
assert decide(order, intent())[0] == "skip"
def test_repair_when_no_intent():
order = {"status": "trash", "meta_data": []}
assert decide(order, None)[0] == "repair"
def test_repair_when_fully_refunded():
order = {"status": "trash", "meta_data": []}
assert decide(order, intent(amount_refunded=5000))[0] == "repair"
def test_hold_when_charge_is_live_and_unrefunded():
order = {"status": "trash", "meta_data": []}
assert decide(order, intent())[0] == "hold"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, isExcluded } from "./exclude-trashed-from-stats.js";
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, amount_refunded: 0, ...over });
test("skip when not trashed", () => {
assert.equal(decide({ status: "processing" }, intent())[0], "skip");
});
test("skip when already excluded", () => {
const order = { status: "trash", meta_data: [{ key: "_exclude_from_stats", value: "yes" }] };
assert.equal(decide(order, intent())[0], "skip");
});
test("repair when no intent", () => {
const order = { status: "trash", meta_data: [] };
assert.equal(decide(order, null)[0], "repair");
});
test("repair when fully refunded", () => {
const order = { status: "trash", meta_data: [] };
assert.equal(decide(order, intent({ amount_refunded: 5000 }))[0], "repair");
});
test("hold when charge is live and unrefunded", () => {
const order = { status: "trash", meta_data: [] };
assert.equal(decide(order, intent())[0], "hold");
});
Case studies
The privacy tool that trashed orders quietly
A store ran a GDPR eraser plugin to comply with a customer's deletion request. The tool trashed a batch of related orders by writing the status column directly, to avoid the overhead of the normal WordPress trash flow across thousands of rows. The order list looked correct. The monthly revenue report in Analytics did not budge.
The script found forty two trashed orders that were never excluded, checked each one against Stripe, confirmed none had a live unrefunded charge, and excluded all forty two in a single dry run followed by a real run.
The staging habit that leaked into production totals
A developer used a bulk SQL script to clear out old test orders left on a store after a product launch, trashing dozens of rows in one query for speed. Weeks later, the store owner noticed the average order value in Analytics looked oddly low and could not explain why.
Running the script in dry run surfaced the exact batch, all with no Stripe charge attached since they were test orders to begin with. A real run excluded them and the average order value snapped back to the true number.
After this runs on a schedule, trashing an order the fast way stops being a trap for your reports. Analytics numbers match what a person sees browsing the order list, and any trashed order that still has real money behind it gets a note asking someone to look before it disappears for good.
FAQ
Why do trashed WooCommerce orders still show up in Analytics?
Analytics reads its totals from a separate lookup table, not the order list itself. That table is only cleared for an order when the normal trash action fires and WooCommerce sets its own exclude from stats flag. A direct database delete, a cleanup cron, or a plugin that changes the status column directly can skip that step, so the old total keeps counting an order that looks trashed everywhere else.
Is it safe to let a script exclude orders from stats automatically?
Yes, when the script only excludes an order that is already trashed and Stripe confirms there is no live, unrefunded charge behind it. If Stripe still shows real money on a trashed order, the script holds it and flags it for a human instead of hiding it. Start in dry run mode to review the list before it writes.
How often should this exclusion check run?
Once a day is enough for most stores, and right after any bulk cleanup or migration that trashes a batch of orders. It only ever touches orders that are already trashed, so running it often carries no real risk.
Related field notes
Citations
On the problem:
- WooCommerce Analytics developer docs: reports are built from dedicated lookup tables, not live order queries. github.com/woocommerce/woocommerce/wiki/Analytics-Data-Reports
- WooCommerce source: the
_exclude_from_statsmeta key and how order stats syncing decides which orders count. github.com/woocommerce/woocommerce - WooCommerce community report: Analytics totals do not match the order list after bulk deletes and cleanup tools. wordpress.org/support/topic/analytics-report-not-matching-orders
On the solution:
- WooCommerce REST API: list and update orders, including the
meta_dataarray and order notes. woocommerce.github.io/woocommerce-rest-api-docs - Stripe API: retrieve a PaymentIntent to confirm its status and refunded amount. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce docs: how order status changes are expected to keep Analytics data in sync. woocommerce.com/document/woocommerce-analytics
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your Analytics totals?
If this saved you a confusing afternoon staring at a revenue chart that would not add up, 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