Repair WooCommerce core: products and catalog
Out of stock but still purchasable
The product page says "Out of stock" in plain red letters, and the buy button still adds it to the cart. A customer checks out, pays, and now you have an order for something you do not have. This is not a rare glitch. It is what happens when the fields WooCommerce uses to decide "can this be bought" quietly drift apart from the field that says "do we have any." Here is why it happens and a small job that finds every product in that state, locks it down, and flags any order that already slipped through.
WooCommerce decides whether a product can be bought using a few separate fields: stock_status, purchasable, backorders, and catalog_visibility. A product can have stock_status set to outofstock while purchasable stays true, or while backorders got switched on somewhere along the way, so the buy button never turns off. Run a small Python or Node.js job on a schedule that reads every product (and every variation of a variable product) through the WooCommerce REST API, decides which ones are truly out of stock yet still buyable, and locks them down. Then check any recent order against Stripe to see if a buyer was actually charged while the item was broken. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce does not have one single "in stock or not" switch. It has several fields working together: stock_status (instock, outofstock, or onbackorder), manage_stock and stock_quantity if you track exact numbers, backorders (whether buying continues after zero), and catalog_visibility (whether the product shows in the shop, search, both, or neither). The buy button on the front end reads a computed purchasable flag, and that flag can end up true even when the product is genuinely empty.
This happens most on variable products. A shirt with five sizes sells out of every size, but the parent product's own stock_status was never recalculated, because that recalculation is supposed to run automatically and sometimes does not, especially after a bulk import, a direct database edit, a caching layer serving a stale page, or a plugin that writes stock numbers without going through WooCommerce's own stock functions.
Why it happens
The WooCommerce docs describe stock_status, backorders, and catalog_visibility as separate settings that a theme or a plugin can each read differently, which is exactly how they drift apart. A few common reasons the buy button outlives the stock:
- A variable product's parent
stock_statusis a cached rollup of its variations. When a variation sells out through a direct database write or an import that bypasses WooCommerce's stock functions, the parent is never told to recheck. - A plugin or a one off script sets
stock_quantityto zero but never touchesstock_status, so the two fields disagree and the storefront trusts whichever one it reads first. backordersgets left onnotifyoryesfrom an earlier season when it was intentional, and nobody turns it off once the item is meant to be truly gone.- A page cache or a search index serves a snapshot of the product from before it sold out, so the "Add to cart" button in that cached HTML has no idea the item is gone.
This is reported often enough that WooCommerce's own support docs have a dedicated troubleshooting page for products that will not go out of stock automatically. See the citations at the end for the exact reference.
The WooCommerce REST API is the source of truth for what should be true. If a product's stock_status reads outofstock, or its managed quantity is zero or less with backorders off, then purchasable and catalog_visibility must agree with that. A repair job is a safety net that runs on a schedule, checks every product against its own stock fields, and closes the gap the moment it appears.
The fix, as a flow
We do not touch pricing, descriptions, or anything about products that are genuinely in stock. We add a job that walks the catalog, including every variation of variable products, and for each one asks a single pure question: is this out of stock, and if so, is it still buyable or still fully listed? If yes, we lock it down the same way a careful shop manager would by hand. Then, because a broken window can let an order through before anyone notices, we check any open order against Stripe to see if a real charge already happened.
Build it step by step
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 products and orders, and a Stripe secret key to check whether a flagged order was actually charged. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" // start safe, change to false to write
Decide whether a product is truly out of stock
Trust stock_status first, since that is the field WooCommerce itself uses most directly. If it already says outofstock, the product is out of stock, full stop. Otherwise, only treat a product as out of stock when stock is managed, the quantity is zero or below, and backorders are turned off. A managed product with backorders allowed is not out of stock in the sense we care about, since WooCommerce is deliberately letting it keep selling.
def is_out_of_stock(product):
if product.get("stock_status") == "outofstock":
return True
if not product.get("manage_stock"):
return False
qty = product.get("stock_quantity")
if qty is None:
return False
return qty <= 0 and product.get("backorders", "no") == "no"
export function isOutOfStock(product) {
if (product.stock_status === "outofstock") return true;
if (!product.manage_stock) return false;
const qty = product.stock_quantity;
if (qty === null || qty === undefined) return false;
return qty <= 0 && (product.backorders || "no") === "no";
}
List every product and every variation
Page through /wp-json/wc/v3/products for simple products, and for every product whose type is variable, also page through /wp-json/wc/v3/products/{id}/variations. A variable product's own stock fields and each of its variations can be wrong independently, so both need checking.
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 list_products():
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products",
params={"per_page": 50, "page": page, "status": "publish"},
auth=AUTH, timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
return
for product in batch:
yield product
page += 1
def list_variations(product_id):
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations",
params={"per_page": 50, "page": page}, auth=AUTH, timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
return
for variation in batch:
yield variation
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");
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* listProducts() {
let page = 1;
while (true) {
const batch = await woo(`/products?per_page=50&page=${page}&status=publish`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
async function* listVariations(productId) {
let page = 1;
while (true) {
const batch = await woo(`/products/${productId}/variations?per_page=50&page=${page}`);
if (!batch.length) return;
for (const variation of batch) yield variation;
page++;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a product and returns an action. If the product is not out of stock, skip it and move on. If it is out of stock but already locked down (not purchasable and hidden from the shop and search), skip it too, there is nothing to do. Otherwise, repair it.
SAFE_VISIBILITY = "search"
def decide_product(product):
if not is_out_of_stock(product):
return ("skip", "product is in stock")
purchasable = product.get("purchasable", True)
visibility = product.get("catalog_visibility", "visible")
if not purchasable and visibility == SAFE_VISIBILITY:
return ("skip", "already locked down: not purchasable and hidden from the shop")
return ("repair", "out of stock but still purchasable or still fully listed")
const SAFE_VISIBILITY = "search";
export function decideProduct(product) {
if (!isOutOfStock(product)) return ["skip", "product is in stock"];
const purchasable = product.purchasable ?? true;
const visibility = product.catalog_visibility ?? "visible";
if (!purchasable && visibility === SAFE_VISIBILITY) {
return ["skip", "already locked down: not purchasable and hidden from the shop"];
}
return ["repair", "out of stock but still purchasable or still fully listed"];
}
Lock the product down, without breaking its page
When the action is repair, set stock_status to outofstock (harmless if already set), force backorders to no so purchasing cannot quietly reopen later, and drop catalog_visibility to search. That keeps the product's own page resolving normally, so you keep its SEO and any links pointing at it, but it stops appearing in the shop and in search driven upsells that would push a customer back toward buying it. Variations only take the stock fields, since they have no catalog visibility of their own.
def build_patch():
return {
"stock_status": "outofstock",
"backorders": "no",
"catalog_visibility": SAFE_VISIBILITY,
}
def repair_product(product_id, patch):
requests.put(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json=patch, auth=AUTH, timeout=30).raise_for_status()
def repair_variation(product_id, variation_id, patch):
variation_patch = {k: v for k, v in patch.items() if k in ("stock_status", "backorders")}
requests.put(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations/{variation_id}",
json=variation_patch, auth=AUTH, timeout=30).raise_for_status()
export function buildPatch() {
return {
stock_status: "outofstock",
backorders: "no",
catalog_visibility: SAFE_VISIBILITY,
};
}
async function repairProduct(productId, patch) {
await woo(`/products/${productId}`, { method: "PUT", body: JSON.stringify(patch) });
}
async function repairVariation(productId, variationId, patch) {
const variationPatch = { stock_status: patch.stock_status, backorders: patch.backorders };
await woo(`/products/${productId}/variations/${variationId}`, {
method: "PUT",
body: JSON.stringify(variationPatch),
});
}
Check whether an order already slipped through
Locking down the catalog does not undo an order that was already placed while the product was broken. For any product you just repaired, look at recent open orders (pending, processing, or on-hold) and check whether any of them includes that product. Read the order's saved Stripe PaymentIntent id from the order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent, then ask Stripe whether it actually succeeded. This never cancels or refunds anything by itself. It only tells a human what happened, since fulfilling from backorder or refunding the buyer is a judgment call.
OPEN_ORDER_STATUSES = {"pending", "processing", "on-hold"}
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 decide_order(order, intent, product_ids_repaired):
if order["status"] not in OPEN_ORDER_STATUSES:
return ("skip", "order is not open")
line_ids = {item["product_id"] for item in order.get("line_items", [])}
if not line_ids & product_ids_repaired:
return ("skip", "order does not include a repaired product")
if intent is not None and intent.get("status") == "succeeded":
return ("flag_charged", "buyer was charged while the item was out of stock")
return ("flag_uncharged", "order is open but no succeeded charge is on file")
const OPEN_ORDER_STATUSES = new Set(["pending", "processing", "on-hold"]);
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 decideOrder(order, intent, repairedProductIds) {
if (!OPEN_ORDER_STATUSES.has(order.status)) return ["skip", "order is not open"];
const lineIds = new Set((order.line_items || []).map((item) => item.product_id));
const touchesRepaired = [...repairedProductIds].some((id) => lineIds.has(id));
if (!touchesRepaired) return ["skip", "order does not include a repaired product"];
if (intent && intent.status === "succeeded") {
return ["flag_charged", "buyer was charged while the item was out of stock"];
}
return ["flag_uncharged", "order is open but no succeeded charge is on file"];
}
Always start with DRY_RUN=true. This job writes to real products and adds notes to real orders, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.
The full code
Here is the complete job 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 it only ever repairs a product that is genuinely out of stock and only ever flags an order for a human to review.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Repair WooCommerce products that are out of stock but still purchasable,
and check whether any order already slipped through while the catalog was wrong.
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("fix_purchasable_stock")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SAFE_VISIBILITY = "search"
OPEN_ORDER_STATUSES = {"pending", "processing", "on-hold"}
def is_out_of_stock(product):
if product.get("stock_status") == "outofstock":
return True
if not product.get("manage_stock"):
return False
qty = product.get("stock_quantity")
if qty is None:
return False
return qty <= 0 and product.get("backorders", "no") == "no"
def decide_product(product):
if not is_out_of_stock(product):
return ("skip", "product is in stock")
purchasable = product.get("purchasable", True)
visibility = product.get("catalog_visibility", "visible")
if not purchasable and visibility == SAFE_VISIBILITY:
return ("skip", "already locked down: not purchasable and hidden from the shop")
return ("repair", "out of stock but still purchasable or still fully listed")
def build_patch():
return {
"stock_status": "outofstock",
"backorders": "no",
"catalog_visibility": SAFE_VISIBILITY,
}
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 decide_order(order, intent, product_ids_repaired):
if order["status"] not in OPEN_ORDER_STATUSES:
return ("skip", "order is not open")
line_ids = {item["product_id"] for item in order.get("line_items", [])}
if not line_ids & product_ids_repaired:
return ("skip", "order does not include a repaired product")
if intent is not None and intent.get("status") == "succeeded":
return ("flag_charged", "buyer was charged while the item was out of stock")
return ("flag_uncharged", "order is open but no succeeded charge is on file")
def list_products():
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products",
params={"per_page": 50, "page": page, "status": "publish"},
auth=AUTH, timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
return
for product in batch:
yield product
page += 1
def list_variations(product_id):
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations",
params={"per_page": 50, "page": page}, auth=AUTH, timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
return
for variation in batch:
yield variation
page += 1
def recent_open_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": "pending,processing,on-hold", "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 get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def repair_product(product_id, patch):
requests.put(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json=patch, auth=AUTH, timeout=30).raise_for_status()
def repair_variation(product_id, variation_id, patch):
variation_patch = {k: v for k, v in patch.items() if k in ("stock_status", "backorders")}
requests.put(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations/{variation_id}",
json=variation_patch, auth=AUTH, timeout=30).raise_for_status()
def flag_order(order, reason):
requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Out of stock check: {reason}. This order includes a product that "
f"was out of stock but still purchasable. Please review."},
auth=AUTH, timeout=30).raise_for_status()
def run():
repaired_ids = set()
for product in list_products():
action, reason = decide_product(product)
if action == "repair":
log.warning("Product %s (%s): %s. %s", product["id"], product.get("name", ""),
reason, "would repair" if DRY_RUN else "repairing")
if not DRY_RUN:
repair_product(product["id"], build_patch())
repaired_ids.add(product["id"])
if product.get("type") == "variable":
for variation in list_variations(product["id"]):
v_action, v_reason = decide_product(variation)
if v_action != "repair":
continue
log.warning("Variation %s of product %s: %s. %s", variation["id"], product["id"],
v_reason, "would repair" if DRY_RUN else "repairing")
if not DRY_RUN:
repair_variation(product["id"], variation["id"], build_patch())
repaired_ids.add(product["id"])
flagged = 0
if repaired_ids:
for order in recent_open_orders():
intent = get_intent(intent_id_of(order))
action, reason = decide_order(order, intent, repaired_ids)
if action not in ("flag_charged", "flag_uncharged"):
continue
log.warning("Order %s: %s. %s", order["id"], reason, "would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
flag_order(order, reason)
flagged += 1
log.info("Done. %d product/variation(s) %s, %d order(s) %s.",
len(repaired_ids), "to repair" if DRY_RUN else "repaired",
flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Repair WooCommerce products that are out of stock but still purchasable,
* and check whether any order already slipped through while the catalog
* was wrong. Safe 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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SAFE_VISIBILITY = "search";
const OPEN_ORDER_STATUSES = new Set(["pending", "processing", "on-hold"]);
export function isOutOfStock(product) {
if (product.stock_status === "outofstock") return true;
if (!product.manage_stock) return false;
const qty = product.stock_quantity;
if (qty === null || qty === undefined) return false;
return qty <= 0 && (product.backorders || "no") === "no";
}
export function decideProduct(product) {
if (!isOutOfStock(product)) return ["skip", "product is in stock"];
const purchasable = product.purchasable ?? true;
const visibility = product.catalog_visibility ?? "visible";
if (!purchasable && visibility === SAFE_VISIBILITY) {
return ["skip", "already locked down: not purchasable and hidden from the shop"];
}
return ["repair", "out of stock but still purchasable or still fully listed"];
}
export function buildPatch() {
return {
stock_status: "outofstock",
backorders: "no",
catalog_visibility: SAFE_VISIBILITY,
};
}
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 decideOrder(order, intent, repairedProductIds) {
if (!OPEN_ORDER_STATUSES.has(order.status)) return ["skip", "order is not open"];
const lineIds = new Set((order.line_items || []).map((item) => item.product_id));
const touchesRepaired = [...repairedProductIds].some((id) => lineIds.has(id));
if (!touchesRepaired) return ["skip", "order does not include a repaired product"];
if (intent && intent.status === "succeeded") {
return ["flag_charged", "buyer was charged while the item was out of stock"];
}
return ["flag_uncharged", "order is open but no succeeded charge is on file"];
}
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* listProducts() {
let page = 1;
while (true) {
const batch = await woo(`/products?per_page=50&page=${page}&status=publish`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
async function* listVariations(productId) {
let page = 1;
while (true) {
const batch = await woo(`/products/${productId}/variations?per_page=50&page=${page}`);
if (!batch.length) return;
for (const variation of batch) yield variation;
page++;
}
}
async function* recentOpenOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=pending,processing,on-hold&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function repairProduct(productId, patch) {
await woo(`/products/${productId}`, { method: "PUT", body: JSON.stringify(patch) });
}
async function repairVariation(productId, variationId, patch) {
const variationPatch = { stock_status: patch.stock_status, backorders: patch.backorders };
await woo(`/products/${productId}/variations/${variationId}`, {
method: "PUT",
body: JSON.stringify(variationPatch),
});
}
async function flagOrder(order, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Out of stock check: ${reason}. This order includes a product that ` +
`was out of stock but still purchasable. Please review.`,
}),
});
}
export async function run() {
const repairedIds = new Set();
for await (const product of listProducts()) {
const [action, reason] = decideProduct(product);
if (action === "repair") {
console.warn(`Product ${product.id} (${product.name || ""}): ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
if (!DRY_RUN) await repairProduct(product.id, buildPatch());
repairedIds.add(product.id);
}
if (product.type === "variable") {
for await (const variation of listVariations(product.id)) {
const [vAction, vReason] = decideProduct(variation);
if (vAction !== "repair") continue;
console.warn(`Variation ${variation.id} of product ${product.id}: ${vReason}. ${DRY_RUN ? "would repair" : "repairing"}`);
if (!DRY_RUN) await repairVariation(product.id, variation.id, buildPatch());
repairedIds.add(product.id);
}
}
}
let flagged = 0;
if (repairedIds.size) {
for await (const order of recentOpenOrders()) {
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decideOrder(order, intent, repairedIds);
if (action !== "flag_charged" && action !== "flag_uncharged") continue;
console.warn(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) await flagOrder(order, reason);
flagged++;
}
}
console.log(`Done. ${repairedIds.size} product/variation(s) ${DRY_RUN ? "to repair" : "repaired"}, ` +
`${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The two decision rules are the part most worth testing, because together they decide which products get locked down and which orders get flagged for a human. Because decide_product and decide_order are pure, the tests need no network and no live store. They just feed in plain objects and check the action.
from fix_purchasable_stock import decide_product, decide_order, intent_id_of, is_out_of_stock
def product(**over):
base = {"id": 101, "stock_status": "outofstock", "manage_stock": True,
"stock_quantity": 0, "backorders": "no", "purchasable": True,
"catalog_visibility": "visible"}
base.update(over)
return base
def order(**over):
base = {"id": 555, "status": "processing", "line_items": [{"product_id": 101}]}
base.update(over)
return base
def intent(**over):
base = {"status": "succeeded", "id": "pi_1"}
base.update(over)
return base
def test_repair_when_out_of_stock_and_purchasable():
action, _ = decide_product(product(purchasable=True, catalog_visibility="visible"))
assert action == "repair"
def test_skip_when_already_locked_down():
action, _ = decide_product(product(purchasable=False, catalog_visibility="search"))
assert action == "skip"
def test_skip_when_in_stock():
action, _ = decide_product(product(stock_status="instock", manage_stock=False))
assert action == "skip"
def test_flag_charged_when_order_open_and_payment_succeeded():
action, _ = decide_order(order(), intent(), {101})
assert action == "flag_charged"
def test_flag_uncharged_when_order_open_and_no_intent():
action, _ = decide_order(order(), None, {101})
assert action == "flag_uncharged"
def test_skip_when_order_has_no_repaired_product():
action, _ = decide_order(order(line_items=[{"product_id": 999}]), intent(), {101})
assert action == "skip"
def test_intent_id_falls_back_to_transaction_id():
o = {"meta_data": [], "transaction_id": "pi_456"}
assert intent_id_of(o) == "pi_456"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideProduct, decideOrder, intentIdOf } from "./fix-purchasable-stock.js";
const product = (over = {}) => ({
id: 101, stock_status: "outofstock", manage_stock: true, stock_quantity: 0,
backorders: "no", purchasable: true, catalog_visibility: "visible", ...over,
});
const order = (over = {}) => ({
id: 555, status: "processing", line_items: [{ product_id: 101 }], ...over,
});
const intent = (over = {}) => ({ status: "succeeded", id: "pi_1", ...over });
test("repair when out of stock and purchasable", () => {
assert.equal(decideProduct(product({ purchasable: true, catalog_visibility: "visible" }))[0], "repair");
});
test("skip when already locked down", () => {
assert.equal(decideProduct(product({ purchasable: false, catalog_visibility: "search" }))[0], "skip");
});
test("skip when in stock", () => {
assert.equal(decideProduct(product({ stock_status: "instock", manage_stock: false }))[0], "skip");
});
test("flag_charged when order open and payment succeeded", () => {
assert.equal(decideOrder(order(), intent(), new Set([101]))[0], "flag_charged");
});
test("flag_uncharged when order open and no intent", () => {
assert.equal(decideOrder(order(), null, new Set([101]))[0], "flag_uncharged");
});
test("skip when order has no repaired product", () => {
assert.equal(decideOrder(order({ line_items: [{ product_id: 999 }] }), intent(), new Set([101]))[0], "skip");
});
test("intentIdOf falls back to transaction_id", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});
Case studies
The shirt that sold out but never said so
A store's best selling shirt sold out of every size during a promotion. Each variation correctly flipped to outofstock, but the parent product's own stock_status never recalculated, since the promotion ran through a bulk price tool that skipped WooCommerce's stock sync hooks. The shop page still listed it as available for four days.
The job's first run found the parent product, out of stock but still purchasable, locked it down, and flagged two orders that had gone through in that window so the team could sort out backorders with those two customers directly.
The import that forgot the status field
A nightly inventory sync from a warehouse system wrote fresh stock_quantity numbers for a few thousand products but never touched stock_status, since the two fields were assumed to update together. About sixty products sat at zero quantity while still marked instock with backorders off, which our decision rule catches as truly out of stock even though the status field disagreed.
Running the job nightly after the sync closed the gap every time, and the store stopped getting the occasional "I ordered something you do not have" email within a week.
After this runs on a schedule, a product selling out is no longer a race between your inventory system and your storefront. The worst case becomes a short window before the job catches it, and even then, any order that slipped through gets flagged for a human instead of silently sitting there. Keep it running even after you fix the root cause of a specific drift, because a new plugin or a new import will eventually cause the same kind of gap again.
FAQ
Why can customers still buy a product that shows out of stock?
WooCommerce checks several fields to decide whether a product can be bought: stock_status, purchasable, and catalog_visibility. If stock_status says outofstock but purchasable is still true, or backorders quietly got turned on, the buy button keeps working even though the product page says the item is unavailable.
Is it safe to change stock_status and catalog_visibility with a script?
Yes, when the script only touches products it confirms are truly out of stock (managed stock at zero or below with backorders off, or stock_status already outofstock), and it never deletes the product or removes its page. Start in dry run mode to review the list before it writes.
What should happen to an order that already went through for a broken product?
Do not cancel or refund it automatically. Check the order's saved Stripe PaymentIntent. If Stripe shows the charge succeeded, a human needs to decide whether to fulfil it from backorder or refund the buyer. The safe move for a script is to flag the order with a note and leave the decision to a person.
Related field notes
Citations
On the problem:
- WooCommerce docs: managing stock, stock status, and backorder settings. woocommerce.com/document/managing-products
- WooCommerce docs: catalog visibility options and how they affect the shop and search. woocommerce.com/document/hide-a-product
- WooCommerce support: products that will not automatically switch to out of stock. wordpress.org/support/topic/products-not-going-out-of-stock-automatically
On the solution:
- WooCommerce REST API: list, read, and update products and variations. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: list orders and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent to confirm its status before acting on an order. docs.stripe.com/api/payment_intents/retrieve
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 catalog?
If this saved you a pile of "I ordered something you do not have" emails, 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