Reconciler WooCommerce core: stock and inventory

Order edits do not adjust stock

A shop manager opens an order, bumps a quantity from one to three, saves it, and moves on. WooCommerce never touches product stock for that change. The order total updates. The line item updates. The number of units sitting in your warehouse stays exactly where it was before the edit. Here is why that happens and a small script that finds every line item where the order and the stock have drifted apart, then brings them back in line.

Python and Node.js Runs on a schedule Safe by default (dry run)
A close up of text on a piece of paper
Photo by Cyril Muhammad on Unsplash
The short answer

WooCommerce reduces stock exactly once, the moment an order first moves to a stock reducing status, and stamps the quantity it took on each line item as _reduced_stock meta. Editing the order afterward never re-runs that reduction. Run a small Python or Node.js reconciler on a schedule that reads each order's line items, compares the current quantity to the _reduced_stock value, and adjusts the product's stock by the difference. Full code, tests, and a dry run guard are below.

The problem in plain words

When an order is placed and moves to Processing or Completed, WooCommerce walks its line items once and takes that many units out of each product's stock. To remember it already did this, it writes the exact quantity it reduced onto the line item itself, in a bit of hidden data called _reduced_stock.

That single pass is the only time WooCommerce touches stock for that order under normal conditions. If a shop manager later opens the order and edits it, changing a quantity, deleting a product line, or adding a new one, WooCommerce updates the order's totals and saves the new line items, but it does not go back and adjust the stock it already reduced. The order now says one thing. The warehouse shelf says another.

Order paid qty 1, stock -1 _reduced_stock saved line item remembers: 1 not revisited Admin edits order qty changed to 3 Stock still only -1
Stock is reduced once, on the first status change. The order edit changes the line item, but nothing tells WooCommerce to reduce or restore stock again.

Why it happens

This is a known limit of how WooCommerce core tracks stock changes, confirmed in the official developer documentation and discussed on the WooCommerce GitHub tracker. A few concrete ways it shows up:

Over weeks, this quietly drifts a store's stock numbers away from reality, usually surfacing as unexplained overselling or as popular products that look sold out when they are not.

The key insight

The _reduced_stock meta on each line item is WooCommerce's own memory of what it already took out of stock. Compare that saved number to the line item's current quantity, and the difference is exactly the correction the product's stock needs, no guessing required.

The fix, as a flow

We do not change how checkout or order editing works. We add a job that runs on a schedule, looks at recent orders in a stock reducing status, and checks every line item's current quantity against its _reduced_stock meta. When they differ, we move the product's stock by that exact difference and leave a note on the order explaining what changed and why.

Scheduled job every hour Read recent orders processing, completed, on-hold Compare quantity to _reduced_stock meta Quantity differs? yes no, skip Adjust stock by the delta + add note
The reconciler only touches line items whose current quantity no longer matches what was already reduced. Everything already in sync is left alone.

Build it step by step

1

Get access to the store

You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and products. Create the key under WooCommerce, Settings, Advanced, REST API. This fix does not need Stripe, since stock is a WooCommerce concept, not a payment one. Keep every value in environment variables, never in the file.

setup (shell)
pip install requests

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

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

Read each line item's saved reduction

Every line item that WooCommerce reduced stock for carries a _reduced_stock meta entry with the exact quantity it took. A line item added after the fact has no such meta, which means zero units were ever reduced for it, so the whole current quantity is owed to stock.

step2.py
def reduced_stock_of(line_item):
    """The quantity WooCommerce already took out of stock for this line item,
    read from its `_reduced_stock` meta. Zero when the meta is missing, which
    means the line was added after the order's stock reduction ran."""
    for meta in line_item.get("meta_data") or []:
        if meta.get("key") == "_reduced_stock":
            try:
                return int(meta["value"])
            except (TypeError, ValueError):
                return 0
    return 0
step2.js
export function reducedStockOf(lineItem) {
  for (const meta of lineItem.meta_data || []) {
    if (meta.key === "_reduced_stock") {
      const n = parseInt(meta.value, 10);
      return Number.isNaN(n) ? 0 : n;
    }
  }
  return 0;
}
3

Find every line item that drifted

Only orders in a stock reducing status matter here (Processing, Completed, On hold). For each stock managed line item in those orders, compare the current quantity to what was already reduced. Anything that does not match becomes a correction to make, and the sign of the difference tells us which direction to move stock.

step3.py
STOCK_REDUCED_STATUSES = {"processing", "completed", "on-hold"}

def line_items_needing_sync(order):
    """Every stock managed line item whose current quantity does not match
    the quantity WooCommerce already reduced from stock."""
    if order["status"] not in STOCK_REDUCED_STATUSES:
        return []
    out = []
    for item in order.get("line_items") or []:
        if not item.get("product_id"):
            continue
        reduced = reduced_stock_of(item)
        current = int(item.get("quantity") or 0)
        if reduced != current:
            out.append({
                "product_id": item["product_id"],
                "reduced": reduced,
                "current": current,
                "delta": current - reduced,
            })
    return out
step3.js
const STOCK_REDUCED_STATUSES = new Set(["processing", "completed", "on-hold"]);

export function lineItemsNeedingSync(order) {
  if (!STOCK_REDUCED_STATUSES.has(order.status)) return [];
  const out = [];
  for (const item of order.line_items || []) {
    if (!item.product_id) continue;
    const reduced = reducedStockOf(item);
    const current = Number(item.quantity || 0);
    if (reduced !== current) {
      out.push({
        product_id: item.product_id,
        reduced,
        current,
        delta: current - reduced,
      });
    }
  }
  return out;
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order and the product behind a drifted line item, 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 orders that never reduced stock. Skip products that no longer exist or do not manage stock. Otherwise, adjust.

decide.py
def decide(order, product):
    """Pure decision for one out-of-sync line item against its product record.

    Returns (action, reason). Actions:
      "skip"      - order not in a stock reducing status
      "orphan"    - the product behind the line item no longer exists
      "unmanaged" - product does not track stock, nothing to sync
      "adjust"    - stock should move by the line item's delta
    """
    if order["status"] not in STOCK_REDUCED_STATUSES:
        return ("skip", "order not in a stock reducing status")
    if product is None:
        return ("orphan", "product for this line item no longer exists")
    if not product.get("manage_stock"):
        return ("unmanaged", "product does not manage stock")
    return ("adjust", "line item quantity no longer matches reduced stock")


def apply_delta(current_stock, delta):
    """New stock quantity after applying delta, in whole units, never negative."""
    return max(0, int(current_stock) + int(delta))
decide.js
export function decide(order, product) {
  if (!STOCK_REDUCED_STATUSES.has(order.status)) {
    return ["skip", "order not in a stock reducing status"];
  }
  if (!product) return ["orphan", "product for this line item no longer exists"];
  if (!product.manage_stock) return ["unmanaged", "product does not manage stock"];
  return ["adjust", "line item quantity no longer matches reduced stock"];
}

export function applyDelta(currentStock, delta) {
  return Math.max(0, Number(currentStock) + Number(delta));
}
5

Write the new stock and explain it on the order

When the action is adjust, set the product's stock to the corrected number through the REST API and add an order note describing exactly what changed. That note is what saves a shop manager from wondering why stock moved on its own weeks later.

apply.py
def set_stock(product_id, new_qty):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={"stock_quantity": new_qty, "manage_stock": True},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def add_note(order_id, note):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": note},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function setStock(productId, newQty) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({ stock_quantity: newQty, manage_stock: true }),
  });
}

async function addNote(orderId, note) {
  await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once an hour.

Run it safe

Always start with DRY_RUN=true. A stock reconciler writes to real product stock, 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 reconciler 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 moves stock by the difference that is actually missing.

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

reconcile_stock.py
"""Reconcile product stock after an order was edited in the WooCommerce admin.

WooCommerce reduces stock once, when an order first moves to a stock reducing
status, and stamps how much it took on each line item in `_reduced_stock` meta.
If a shop manager later edits the order (changes a quantity, removes a line,
adds a new line, deletes the whole order) WooCommerce does not revisit that
stock. This walks recent orders, compares each line item's current quantity
against its `_reduced_stock` meta, and restocks or further reduces the
difference so the product stock matches what the order actually charged for.

Read only by default (DRY_RUN=true). Run on a schedule.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth

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

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"

STOCK_REDUCED_STATUSES = {"processing", "completed", "on-hold"}


def reduced_stock_of(line_item):
    """The quantity WooCommerce already took out of stock for this line item,
    read from its `_reduced_stock` meta. Zero when the meta is missing, which
    means the line was added after the order's stock reduction ran."""
    for meta in line_item.get("meta_data") or []:
        if meta.get("key") == "_reduced_stock":
            try:
                return int(meta["value"])
            except (TypeError, ValueError):
                return 0
    return 0


def line_items_needing_sync(order):
    """Every stock managed line item whose current quantity does not match
    the quantity WooCommerce already reduced from stock."""
    if order["status"] not in STOCK_REDUCED_STATUSES:
        return []
    out = []
    for item in order.get("line_items") or []:
        if not item.get("product_id"):
            continue
        reduced = reduced_stock_of(item)
        current = int(item.get("quantity") or 0)
        if reduced != current:
            out.append({
                "product_id": item["product_id"],
                "variation_id": item.get("variation_id") or 0,
                "sku": item.get("sku", ""),
                "reduced": reduced,
                "current": current,
                "delta": current - reduced,
            })
    return out


def decide(order, product):
    """Pure decision for one out-of-sync line item against its product record.

    order    - a dict with at least "status" and the order id
    product  - a dict with at least "manage_stock" and "stock_quantity", or
               None when the product could not be found

    Returns (action, reason). Actions:
      "skip"    - nothing to do, order not in a stock reducing status
      "orphan"  - the product behind the line item no longer exists
      "unmanaged" - product does not track stock, so there is nothing to sync
      "adjust"  - stock should move by `delta` (negative reduces, positive restocks)
    """
    if order["status"] not in STOCK_REDUCED_STATUSES:
        return ("skip", "order not in a stock reducing status")
    if product is None:
        return ("orphan", "product for this line item no longer exists")
    if not product.get("manage_stock"):
        return ("unmanaged", "product does not manage stock")
    return ("adjust", "line item quantity no longer matches reduced stock")


def apply_delta(current_stock, delta):
    """New stock quantity after applying delta, in whole units, never negative."""
    return max(0, int(current_stock) + int(delta))


def get_order(order_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def get_product(product_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def recent_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": "processing,completed,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 set_stock(product_id, new_qty):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={"stock_quantity": new_qty, "manage_stock": True},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def add_note(order_id, note):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": note},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for order in recent_orders():
        items = line_items_needing_sync(order)
        for item in items:
            product = get_product(item["product_id"])
            action, reason = decide(order, product)
            if action in ("skip", "orphan", "unmanaged"):
                if action == "orphan":
                    log.warning("Order %s product %s missing: %s", order["id"], item["product_id"], reason)
                continue
            new_qty = apply_delta(product["stock_quantity"] or 0, item["delta"])
            log.info(
                "Order %s product %s: reduced=%s current=%s delta=%+d -> stock %s. %s",
                order["id"], item["product_id"], item["reduced"], item["current"],
                item["delta"], new_qty, "would fix" if DRY_RUN else "fixing",
            )
            if not DRY_RUN:
                set_stock(item["product_id"], new_qty)
                add_note(
                    order["id"],
                    f"Stock reconciled for product #{item['product_id']}: order edit changed the "
                    f"quantity from {item['reduced']} to {item['current']}, stock adjusted by "
                    f"{item['delta']:+d} to {new_qty}.",
                )
            fixed += 1
    log.info("Done. %d line item(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
reconcile-stock.js
/**
 * Reconcile product stock after an order was edited in the WooCommerce admin.
 *
 * WooCommerce reduces stock once, when an order first moves to a stock reducing
 * status, and stamps how much it took on each line item in `_reduced_stock` meta.
 * If a shop manager later edits the order (changes a quantity, removes a line,
 * adds a new line) WooCommerce does not revisit that stock. This walks recent
 * orders, compares each line item's current quantity against its
 * `_reduced_stock` meta, and restocks or further reduces the difference so the
 * product stock matches what the order actually charged for.
 *
 * Read only by default (DRY_RUN=true). Run on a schedule.
 */
import { pathToFileURL } from "node:url";

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 STOCK_REDUCED_STATUSES = new Set(["processing", "completed", "on-hold"]);

export function reducedStockOf(lineItem) {
  for (const meta of lineItem.meta_data || []) {
    if (meta.key === "_reduced_stock") {
      const n = parseInt(meta.value, 10);
      return Number.isNaN(n) ? 0 : n;
    }
  }
  return 0;
}

export function lineItemsNeedingSync(order) {
  if (!STOCK_REDUCED_STATUSES.has(order.status)) return [];
  const out = [];
  for (const item of order.line_items || []) {
    if (!item.product_id) continue;
    const reduced = reducedStockOf(item);
    const current = Number(item.quantity || 0);
    if (reduced !== current) {
      out.push({
        product_id: item.product_id,
        variation_id: item.variation_id || 0,
        sku: item.sku || "",
        reduced,
        current,
        delta: current - reduced,
      });
    }
  }
  return out;
}

export function decide(order, product) {
  if (!STOCK_REDUCED_STATUSES.has(order.status)) return ["skip", "order not in a stock reducing status"];
  if (!product) return ["orphan", "product for this line item no longer exists"];
  if (!product.manage_stock) return ["unmanaged", "product does not manage stock"];
  return ["adjust", "line item quantity no longer matches reduced stock"];
}

export function applyDelta(currentStock, delta) {
  return Math.max(0, Number(currentStock) + Number(delta));
}

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

async function* recentOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed,on-hold&after=${after}&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function setStock(productId, newQty) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({ stock_quantity: newQty, manage_stock: true }),
  });
}

async function addNote(orderId, note) {
  await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}

export async function run() {
  let fixed = 0;
  for await (const order of recentOrders()) {
    const items = lineItemsNeedingSync(order);
    for (const item of items) {
      const product = await woo(`/products/${item.product_id}`);
      const [action] = decide(order, product);
      if (action === "skip" || action === "orphan" || action === "unmanaged") {
        if (action === "orphan") console.warn(`Order ${order.id} product ${item.product_id} missing`);
        continue;
      }
      const newQty = applyDelta(product.stock_quantity || 0, item.delta);
      console.log(
        `Order ${order.id} product ${item.product_id}: reduced=${item.reduced} current=${item.current} ` +
        `delta=${item.delta > 0 ? "+" : ""}${item.delta} -> stock ${newQty}. ${DRY_RUN ? "would fix" : "fixing"}`
      );
      if (!DRY_RUN) {
        await setStock(item.product_id, newQty);
        await addNote(
          order.id,
          `Stock reconciled for product #${item.product_id}: order edit changed the quantity from ` +
          `${item.reduced} to ${item.current}, stock adjusted by ${item.delta > 0 ? "+" : ""}${item.delta} to ${newQty}.`
        );
      }
      fixed++;
    }
  }
  console.log(`Done. ${fixed} line item(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}

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

Add a test

The decision rules are the part most worth testing, because they decide whether real product stock gets touched. Because we kept decide and lineItemsNeedingSync pure, the tests need no network and no live store. They just feed in plain objects and check the outcome.

test_stock_decide.py
from reconcile_stock import decide, line_items_needing_sync, reduced_stock_of, apply_delta


def line_item(**over):
    base = {
        "product_id": 42,
        "quantity": 2,
        "meta_data": [{"key": "_reduced_stock", "value": "2"}],
    }
    base.update(over)
    return base


def product(**over):
    base = {"manage_stock": True, "stock_quantity": 10}
    base.update(over)
    return base


def test_needs_sync_when_quantity_was_edited_up():
    order = {"status": "processing", "line_items": [line_item(quantity=5)]}
    out = line_items_needing_sync(order)
    assert out[0]["delta"] == 3


def test_new_line_item_added_after_reduction_needs_full_sync():
    order = {"status": "processing", "line_items": [line_item(quantity=3, meta_data=[])]}
    out = line_items_needing_sync(order)
    assert out[0]["reduced"] == 0
    assert out[0]["delta"] == 3


def test_decide_adjust_when_stock_managed_and_order_paid():
    assert decide({"status": "completed"}, product())[0] == "adjust"


def test_apply_delta_never_goes_negative():
    assert apply_delta(2, -5) == 0
reconcile-stock.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, lineItemsNeedingSync, applyDelta } from "./reconcile-stock.js";

const lineItem = (over = {}) => ({
  product_id: 42,
  quantity: 2,
  meta_data: [{ key: "_reduced_stock", value: "2" }],
  ...over,
});

const product = (over = {}) => ({ manage_stock: true, stock_quantity: 10, ...over });

test("needs sync when quantity was edited up", () => {
  const order = { status: "processing", line_items: [lineItem({ quantity: 5 })] };
  assert.equal(lineItemsNeedingSync(order)[0].delta, 3);
});

test("new line item added after reduction needs full sync", () => {
  const order = { status: "processing", line_items: [lineItem({ quantity: 3, meta_data: [] })] };
  const out = lineItemsNeedingSync(order);
  assert.equal(out[0].reduced, 0);
  assert.equal(out[0].delta, 3);
});

test("decide adjust when stock managed and order paid", () => {
  assert.equal(decide({ status: "completed" }, product())[0], "adjust");
});

test("applyDelta never goes negative", () => {
  assert.equal(applyDelta(2, -5), 0);
});

Case studies

Wholesale desk

The reorder that never left the shelf

A B2B buyer called in to raise a Processing order's quantity from ten cases to twenty five. The support agent edited the order and saved it. The extra fifteen cases were shipped out that afternoon, but WooCommerce still believed only ten had ever left the warehouse.

Within a week, the product looked like it had fifteen more units on hand than it truly did, and two other customers placed orders the warehouse could not fill. The reconciler, run hourly, caught the mismatch on its very first pass after being installed and corrected the stock the same day.

Customer service

The line item that got removed, not the stock

A customer asked to drop one item from a two item Completed order and got a partial refund. The agent deleted the line item from the order in the admin. The order looked right afterward, but the product that was removed never had its stock given back.

Over a season, a dozen similar edits quietly reserved stock that no longer belonged to any order. Running the reconciler in dry run first surfaced the exact list, and turning it on for real released the stock back for sale.

What good looks like

After this runs on a schedule, editing an order is no longer a quiet way to drift your stock counts. Any gap between a line item's quantity and what was actually reduced gets found and corrected within the hour, with a clear note on the order for anyone who checks later. Keep it running even for stores that rarely edit orders, since the one time it happens is the one time it is easy to miss.

FAQ

Why doesn't WooCommerce update stock when I edit an order?

WooCommerce only reduces stock once, the first time an order moves to a stock reducing status, and records what it took in _reduced_stock meta on each line item. Editing the order afterward, changing a quantity, removing a line, or adding a product, never re-runs that stock reduction, so the two numbers drift apart.

Is it safe to let a script change my product stock automatically?

Yes, when the script only compares a line item's current quantity against the quantity WooCommerce already reduced, skips products that do not manage stock or no longer exist, and never lets stock go negative. Start in dry run mode to review the plan before it writes.

How often should the stock reconciler run?

Once every hour is plenty for most stores, since order edits are a manual, occasional action. Running it more often is harmless because it only touches line items that are already out of sync.

Related field notes

Citations

On the problem:

  1. WooCommerce developer docs: how and when stock is reduced for an order. woocommerce.com/document/managing-products
  2. WooCommerce core source: the _reduced_stock meta key and the stock reduction routine. github.com/woocommerce/woocommerce/wc-order-functions.php
  3. WooCommerce GitHub tracker: reports of stock not adjusting after order line items are edited. github.com/woocommerce/woocommerce/issues

On the solution:

  1. WooCommerce REST API: update a product's stock quantity. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: list and read orders, including line item meta data. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce REST API: add an order note. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your stock counts?

If this saved you a pile of overselling headaches or a warehouse recount, 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