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.

Python and Node.js Runs on a schedule Safe by default (dry run)
A white metal shelf with food packs
Photo by Dennis Siqueira on Unsplash
The short answer

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.

Last unit sells stock_quantity: 0 Import or bulk edit skips the recalc step status never syncs purchasable: true still listed in shop Buy button still works
Stock hits zero, but the fields that gate purchasing never catch up, so the product keeps selling after it should have stopped.

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:

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

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.

Scheduled job every few minutes List products and variations Read stock fields status, qty, backorders Out of stock and still buyable? yes no, skip Lock it down then check Stripe for charges
The job only acts on products it confirms are truly out of stock, and only flags orders for a human to review. Everything else is left alone.

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 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.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

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

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.

step3.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 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
step3.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* 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++;
  }
}
4

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.

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

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.

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

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.

check_orders.py
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")
check-orders.js
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"];
}
Run it safe

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.

fix_purchasable_stock.py
"""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()
fix-purchasable-stock.js
/**
 * 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.

test_outofstock_decide.py
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"
fix-purchasable-stock.test.js
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

Variable product

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.

Stale cache

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.

What good looks like

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:

  1. WooCommerce docs: managing stock, stock status, and backorder settings. woocommerce.com/document/managing-products
  2. WooCommerce docs: catalog visibility options and how they affect the shop and search. woocommerce.com/document/hide-a-product
  3. 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:

  1. WooCommerce REST API: list, read, and update products and variations. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: list orders and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes