Reconciler Inventory and Stock
Return processing does not restore product stock
A customer sends an item back. Support processes the return in the Administration, the return line items flip to returned, and the order shows the refund went out. Everyone assumes the unit is back on the shelf and sellable again. It is not. product.stock never moved, so availableStock is still short by exactly the quantity that just came back through the door. Nothing crashed, Shopware simply never wired returns to stock in the first place.
Shopware 6 does not automatically add a returned quantity back to product.stock when a return is processed. StockUpdater only listens for sell-side events, an order being placed or cancelled through the state machine, and has no built in reaction to an order line item's state moving to returned. The refund can go out, the return can look complete in the Administration, and the product still shows the same stock and availableStock it had while the item was out with the customer. The fix is a small reconciler: list order line items whose state is returned, keep only the ones a warehouse worker has actually confirmed back on the shelf with a restock tag, skip anything already credited, and PATCH the product's stock up by the returned quantity. Full code, tests, and sources are below.
The problem in plain words
When an order is placed, Shopware subtracts the line item quantities from product.stock so the number on the shelf matches what customers can still buy. That side is wired up and automatic. Returns are the mirror image, and Shopware simply never built the mirror.
Processing a return in the Administration changes the order line item's state to returned and can trigger a refund through the payment gateway. Neither of those steps touches product.stock. The unit can be sitting back on the warehouse shelf, checked in, ready to sell, and the storefront still reports the exact same availability it did the day the customer had the box in their hands. Anyone relying on availableStock to decide what to reorder or what to show as in stock is working from a number that is permanently short by every return that has ever been processed.
Why it happens
Shopware's stock system was built around the order lifecycle, not the return lifecycle, and a few things make the gap easy to miss until inventory drifts noticeably low:
StockUpdatersubscribes to order and order-line-item events on the sell side, placing an order, cancelling an order. There is no core subscriber for an order line item's state moving toreturned.- A refund through
order_transactiononly moves money. It has no relationship toproduct.stockat all, so seeing the refund succeed tells you nothing about whether the physical unit was ever accounted for. - Restocking a return is not always the right call. A damaged item, a final-sale product, or a unit routed to a different warehouse should not simply add one back to sellable stock, so Shopware leaves the decision to the merchant instead of guessing.
- Because the return workflow and the stock workflow are unrelated, a store can process hundreds of returns correctly, refund every customer, and still have a product page or a reorder report built entirely on stale
availableStocknumbers.
This is a known gap between how Shopware models returns and how it models stock, discussed on the Shopware GitHub issue tracker and the community forum where merchants ask why returned items never reappear as available. See the citations at the end for the exact threads and docs.
Restocking a return is a judgment call, not a mechanical recompute like availableStock is. That is exactly why Shopware does not do it automatically, and exactly why the fix should not either. The safe pattern is not "add the quantity back for every returned line item." It is "add it back only for the line items a human at the warehouse has actually confirmed as restockable," and never add it twice. We do that with a restock tag added once the unit is checked in, and a guard that skips any line item already credited.
The fix, as a flow
We never touch the return or the order. The script lists order line items whose state is returned, keeps only the ones that carry your restock confirmation tag and that have not already been credited, and PATCHes the product's stock up by the returned quantity, recording the credit so the same line item is never counted twice.
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 RESTOCK_TAG="restock-confirmed"
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 RESTOCK_TAG="restock-confirmed"
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 returned order line items and their products
Search order-line-item for state.technicalName equals returned, with the product association, and read back the fields the decision needs: the id, the quantity, the tags, and a custom field we use to record a prior credit so the same return is never counted twice.
def search_returned_line_items(token, limit=200):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "state.technicalName", "value": "returned"}],
"associations": {"product": {}},
"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()
return r.json()["data"]
async function searchReturnedLineItems(token, limit = 200) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "state.technicalName", value: "returned" }],
associations: { product: {} },
"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;
}
Decide, with one pure function
Keep the decision in its own function that takes a line item and the required restock tag and returns whether it should be credited plus the quantity to add. A pure function like this is easy to read and easy to test. The rule is strict on purpose. The line item's state must be returned, it must carry the restock confirmation tag, and it must not already have been credited. If any of those is missing, we do not touch the product.
def decide_restock(line_item, required_tag):
if line_item.get("stateTechnicalName") != "returned":
return {"shouldRestock": False, "quantity": 0}
if required_tag not in (line_item.get("tags") or []):
return {"shouldRestock": False, "quantity": 0}
if (line_item.get("customFields") or {}).get("restocked_at"):
return {"shouldRestock": False, "quantity": 0}
quantity = line_item.get("quantity") or 0
return {"shouldRestock": quantity > 0, "quantity": quantity}
export function decideRestock(lineItem, requiredTag) {
if (lineItem.stateTechnicalName !== "returned") return { shouldRestock: false, quantity: 0 };
if (!(lineItem.tags || []).includes(requiredTag)) return { shouldRestock: false, quantity: 0 };
if ((lineItem.customFields || {}).restocked_at) return { shouldRestock: false, quantity: 0 };
const quantity = lineItem.quantity || 0;
return { shouldRestock: quantity > 0, quantity };
}
Add the quantity back to product.stock
When a line item should be restocked, PATCH the product's stock to its current value plus the returned quantity, then PATCH the line item's customFields.restocked_at so the same return can never be credited twice, even if the job runs again before the tag is removed.
import datetime
def restock_product(token, product_id, current_stock, quantity):
r = requests.patch(
f"{SHOPWARE_URL}/api/product/{product_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"stock": current_stock + quantity},
timeout=30,
)
r.raise_for_status()
def mark_line_item_restocked(token, line_item_id):
r = requests.patch(
f"{SHOPWARE_URL}/api/order-line-item/{line_item_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"customFields": {"restocked_at": datetime.datetime.utcnow().isoformat() + "Z"}},
timeout=30,
)
r.raise_for_status()
async function restockProduct(token, productId, currentStock, quantity) {
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({ stock: currentStock + quantity }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product patch`);
}
async function markLineItemRestocked(token, lineItemId) {
const res = await fetch(`${SHOPWARE_URL}/api/order-line-item/${lineItemId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ customFields: { restocked_at: new Date().toISOString() } }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on line item patch`);
}
Wire it together with a dry run guard
The loop ties every piece together. For each returned line item, run the pure decision function, and only write when it says the item should be restocked. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which products it would restock and by how much. Read the output, agree with it, then switch it off to let it write.
Never touch the order, the return, or stateId, this script only ever adjusts product.stock and records a credit on the line item. Always start with DRY_RUN=true, and only tag a line item once a warehouse worker has actually checked the returned unit back in as sellable.
The full code
Here is the complete script in one file for each language. It authenticates, lists returned order line items, applies the pure decision function, restores the product's stock for the ones confirmed restockable, and records the credit so the same return is never double counted, respecting DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Restore Shopware 6 product stock for returns that Shopware itself never
restocks, without ever touching the order, the return, or stateId.
StockUpdater only reacts to sell-side order events, placing or cancelling an
order. There is no core listener that turns an order line item's state
moving to returned into a stock increase, because a returned item might be
damaged, final sale, or routed elsewhere, and only a human can decide that.
Lists order line items whose state is returned, keeps only the ones that
carry a restock confirmation tag a warehouse worker adds once the unit is
checked in, skips anything already credited, and adds the returned quantity
back to product.stock. Run on a schedule, gated by DRY_RUN. Safe to run
again and again, because every credit is recorded on the line item itself.
"""
import os
import datetime
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("restore_return_stock")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
RESTOCK_TAG = os.environ.get("RESTOCK_TAG", "restock-confirmed")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def decide_restock(line_item, required_tag):
"""Pure decision. No I/O. Takes a plain line item dict and a tag name."""
if line_item.get("stateTechnicalName") != "returned":
return {"shouldRestock": False, "quantity": 0}
if required_tag not in (line_item.get("tags") or []):
return {"shouldRestock": False, "quantity": 0}
if (line_item.get("customFields") or {}).get("restocked_at"):
return {"shouldRestock": False, "quantity": 0}
quantity = line_item.get("quantity") or 0
return {"shouldRestock": quantity > 0, "quantity": quantity}
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_returned_line_items(token, limit=200):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "state.technicalName", "value": "returned"}],
"associations": {"product": {}},
"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()
return r.json()["data"]
def to_plain_line_item(raw):
attrs = raw.get("attributes", raw)
product = (attrs.get("product") or {})
product_attrs = product.get("attributes", product)
return {
"id": attrs["id"],
"productId": attrs.get("productId"),
"quantity": attrs.get("quantity"),
"tags": attrs.get("tags") or [],
"customFields": attrs.get("customFields") or {},
"stateTechnicalName": ((attrs.get("state") or {}).get("technicalName")),
"productStock": product_attrs.get("stock"),
}
def restock_product(token, product_id, current_stock, quantity):
r = requests.patch(
f"{SHOPWARE_URL}/api/product/{product_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"stock": current_stock + quantity},
timeout=30,
)
r.raise_for_status()
def mark_line_item_restocked(token, line_item_id):
r = requests.patch(
f"{SHOPWARE_URL}/api/order-line-item/{line_item_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"customFields": {"restocked_at": datetime.datetime.utcnow().isoformat() + "Z"}},
timeout=30,
)
r.raise_for_status()
def run():
token = get_token()
raw_line_items = search_returned_line_items(token)
log.info("Found %d returned line item(s).", len(raw_line_items))
fixed = 0
for raw in raw_line_items:
line_item = to_plain_line_item(raw)
result = decide_restock(line_item, RESTOCK_TAG)
if not result["shouldRestock"]:
continue
log.info(
"Line item %s confirmed for restock. quantity=%d %s",
line_item["id"], result["quantity"],
"would restock" if DRY_RUN else "restocking",
)
if not DRY_RUN:
restock_product(token, line_item["productId"], line_item["productStock"] or 0, result["quantity"])
mark_line_item_restocked(token, line_item["id"])
fixed += 1
log.info("Done. %d line item(s) %s.", fixed, "to restock" if DRY_RUN else "restocked")
if __name__ == "__main__":
run()
/**
* Restore Shopware 6 product stock for returns that Shopware itself never
* restocks, without ever touching the order, the return, or stateId.
*
* StockUpdater only reacts to sell-side order events, placing or cancelling
* an order. There is no core listener that turns an order line item's state
* moving to returned into a stock increase, because a returned item might
* be damaged, final sale, or routed elsewhere, and only a human can decide
* that.
*
* Lists order line items whose state is returned, keeps only the ones that
* carry a restock confirmation tag a warehouse worker adds once the unit is
* checked in, skips anything already credited, and adds the returned
* quantity back to product.stock. Run on a schedule, gated by DRY_RUN. Safe
* to run again and again, because every credit is recorded on the line item
* itself.
*
* Guide: https://www.allanninal.dev/shopware/return-stock-not-restored/
*/
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 RESTOCK_TAG = process.env.RESTOCK_TAG || "restock-confirmed";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideRestock(lineItem, requiredTag) {
if (lineItem.stateTechnicalName !== "returned") return { shouldRestock: false, quantity: 0 };
if (!(lineItem.tags || []).includes(requiredTag)) return { shouldRestock: false, quantity: 0 };
if ((lineItem.customFields || {}).restocked_at) return { shouldRestock: false, quantity: 0 };
const quantity = lineItem.quantity || 0;
return { shouldRestock: quantity > 0, quantity };
}
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 searchReturnedLineItems(token, limit = 200) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "state.technicalName", value: "returned" }],
associations: { product: {} },
"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;
}
function toPlainLineItem(raw) {
const attrs = raw.attributes || raw;
const product = attrs.product || {};
const productAttrs = product.attributes || product;
return {
id: attrs.id,
productId: attrs.productId,
quantity: attrs.quantity,
tags: attrs.tags || [],
customFields: attrs.customFields || {},
stateTechnicalName: (attrs.state || {}).technicalName,
productStock: productAttrs.stock,
};
}
async function restockProduct(token, productId, currentStock, quantity) {
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({ stock: currentStock + quantity }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product patch`);
}
async function markLineItemRestocked(token, lineItemId) {
const res = await fetch(`${SHOPWARE_URL}/api/order-line-item/${lineItemId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ customFields: { restocked_at: new Date().toISOString() } }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on line item patch`);
}
export async function run() {
const token = await getToken();
const rawLineItems = await searchReturnedLineItems(token);
console.log(`Found ${rawLineItems.length} returned line item(s).`);
let fixed = 0;
for (const raw of rawLineItems) {
const lineItem = toPlainLineItem(raw);
const result = decideRestock(lineItem, RESTOCK_TAG);
if (!result.shouldRestock) continue;
console.log(
`Line item ${lineItem.id} confirmed for restock. quantity=${result.quantity} ${DRY_RUN ? "would restock" : "restocking"}`
);
if (!DRY_RUN) {
await restockProduct(token, lineItem.productId, lineItem.productStock || 0, result.quantity);
await markLineItemRestocked(token, lineItem.id);
}
fixed++;
}
console.log(`Done. ${fixed} line item(s) ${DRY_RUN ? "to restock" : "restocked"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which returns actually get credited back to stock. Because decide_restock is pure and takes only plain fields, the test needs no network, no Shopware store, and no return workflow.
from restore_return_stock import decide_restock
def line_item(**over):
base = {"stateTechnicalName": "returned", "tags": ["restock-confirmed"], "customFields": {}, "quantity": 2}
base.update(over)
return base
def test_restocks_when_returned_tagged_and_uncredited():
result = decide_restock(line_item(), "restock-confirmed")
assert result["shouldRestock"] is True
assert result["quantity"] == 2
def test_skip_when_not_returned():
result = decide_restock(line_item(stateTechnicalName="open"), "restock-confirmed")
assert result["shouldRestock"] is False
def test_skip_without_tag():
result = decide_restock(line_item(tags=[]), "restock-confirmed")
assert result["shouldRestock"] is False
def test_skip_when_already_credited():
result = decide_restock(line_item(customFields={"restocked_at": "2026-07-01T00:00:00Z"}), "restock-confirmed")
assert result["shouldRestock"] is False
def test_skip_when_quantity_is_zero():
result = decide_restock(line_item(quantity=0), "restock-confirmed")
assert result["shouldRestock"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRestock } from "./restore-return-stock.js";
const lineItem = (over = {}) => ({ stateTechnicalName: "returned", tags: ["restock-confirmed"], customFields: {}, quantity: 2, ...over });
test("restocks when returned, tagged, and uncredited", () => {
const result = decideRestock(lineItem(), "restock-confirmed");
assert.equal(result.shouldRestock, true);
assert.equal(result.quantity, 2);
});
test("skip when not returned", () => {
const result = decideRestock(lineItem({ stateTechnicalName: "open" }), "restock-confirmed");
assert.equal(result.shouldRestock, false);
});
test("skip without tag", () => {
const result = decideRestock(lineItem({ tags: [] }), "restock-confirmed");
assert.equal(result.shouldRestock, false);
});
test("skip when already credited", () => {
const result = decideRestock(lineItem({ customFields: { restocked_at: "2026-07-01T00:00:00Z" } }), "restock-confirmed");
assert.equal(result.shouldRestock, false);
});
test("skip when quantity is zero", () => {
const result = decideRestock(lineItem({ quantity: 0 }), "restock-confirmed");
assert.equal(result.shouldRestock, false);
});
Case studies
A best seller looked sold out while boxes sat in the returns bin
A clothing store ran a size that sold out fast, and returns for that size came in steadily as customers exchanged for a different fit. The warehouse checked every returned unit back in, refunds went out on time, but availableStock for that size never moved. The product page kept showing sold out for weeks while good stock sat on a shelf ten feet from the picking station.
Once the team tagged each checked-in return with restock-confirmed, an hourly run of the reconciler added the quantity back the same day it was verified. The size came back in stock within hours of the return being processed instead of sitting invisible until someone did a manual count.
Reorder decisions built on numbers that only ever went down
A store's reorder report pulled straight from availableStock to flag what to buy more of. Because returns never restocked, the report treated every returned unit as permanently gone, and the buying team kept ordering more of a product that already had enough stock once returns were accounted for.
Running the script in dry run first showed exactly how much quantity was sitting uncredited across hundreds of line items. After turning it on for real on a nightly schedule, the reorder report started reflecting what was actually on the shelf, and the buying team stopped over-ordering products that returns had already replenished.
After this runs on a schedule, a confirmed return adds its quantity back to product.stock within the hour instead of sitting invisible forever, and availableStock follows automatically the next time Shopware recomputes it. No order, return, or state machine field is ever touched, and no return is ever credited twice because each one records its own credit. Keep the restock tag with a human at the warehouse, since that is what keeps damaged or final-sale returns from being added back by mistake.
FAQ
Why does completing a return not add the stock back automatically in Shopware 6?
StockUpdater only reacts to order and order line item state changes on the sell side, such as an order being placed or cancelled. Marking a return's order line items as returned changes the line item state, but Shopware core has no built in listener that turns that into a stock increment, because a returned item might be damaged, restocked to a different warehouse, or scrapped, and only a human can decide that.
Is it safe to add the returned quantity back to stock with a script?
Yes, when the script only touches order line items whose state is returned, that carry a restock confirmation tag or custom field you set once a warehouse worker has actually checked the item back in, and that have not already been credited. Checking for a prior credit before writing again is what keeps the script safe to run on a schedule without double counting stock.
What is the difference between product.stock and product.availableStock here?
product.stock is the physical count in the warehouse, and product.availableStock is stock minus the sum of quantities still held by open orders. Restoring a return means increasing stock by the returned quantity. availableStock then follows automatically the next time Shopware recomputes it, because it is a derived cache field, not something you patch by hand.
Related field notes
Stuck on a tricky one?
If you have a problem in Shopware orders, returns, inventory, or the state machine 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 return stock?
If this saved you a confused reorder report or a product that looked sold out while returns sat on the shelf, 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