Reconciler Inventory and Stock
availableStock not restored after order cancellation
A customer's order gets cancelled. The line items are gone, the order clearly shows cancelled, and the stock those items were holding should be free again. But the product still reads the old, lower availableStock, sometimes for minutes, sometimes until someone notices the storefront is refusing to sell units that are actually free. Nothing is broken forever, the number is just behind, because the recompute that frees the stock does not run inside the cancel request itself.
Shopware 6 does not recompute product.availableStock synchronously inside the order-cancel request. Calling POST /api/_action/order/{id}/state/cancel does trigger StockUpdater to release the line item's reserved quantity, but the actual recalculation of availableStock, stock minus the sum of quantities from orders still in an open state, is dispatched as an async message and only applied once the message queue worker (messenger:consume) processes it. If that worker is stalled or backlogged, or you read the product immediately after cancelling, the Admin API still returns the pre-cancellation availableStock even though the order already shows stateMachineState.technicalName equal to cancelled. The fix is a small reconciler: list recently cancelled orders, recompute what availableStock should be for every product they touched, and PATCH only the products where the live value disagrees with the recomputed one. Full code, tests, and sources are below.
The problem in plain words
When an order is placed, Shopware subtracts its line item quantities from each product's availableStock so the storefront cannot oversell stock that is already spoken for. When an order is cancelled, that reservation should come back. The state machine transition is instant, the order flips to cancelled the moment the API call returns.
The stock number is not that fast. Releasing a reservation means Shopware has to walk back through every order still counted as open for that product and recompute availableStock from scratch, and that recompute is not something Shopware does inline on the request that cancelled the order. It hands the work off to the message queue as an async message and moves on. If a worker process is consuming that queue promptly, the gap closes in a second or two and nobody notices. If the worker is stalled, restarting, or simply backlogged with other work, the gap can sit there far longer, and any client reading the product in that window sees stock that is wrong in the pessimistic direction: lower than what is truly available.
Why it happens
The cancel transition and the stock recompute are two different pieces of work that Shopware deliberately does not couple into one synchronous request. A few things make the gap visible in practice:
- The cancel state-machine transition,
POST /api/_action/order/{id}/state/cancel, only writes the order's state. Freeing the stock the order held is a side effect handled byStockUpdater, and that side effect is dispatched as a message, not executed inline. - Background work like this only runs when something is consuming the queue. If the store relies solely on the admin worker that runs while someone has the Administration open in a browser tab, closing that tab pauses the queue and the recompute never happens until it reopens.
- A supervised
messenger:consumeprocess can fall behind during a traffic spike, a deploy, or a slow message earlier in the queue, so even a healthy setup has a window where cancelled orders have not yet had their stock released. - Any client, script, or integration that reads
product.availableStockimmediately after calling the cancel transition is reading a value that has not caught up yet, and there is nothing in the cancel response itself that warns you the number is about to change.
This is a known rough edge in how Shopware 6 separates state changes from derived stock recomputation. The Shopware GitHub issue tracker and community forum both describe availableStock not updating promptly after order events, and Shopware's own stock refactoring discussion acknowledges the earlier model needed a rewrite for exactly this kind of staleness. See the citations at the end for the exact threads and docs.
availableStock is a derived cache field with no independent ledger meaning of its own. It only ever means stock minus the sum of quantities from orders still in an open state. That makes it safe to recompute and overwrite directly, because doing so has exactly the same effect as StockUpdater's own queued message finally being consumed. The fix is not to touch the order or the state machine at all. It is to recompute the number Shopware itself would produce, and write it, for the specific products a cancellation just affected.
The fix, as a flow
We never touch order state. The script lists orders that are already cancelled, collects the products their line items referenced, recomputes each product's expected availableStock from its physical stock and the quantities still open on other orders, and PATCHes only the products where the live value disagrees with that recomputed one.
Build it step by step
Get an Admin API access token
Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and the credentials in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" // start safe, change to false to write
Authenticate and call the Admin API
A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for every search and for the PATCH call.
import os, requests
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_post(path, token, body=None):
r = requests.post(
f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body or {},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function apiPost(path, token, body = {}) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
List recently cancelled orders and their products
Search order for stateMachineState.technicalName equals cancelled, with associations for lineItems and lineItems.product, then collect the distinct productId values the cancelled orders touched.
def search_cancelled_orders(token, limit=200):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "cancelled"}],
"associations": {"lineItems": {"associations": {"product": {}}}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def product_ids_from_cancelled_orders(orders):
ids = set()
for order in orders:
for line_item in order.get("lineItems") or []:
product_id = line_item.get("productId")
if product_id:
ids.add(product_id)
return ids
async function searchCancelledOrders(token, limit = 200) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "cancelled" }],
associations: { lineItems: { associations: { product: {} } } },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on order search`);
const data = await res.json();
return data.data;
}
function productIdsFromCancelledOrders(orders) {
const ids = new Set();
for (const order of orders) {
for (const lineItem of order.lineItems || []) {
if (lineItem.productId) ids.add(lineItem.productId);
}
}
return ids;
}
Decide, with one pure function
Keep the comparison separate from any network call. Given a product's stock and availableStock, plus the summed quantity of that product still held by orders that are not cancelled, decide the expected availableStock and whether the live value needs an update. Nothing here talks to the network, so it is trivial to test with fixture rows.
def decide_available_stock_correction(product, open_reservation_qty_for_product):
expected = max(product["stock"] - open_reservation_qty_for_product, 0)
needs_update = expected != product["availableStock"]
return {
"productId": product["id"],
"needsUpdate": needs_update,
"currentAvailableStock": product["availableStock"],
"expectedAvailableStock": expected,
}
export function decideAvailableStockCorrection(product, openReservationQtyForProduct) {
const expected = Math.max(product.stock - openReservationQtyForProduct, 0);
const needsUpdate = expected !== product.availableStock;
return {
productId: product.id,
needsUpdate,
currentAvailableStock: product.availableStock,
expectedAvailableStock: expected,
};
}
Compute the open reservation quantity per product
For each suspect product id, search order-line-item filtered by productId equals the product and order.stateMachineState.technicalName not-equals cancelled, and sum the quantities. That sum is exactly what StockUpdater itself would subtract from stock.
def open_reservation_qty(token, product_id, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [
{"type": "equals", "field": "productId", "value": product_id},
{"type": "not", "queries": [
{"type": "equals", "field": "order.stateMachineState.technicalName", "value": "cancelled"}
]},
],
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order-line-item",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
line_items = r.json()["data"]
return sum(item.get("quantity", 0) for item in line_items)
async function openReservationQty(token, productId, limit = 500) {
const body = {
page: 1,
limit,
filter: [
{ type: "equals", field: "productId", value: productId },
{ type: "not", queries: [
{ type: "equals", field: "order.stateMachineState.technicalName", value: "cancelled" },
] },
],
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order-line-item`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on order-line-item search`);
const data = await res.json();
return (data.data || []).reduce((sum, item) => sum + (item.quantity || 0), 0);
}
Re-sync only the products that disagree, never touch order state
When needsUpdate is true, call PATCH /api/product/{id} with availableStock set to the recomputed expected value. This is safe precisely because availableStock is a cache, not a ledger, so writing the correct value is indistinguishable from letting the queued message finally run.
def patch_available_stock(token, product_id, expected_available_stock):
r = requests.patch(
f"{SHOPWARE_URL}/api/product/{product_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"availableStock": expected_available_stock},
timeout=30,
)
r.raise_for_status()
async function patchAvailableStock(token, productId, expectedAvailableStock) {
const res = await fetch(`${SHOPWARE_URL}/api/product/${productId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ availableStock: expectedAvailableStock }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product patch`);
}
Wire it together with a dry run guard
The loop ties every piece together. For each product a cancelled order touched, fetch its live stock and availableStock, compute the open reservation quantity from every non-cancelled order, run the pure decision function, and only PATCH when it says the live value is stale.
Never write to stateId or any order field, this reconciler only ever touches availableStock on the product. Start with DRY_RUN=true, review the exact list of products it would patch and the diff between current and expected, and only let it write once you trust the list.
The full code
Here is the complete script in one file for each language. It authenticates, lists cancelled orders and the products they touched, recomputes each product's expected availableStock with the pure function, and patches only the products where the live value disagrees, respecting DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Reconcile Shopware 6 availableStock that stays stale right after an order
is cancelled, without ever touching order state or stateId.
Cancelling an order through the state machine (POST /api/_action/order/{id}/
state/cancel) triggers StockUpdater to release the reserved quantity, but the
recompute of availableStock is dispatched as an async message and only
applied once the message queue worker (messenger:consume) processes it. If
that worker is stalled or backlogged, the Admin API keeps returning the
pre-cancellation availableStock even though the order already shows
stateMachineState.technicalName equal to cancelled.
Lists recently cancelled orders, collects the products their line items
referenced, recomputes each product's expected availableStock from stock
minus the quantities still open on non-cancelled orders, and PATCHes only the
products where the live value disagrees. Run on a schedule, gated by
DRY_RUN. Safe to run again and again, because availableStock is a derived
cache field with no independent ledger meaning.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_available_stock_after_cancel")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def decide_available_stock_correction(product, open_reservation_qty_for_product):
"""Pure decision. No I/O. Takes plain numeric fields and returns a comparison."""
expected = max(product["stock"] - open_reservation_qty_for_product, 0)
needs_update = expected != product["availableStock"]
return {
"productId": product["id"],
"needsUpdate": needs_update,
"currentAvailableStock": product["availableStock"],
"expectedAvailableStock": expected,
}
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def search_cancelled_orders(token, limit=200):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "cancelled"}],
"associations": {"lineItems": {"associations": {"product": {}}}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def product_ids_from_cancelled_orders(orders):
ids = set()
for order in orders:
for line_item in order.get("lineItems") or []:
product_id = line_item.get("productId")
if product_id:
ids.add(product_id)
return ids
def get_product(token, product_id):
r = requests.get(
f"{SHOPWARE_URL}/api/product/{product_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
data = r.json()["data"]
attrs = data.get("attributes", data)
return {"id": product_id, "stock": attrs["stock"], "availableStock": attrs["availableStock"]}
def open_reservation_qty(token, product_id, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [
{"type": "equals", "field": "productId", "value": product_id},
{"type": "not", "queries": [
{"type": "equals", "field": "order.stateMachineState.technicalName", "value": "cancelled"}
]},
],
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order-line-item",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
line_items = r.json()["data"]
return sum(item.get("quantity", 0) for item in line_items)
def patch_available_stock(token, product_id, expected_available_stock):
r = requests.patch(
f"{SHOPWARE_URL}/api/product/{product_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"availableStock": expected_available_stock},
timeout=30,
)
r.raise_for_status()
def run():
token = get_token()
cancelled_orders = search_cancelled_orders(token)
product_ids = product_ids_from_cancelled_orders(cancelled_orders)
log.info("Found %d cancelled order(s) touching %d distinct product(s).", len(cancelled_orders), len(product_ids))
fixed = 0
for product_id in product_ids:
product = get_product(token, product_id)
reserved_qty = open_reservation_qty(token, product_id)
result = decide_available_stock_correction(product, reserved_qty)
if not result["needsUpdate"]:
continue
log.info(
"Product %s stale. current=%s expected=%s %s",
product_id, result["currentAvailableStock"], result["expectedAvailableStock"],
"would patch" if DRY_RUN else "patching",
)
if not DRY_RUN:
patch_available_stock(token, product_id, result["expectedAvailableStock"])
fixed += 1
log.info("Done. %d product(s) %s.", fixed, "to patch" if DRY_RUN else "patched")
if __name__ == "__main__":
run()
/**
* Reconcile Shopware 6 availableStock that stays stale right after an order
* is cancelled, without ever touching order state or stateId.
*
* Cancelling an order through the state machine (POST /api/_action/order/{id}/
* state/cancel) triggers StockUpdater to release the reserved quantity, but
* the recompute of availableStock is dispatched as an async message and only
* applied once the message queue worker (messenger:consume) processes it. If
* that worker is stalled or backlogged, the Admin API keeps returning the
* pre-cancellation availableStock even though the order already shows
* stateMachineState.technicalName equal to cancelled.
*
* Lists recently cancelled orders, collects the products their line items
* referenced, recomputes each product's expected availableStock from stock
* minus the quantities still open on non-cancelled orders, and PATCHes only
* the products where the live value disagrees. Run on a schedule, gated by
* DRY_RUN. Safe to run again and again, because availableStock is a derived
* cache field with no independent ledger meaning.
*
* Guide: https://www.allanninal.dev/shopware/available-stock-stale-after-cancellation/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideAvailableStockCorrection(product, openReservationQtyForProduct) {
const expected = Math.max(product.stock - openReservationQtyForProduct, 0);
const needsUpdate = expected !== product.availableStock;
return {
productId: product.id,
needsUpdate,
currentAvailableStock: product.availableStock,
expectedAvailableStock: expected,
};
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function searchCancelledOrders(token, limit = 200) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "cancelled" }],
associations: { lineItems: { associations: { product: {} } } },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on order search`);
const data = await res.json();
return data.data;
}
function productIdsFromCancelledOrders(orders) {
const ids = new Set();
for (const order of orders) {
for (const lineItem of order.lineItems || []) {
if (lineItem.productId) ids.add(lineItem.productId);
}
}
return ids;
}
async function getProduct(token, productId) {
const res = await fetch(`${SHOPWARE_URL}/api/product/${productId}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product get`);
const body = await res.json();
const attrs = body.data.attributes || body.data;
return { id: productId, stock: attrs.stock, availableStock: attrs.availableStock };
}
async function openReservationQty(token, productId, limit = 500) {
const body = {
page: 1,
limit,
filter: [
{ type: "equals", field: "productId", value: productId },
{ type: "not", queries: [
{ type: "equals", field: "order.stateMachineState.technicalName", value: "cancelled" },
] },
],
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order-line-item`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on order-line-item search`);
const data = await res.json();
return (data.data || []).reduce((sum, item) => sum + (item.quantity || 0), 0);
}
async function patchAvailableStock(token, productId, expectedAvailableStock) {
const res = await fetch(`${SHOPWARE_URL}/api/product/${productId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ availableStock: expectedAvailableStock }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product patch`);
}
export async function run() {
const token = await getToken();
const cancelledOrders = await searchCancelledOrders(token);
const productIds = productIdsFromCancelledOrders(cancelledOrders);
console.log(`Found ${cancelledOrders.length} cancelled order(s) touching ${productIds.size} distinct product(s).`);
let fixed = 0;
for (const productId of productIds) {
const product = await getProduct(token, productId);
const reservedQty = await openReservationQty(token, productId);
const result = decideAvailableStockCorrection(product, reservedQty);
if (!result.needsUpdate) continue;
console.log(
`Product ${productId} stale. current=${result.currentAvailableStock} expected=${result.expectedAvailableStock} ${DRY_RUN ? "would patch" : "patching"}`
);
if (!DRY_RUN) await patchAvailableStock(token, productId, result.expectedAvailableStock);
fixed++;
}
console.log(`Done. ${fixed} product(s) ${DRY_RUN ? "to patch" : "patched"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The comparison rule is the part most worth testing, because it decides which products get written to. Because decide_available_stock_correction is pure and takes only plain numeric fields, the test needs no network, no Shopware store, and no message queue.
from reconcile_available_stock_after_cancel import decide_available_stock_correction
def product(**over):
base = {"id": "prod-1", "stock": 100, "availableStock": 100}
base.update(over)
return base
def test_no_update_when_already_correct():
result = decide_available_stock_correction(product(availableStock=80), 20)
assert result["needsUpdate"] is False
assert result["expectedAvailableStock"] == 80
def test_needs_update_when_stale_after_cancellation():
# 20 was reserved, but the order was cancelled, so nothing should be held.
result = decide_available_stock_correction(product(availableStock=80), 0)
assert result["needsUpdate"] is True
assert result["expectedAvailableStock"] == 100
assert result["currentAvailableStock"] == 80
def test_expected_never_goes_below_zero():
result = decide_available_stock_correction(product(stock=5, availableStock=5), 20)
assert result["expectedAvailableStock"] == 0
assert result["needsUpdate"] is True
def test_returns_product_id():
result = decide_available_stock_correction(product(id="prod-42"), 0)
assert result["productId"] == "prod-42"
def test_no_update_when_reservation_matches_gap():
result = decide_available_stock_correction(product(stock=50, availableStock=30), 20)
assert result["needsUpdate"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideAvailableStockCorrection } from "./reconcile-available-stock-after-cancel.js";
const product = (over = {}) => ({ id: "prod-1", stock: 100, availableStock: 100, ...over });
test("no update when already correct", () => {
const result = decideAvailableStockCorrection(product({ availableStock: 80 }), 20);
assert.equal(result.needsUpdate, false);
assert.equal(result.expectedAvailableStock, 80);
});
test("needs update when stale after cancellation", () => {
const result = decideAvailableStockCorrection(product({ availableStock: 80 }), 0);
assert.equal(result.needsUpdate, true);
assert.equal(result.expectedAvailableStock, 100);
assert.equal(result.currentAvailableStock, 80);
});
test("expected never goes below zero", () => {
const result = decideAvailableStockCorrection(product({ stock: 5, availableStock: 5 }), 20);
assert.equal(result.expectedAvailableStock, 0);
assert.equal(result.needsUpdate, true);
});
test("returns product id", () => {
const result = decideAvailableStockCorrection(product({ id: "prod-42" }), 0);
assert.equal(result.productId, "prod-42");
});
test("no update when reservation matches gap", () => {
const result = decideAvailableStockCorrection(product({ stock: 50, availableStock: 30 }), 20);
assert.equal(result.needsUpdate, false);
});
Case studies
Bulk cancellations after a mispriced drop
A streetwear store ran a flash sale with a pricing bug that let a batch of orders through at the wrong price. Support cancelled around three hundred orders in one afternoon to unwind the mistake. Within minutes, the top sizes of the affected product still showed as sold out on the storefront, even though every one of those orders was already cancelled in the Administration.
The message queue worker had fallen behind during the sale's traffic spike and was still catching up on the earlier order-placed messages before it could even get to the cancellations. Running the reconciler in dry run showed the exact products still holding stale reservations. Patching just those closed the gap immediately, without waiting for the worker to grind through its backlog.
A returns app that reads stock right after cancelling
A store's returns and cancellation app calls the cancel transition, then immediately re-fetches the product to show the customer service rep the updated stock count. Reps kept reporting that the number displayed was wrong, always the old, lower figure, right after a cancellation went through.
The team added the reconciler as a scheduled job running every few minutes, so any gap between a cancellation and the queue catching up closes quickly on its own. They also fixed the app to stop trusting an immediate re-fetch as truth, but the reconciler remained as the safety net for whenever the worker is slow.
After this runs on a schedule, a cancelled order's stock comes back quickly even when the message queue worker is slow or stalled, because the reconciler recomputes and re-syncs exactly the products that need it. No order state is ever touched, only the cached number that Shopware's own indexer would eventually produce anyway. Keep an eye on why the queue fell behind in the first place, since this reconciler is a safety net, not a substitute for a healthy worker.
FAQ
Why does availableStock stay low right after I cancel a Shopware 6 order?
Cancelling an order through the state machine tells StockUpdater to release the reserved quantity, but the recompute of availableStock is dispatched as an async message rather than applied inside the cancel request. Until the message queue worker processes that message, the Admin API keeps returning the pre-cancellation availableStock even though the order already shows stateMachineState.technicalName equal to cancelled.
Is it safe to PATCH availableStock back to the expected value?
Yes. availableStock is a derived cache field with no independent ledger meaning, it only exists as stock minus the open reservations. Overwriting it with the correctly recomputed value has the same effect as StockUpdater's own queued message being consumed, so a guarded PATCH is safe as long as the recompute logic matches Shopware's own definition of an open order.
How do I stop this from happening again instead of just reconciling after the fact?
Make sure messenger:consume is running continuously as a supervised worker process, not only through the admin worker in the browser, and keep an eye on queue depth with the scheduled_task and message queue monitoring in the Shopware Administration. The reconciler in this guide is a safety net for when the worker stalls or falls behind, not a replacement for keeping the worker healthy.
Related field notes
Citations
On the problem:
- Product availableStock doesn't update, shopware/shopware Issue #3449. github.com/shopware/shopware/issues/3449
- Stock storage refactoring, shopware/shopware Discussion #3172. github.com/shopware/shopware/discussions/3172
- Demystifying the StockUpdater, Shopware Community Forum. forum.shopware.com/t/demystifying-the-stockupdater/70474
On the solution:
- Shopware Developer Documentation: Available stock improvements ADR. developer.shopware.com/docs/resources/references/adr/2022-03-25-available-stock.html
- Shopware Developer Documentation: Stock Manipulation API ADR. developer.shopware.com/docs/resources/references/adr/2023-05-15-stock-api.html
- Shopware Developer Documentation: Stock Configuration. developer.shopware.com/docs/guides/hosting/configurations/shopware/stock.html
Stuck on a tricky one?
If you have a problem in Shopware orders, inventory, the state machine, or the message queue 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 stock numbers?
If this saved you a confused support ticket about stock that was actually free, 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