Reconciler Inventory and Stock

Product available forced to zero when isCloseout is null

A product gets created through the Admin API, or imported by a tool that never touches the closeout flag, and isCloseout sits at null. Stock is fine. minPurchase is fine. But the storefront still says the product is not available, and nothing about the product data looks wrong at a glance. Here is why Shopware's own availability math breaks on a null closeout flag, and a small script that finds every product this hits and repairs it safely.

Python and Node.js Shopware Admin API Safe by default (dry run)
The short answer

Shopware 6's Shopware\Core\Content\Product\Stock\StockStorage recalculates product.available with a single SQL UPDATE built around IFNULL(product.is_closeout, parent.is_closeout) * product.stock >= IFNULL(product.is_closeout, parent.is_closeout) * IFNULL(product.min_purchase, parent.min_purchase), all wrapped in an outer IFNULL(...,0). When is_closeout is null on both the variant and its parent, which happens for products created or imported through the Admin API without the field ever being set, the inner comparison itself evaluates to null instead of true or false, so the outer IFNULL collapses the whole thing to zero. available comes out false even when stock and minPurchase would otherwise say yes. Shopware confirmed this as GitHub issue 5465 and internal ticket NEXT-39546, fixed in release 6.6.10.0. Run a small Python or Node.js script that finds every product still hitting this, applies a pure decision function that only normalizes isCloseout to false when it is genuinely safe to do so, and lets you verify the repair before it writes anything. Full code, tests, and sources are below.

The problem in plain words

Every product in Shopware has an isCloseout flag. It is a boolean, either true or false, meant to tell Shopware whether the product should be treated as sold out the moment stock hits zero, or whether it can keep being sold past zero stock as a backorder. The column itself is nullable in the database, and nothing in the Admin API or in most import tools forces a value onto it, so a product created without explicitly setting isCloseout lands with the column at null, not at true or false.

That would be harmless on its own, except StockStorage uses isCloseout directly inside a boolean comparison in raw SQL, and SQL null does not behave like a normal boolean. Multiplying or comparing against null does not produce true or false, it produces null, and null propagates through the whole expression. The end result is that a product with in-stock inventory and a sane minPurchase gets its available flag forced to zero, purely because the closeout flag was never set to anything at all.

Admin API / import creates product, isCloseout unset is_closeout = NULL on variant and parent SQL comparison is NULL not true, not false stock >= minPurchase never checked available = false outer IFNULL defaults to zero stock is fine, but the product still shows unavailable
The null closeout flag never resolves to true or false inside the SQL comparison, so the outer IFNULL silently forces available to false.

Why it happens

This comes down to how StockStorage expresses the availability rule directly in SQL, and how SQL null interacts with arithmetic and comparisons. A few common ways stores end up here:

The key insight

A null isCloseout is not the same thing as false, even though the storefront ends up behaving as if it were the more restrictive true. The fix is not to reach for the effective value Shopware falls back to and trust it blindly, it is to check whether the variant or its parent supplies any non-null value at all. If a parent value exists, Shopware's own fallback already resolves the comparison correctly, and nothing needs to change. Only when both are null does the bug actually apply, and even then, the repair should never touch a product that is genuinely out of stock, since a null closeout flag on a zero-stock product is ambiguous and needs a human decision, not an automatic patch.

The fix, as a flow

We never patch available directly, since it is a value Shopware recomputes on its own. The script authenticates, searches for products whose isCloseout is null, reads each one's parent to check whether the parent supplies a usable fallback, and runs a pure decision function that classifies every candidate as a safe patch, a case for a human to review, or a case Shopware's fallback already handles. Only the safe patches get written, each guarded behind a dry run flag, and the message queue then reprocesses the product so available gets recomputed the correct way.

Search products isCloseout: null Read parent fallback GET parent isCloseout decideIsCloseoutRepair pure function patch_false? stock > 0 yes, patch it PATCH isCloseout worker reindexes no, skip / flag
The script only patches isCloseout to false when the pure function says it is genuinely safe. Ambiguous zero-stock products are flagged for a human instead.

Build it step by step

1

Get Admin API credentials

Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and talk to the Admin API

A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the product search, the parent lookup, and the repair write.

step2.py
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,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
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", Accept: "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 api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Find every product with a null isCloseout

Search for products where isCloseout equals null, reading back id, productNumber, stock, minPurchase, available, and parentId. Sort by productNumber for a stable, readable order across pages.

step3.py
def find_null_closeout_products(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [{"type": "equals", "field": "isCloseout", "value": None}],
        "associations": {"visibilities": {}},
        "sort": [{"field": "productNumber", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/product", token, body)
step3.js
async function findNullCloseoutProducts(token, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [{ type: "equals", field: "isCloseout", value: null }],
    associations: { visibilities: {} },
    sort: [{ field: "productNumber", order: "ASC" }],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/product", token, body);
}
4

Read the parent's isCloseout for variants

StockStorage falls back to the parent's isCloseout when the variant's own value is null, so a variant is only truly affected when both are null. For every hit that carries a parentId, fetch the parent product and read its isCloseout before deciding anything.

step4.py
def get_parent_is_closeout(token, parent_id):
    if not parent_id:
        return None
    data = api("GET", f"/api/product/{parent_id}", token)
    return data.get("data", {}).get("isCloseout")
step4.js
async function getParentIsCloseout(token, parentId) {
  if (!parentId) return null;
  const data = await api("GET", `/api/product/${parentId}`, token);
  return data.data?.isCloseout ?? null;
}
5

Decide, with one pure function

Keep the decision logic in its own pure function that takes only plain data and returns an action, never touching the network. decideIsCloseoutRepair skips products whose isCloseout is already set, skips products where a non-null parent value already resolves correctly through Shopware's own fallback, flags zero or negative stock products for a human, and only returns patch_false when both the variant and its effective parent value are null and stock is positive.

decide.py
def decide_is_closeout_repair(product):
    if product["isCloseout"] is not None:
        return {"action": "skip", "reason": "isCloseout already set"}

    effective = product["isCloseout"] if product["isCloseout"] is not None else product["parentIsCloseout"]
    if effective is not None:
        return {"action": "skip", "reason": "parent supplies non-null closeout, StockStorage fallback resolves correctly"}

    if product["stock"] <= 0:
        return {"action": "flag_review", "reason": "zero/negative stock with null isCloseout is ambiguous, do not blind-patch"}

    return {"action": "patch_false", "reason": "null isCloseout with positive stock incorrectly zeroes availability per NEXT-39546, normalize to false"}
decide.js
export function decideIsCloseoutRepair(product) {
  if (product.isCloseout !== null) {
    return { action: "skip", reason: "isCloseout already set" };
  }

  const effective = product.isCloseout ?? product.parentIsCloseout;
  if (effective !== null) {
    return { action: "skip", reason: "parent supplies non-null closeout, StockStorage fallback resolves correctly" };
  }

  if (product.stock <= 0) {
    return { action: "flag_review", reason: "zero/negative stock with null isCloseout is ambiguous, do not blind-patch" };
  }

  return { action: "patch_false", reason: "null isCloseout with positive stock incorrectly zeroes availability per NEXT-39546, normalize to false" };
}
6

Patch, verify, and let the worker reindex

For every product the pure function marks patch_false, PATCH /api/product/{id} with {"isCloseout": false}. This is a plain field write, not a state machine transition, since isCloseout is not a stateId. Each write enqueues the ProductIndexer and StockStorage subscriber onto the message queue, so messenger:consume or the scheduled task worker must be running to actually reprocess and republish availability, the admin-only worker will stall this. After the queue drains, GET /api/product/{id} again to confirm available flipped to true.

repair.py
def repair_is_closeout(token, product_id):
    api("PATCH", f"/api/product/{product_id}", token, {"isCloseout": False})
    refreshed = api("GET", f"/api/product/{product_id}", token)
    return refreshed["data"]["available"]
repair.js
async function repairIsCloseout(token, productId) {
  await api("PATCH", `/api/product/${productId}`, token, { isCloseout: false });
  const refreshed = await api("GET", `/api/product/${productId}`, token);
  return refreshed.data.available;
}
Run it safe

Always start with DRY_RUN=true, which only logs the planned {id, productNumber, from: null, to: false} change for every product the pure function marks patch_false, and writes nothing. Every row the function marks flag_review, meaning stock is zero or negative with a null closeout flag, goes into a separate report instead of being auto-patched, since a genuinely out-of-stock item with an ambiguous closeout flag needs a human decision. Confirm the dry run output looks right, then switch DRY_RUN to false and make sure messenger:consume or the scheduled task worker is actually running before you do, or the write will queue but never reprocess.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, routes ambiguous zero-stock rows to a review report instead of patching them, and is safe to run again and again because it only ever writes isCloseout: false to products the pure function has classified as safe.

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

reconcile_null_closeout.py
"""Find and repair Shopware 6 products whose available is forced to false by a null isCloseout.

StockStorage recalculates product.available with a single SQL comparison built on
IFNULL(product.is_closeout, parent.is_closeout) * product.stock >= IFNULL(product.is_closeout,
parent.is_closeout) * IFNULL(product.min_purchase, parent.min_purchase), wrapped in an outer
IFNULL(...,0). When is_closeout is null on both the variant and its parent, the inner
comparison evaluates to null instead of true or false, so the outer IFNULL collapses the
whole thing to zero, forcing available to false even when stock and minPurchase would
otherwise allow the sale. Confirmed as GitHub issue 5465 / NEXT-39546, fixed in 6.6.10.0.

This script never patches available directly. It finds every product with a null
isCloseout, reads the parent's value as a fallback, and runs a pure decision function
that only patches isCloseout to false when it is genuinely safe: already-set values and
products whose parent supplies a usable fallback are skipped, zero or negative stock
products are flagged for a human, and only a null isCloseout with positive stock gets
patched. Run on demand or on a schedule. Safe to run again and again.
"""
import os
import json
import logging
import requests

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

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 get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def find_null_closeout_products(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [{"type": "equals", "field": "isCloseout", "value": None}],
        "associations": {"visibilities": {}},
        "sort": [{"field": "productNumber", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/product", token, body)


def get_parent_is_closeout(token, parent_id):
    if not parent_id:
        return None
    data = api("GET", f"/api/product/{parent_id}", token)
    return data.get("data", {}).get("isCloseout")


def decide_is_closeout_repair(product):
    """Pure, no I/O. product: {id, isCloseout, parentIsCloseout, stock, minPurchase}."""
    if product["isCloseout"] is not None:
        return {"action": "skip", "reason": "isCloseout already set"}

    effective = product["isCloseout"] if product["isCloseout"] is not None else product["parentIsCloseout"]
    if effective is not None:
        return {"action": "skip", "reason": "parent supplies non-null closeout, StockStorage fallback resolves correctly"}

    if product["stock"] <= 0:
        return {"action": "flag_review", "reason": "zero/negative stock with null isCloseout is ambiguous, do not blind-patch"}

    return {"action": "patch_false", "reason": "null isCloseout with positive stock incorrectly zeroes availability per NEXT-39546, normalize to false"}


def repair_is_closeout(token, product_id):
    api("PATCH", f"/api/product/{product_id}", token, {"isCloseout": False})
    refreshed = api("GET", f"/api/product/{product_id}", token)
    return refreshed["data"]["available"]


def run():
    token = get_token()
    patched = []
    flagged = []
    page = 1

    while True:
        data = find_null_closeout_products(token, page=page)
        rows = data.get("data", [])
        if not rows:
            break

        for row in rows:
            product = {
                "id": row["id"],
                "productNumber": row.get("productNumber"),
                "isCloseout": row.get("isCloseout"),
                "parentIsCloseout": get_parent_is_closeout(token, row.get("parentId")),
                "stock": row.get("stock", 0),
                "minPurchase": row.get("minPurchase"),
            }
            decision = decide_is_closeout_repair(product)

            if decision["action"] == "skip":
                continue

            if decision["action"] == "flag_review":
                log.warning(
                    "Product %s flagged for review. stock=%s reason=%s",
                    product["productNumber"], product["stock"], decision["reason"],
                )
                flagged.append({**product, "reason": decision["reason"]})
                continue

            record = {"id": product["id"], "productNumber": product["productNumber"], "from": None, "to": False}
            log.warning(
                "Product %s eligible to patch isCloseout. %s",
                product["productNumber"],
                "dry run, would PATCH isCloseout=false" if DRY_RUN else "patching now",
            )

            if not DRY_RUN:
                available = repair_is_closeout(token, product["id"])
                record["confirmedAvailable"] = available
                if not available and product["stock"] > 0:
                    log.error(
                        "Product %s still not available after repair. Check messenger:consume is running.",
                        product["productNumber"],
                    )

            patched.append(record)

        if page * 500 >= data.get("total", 0):
            break
        page += 1

    log.info(
        "Done. %d product(s) %s, %d flagged for review.",
        len(patched), "to patch" if DRY_RUN else "patched", len(flagged),
    )
    print(json.dumps({"patched": patched, "flagged_review": flagged}, indent=2))
    return {"patched": patched, "flagged_review": flagged}


if __name__ == "__main__":
    run()
reconcile-null-closeout.js
/**
 * Find and repair Shopware 6 products whose available is forced to false by a null isCloseout.
 *
 * StockStorage recalculates product.available with a single SQL comparison built on
 * IFNULL(product.is_closeout, parent.is_closeout) * product.stock >= IFNULL(product.is_closeout,
 * parent.is_closeout) * IFNULL(product.min_purchase, parent.min_purchase), wrapped in an outer
 * IFNULL(...,0). When is_closeout is null on both the variant and its parent, the inner
 * comparison evaluates to null instead of true or false, so the outer IFNULL collapses the
 * whole thing to zero, forcing available to false even when stock and minPurchase would
 * otherwise allow the sale. Confirmed as GitHub issue 5465 / NEXT-39546, fixed in 6.6.10.0.
 *
 * This script never patches available directly. It finds every product with a null
 * isCloseout, reads the parent's value as a fallback, and runs a pure decision function
 * that only patches isCloseout to false when it is genuinely safe: already-set values and
 * products whose parent supplies a usable fallback are skipped, zero or negative stock
 * products are flagged for a human, and only a null isCloseout with positive stock gets
 * patched. Run on demand or on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopware/product-available-zero-null-closeout/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decideIsCloseoutRepair(product) {
  if (product.isCloseout !== null) {
    return { action: "skip", reason: "isCloseout already set" };
  }

  const effective = product.isCloseout ?? product.parentIsCloseout;
  if (effective !== null) {
    return { action: "skip", reason: "parent supplies non-null closeout, StockStorage fallback resolves correctly" };
  }

  if (product.stock <= 0) {
    return { action: "flag_review", reason: "zero/negative stock with null isCloseout is ambiguous, do not blind-patch" };
  }

  return { action: "patch_false", reason: "null isCloseout with positive stock incorrectly zeroes availability per NEXT-39546, normalize to false" };
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "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 api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function findNullCloseoutProducts(token, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [{ type: "equals", field: "isCloseout", value: null }],
    associations: { visibilities: {} },
    sort: [{ field: "productNumber", order: "ASC" }],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/product", token, body);
}

async function getParentIsCloseout(token, parentId) {
  if (!parentId) return null;
  const data = await api("GET", `/api/product/${parentId}`, token);
  return data.data?.isCloseout ?? null;
}

async function repairIsCloseout(token, productId) {
  await api("PATCH", `/api/product/${productId}`, token, { isCloseout: false });
  const refreshed = await api("GET", `/api/product/${productId}`, token);
  return refreshed.data.available;
}

export async function run() {
  const token = await getToken();
  const patched = [];
  const flagged = [];
  let page = 1;

  while (true) {
    const data = await findNullCloseoutProducts(token, page);
    const rows = data.data || [];
    if (rows.length === 0) break;

    for (const row of rows) {
      const product = {
        id: row.id,
        productNumber: row.productNumber,
        isCloseout: row.isCloseout ?? null,
        parentIsCloseout: await getParentIsCloseout(token, row.parentId),
        stock: row.stock || 0,
        minPurchase: row.minPurchase,
      };
      const decision = decideIsCloseoutRepair(product);

      if (decision.action === "skip") continue;

      if (decision.action === "flag_review") {
        console.warn(`Product ${product.productNumber} flagged for review. stock=${product.stock} reason=${decision.reason}`);
        flagged.push({ ...product, reason: decision.reason });
        continue;
      }

      const record = { id: product.id, productNumber: product.productNumber, from: null, to: false };
      console.warn(
        `Product ${product.productNumber} eligible to patch isCloseout. ${DRY_RUN ? "dry run, would PATCH isCloseout=false" : "patching now"}`
      );

      if (!DRY_RUN) {
        const available = await repairIsCloseout(token, product.id);
        record.confirmedAvailable = available;
        if (!available && product.stock > 0) {
          console.error(`Product ${product.productNumber} still not available after repair. Check messenger:consume is running.`);
        }
      }

      patched.push(record);
    }

    if (page * 500 >= (data.total || 0)) break;
    page++;
  }

  console.log(`Done. ${patched.length} product(s) ${DRY_RUN ? "to patch" : "patched"}, ${flagged.length} flagged for review.`);
  console.log(JSON.stringify({ patched, flaggedReview: flagged }, null, 2));
  return { patched, flaggedReview: flagged };
}

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 whether a product gets patched, flagged for a human, or left alone. Because decideIsCloseoutRepair is pure, taking only a plain object and returning an action, the tests need no network and no Shopware instance. They cover a null isCloseout with positive stock, a null isCloseout with zero stock, a null isCloseout with a non-null parent fallback, and a product whose isCloseout is already a boolean.

test_product_available.py
from reconcile_null_closeout import decide_is_closeout_repair


def product(**over):
    base = {"id": "abc123", "isCloseout": None, "parentIsCloseout": None, "stock": 10, "minPurchase": 1}
    base.update(over)
    return base


def test_patch_false_when_null_closeout_and_positive_stock():
    decision = decide_is_closeout_repair(product())
    assert decision["action"] == "patch_false"


def test_flag_review_when_null_closeout_and_zero_stock():
    decision = decide_is_closeout_repair(product(stock=0))
    assert decision["action"] == "flag_review"


def test_flag_review_when_null_closeout_and_negative_stock():
    decision = decide_is_closeout_repair(product(stock=-2))
    assert decision["action"] == "flag_review"


def test_skip_when_parent_supplies_non_null_fallback():
    decision = decide_is_closeout_repair(product(parentIsCloseout=True))
    assert decision["action"] == "skip"


def test_skip_when_parent_supplies_false_fallback():
    decision = decide_is_closeout_repair(product(parentIsCloseout=False))
    assert decision["action"] == "skip"


def test_skip_when_is_closeout_already_true():
    decision = decide_is_closeout_repair(product(isCloseout=True))
    assert decision["action"] == "skip"


def test_skip_when_is_closeout_already_false():
    decision = decide_is_closeout_repair(product(isCloseout=False))
    assert decision["action"] == "skip"
product-available.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideIsCloseoutRepair } from "./reconcile-null-closeout.js";

const product = (over = {}) => ({ id: "abc123", isCloseout: null, parentIsCloseout: null, stock: 10, minPurchase: 1, ...over });

test("patch_false when null closeout and positive stock", () => {
  assert.equal(decideIsCloseoutRepair(product()).action, "patch_false");
});

test("flag_review when null closeout and zero stock", () => {
  assert.equal(decideIsCloseoutRepair(product({ stock: 0 })).action, "flag_review");
});

test("flag_review when null closeout and negative stock", () => {
  assert.equal(decideIsCloseoutRepair(product({ stock: -2 })).action, "flag_review");
});

test("skip when parent supplies non-null true fallback", () => {
  assert.equal(decideIsCloseoutRepair(product({ parentIsCloseout: true })).action, "skip");
});

test("skip when parent supplies non-null false fallback", () => {
  assert.equal(decideIsCloseoutRepair(product({ parentIsCloseout: false })).action, "skip");
});

test("skip when isCloseout already true", () => {
  assert.equal(decideIsCloseoutRepair(product({ isCloseout: true })).action, "skip");
});

test("skip when isCloseout already false", () => {
  assert.equal(decideIsCloseoutRepair(product({ isCloseout: false })).action, "skip");
});

Case studies

Admin API import

A product feed left half a catalog unavailable

A homeware brand pushed a new seasonal collection into Shopware through the Admin API, mapping name, price, and stock but never sending isCloseout since their feed format had no equivalent field. Every one of those products landed with isCloseout null, and the storefront quietly showed them all as unavailable, despite full stock sitting behind each one.

Running the reconciler in dry run surfaced the entire batch in one pass, each row showing positive stock with a null closeout flag. After confirming the list, the team switched off dry run, the script patched isCloseout to false across the batch, and the queue worker reindexed each product within minutes. The whole collection went live at once.

Variant catalog

Variants inherited a bug their parent never had

A clothing retailer's parent products all had isCloseout set correctly, but a batch import that generated size and color variants left the variant-level field null on thousands of rows. Because the parent's value was itself sometimes null on older configurable products, a subset of variants hit the exact null-on-both-sides case and showed as unavailable even with stock in the warehouse.

The pure decision function separated the two situations cleanly: variants whose parent had a real value were left alone since Shopware's own fallback already worked for them, and only the ones with a null parent too were patched. That distinction kept the script from touching thousands of variants that did not actually need it.

What good looks like

After this runs, no product sits unavailable purely because isCloseout was never set. The script never guesses on a genuinely out-of-stock product, it only normalizes the flag when stock says the sale should actually be possible, and it verifies available actually flips to true once the message queue has had a chance to reindex. New imports and Admin API creations stop silently hiding stock that was there the whole time.

FAQ

Why does Shopware show a product as unavailable when it has stock?

Shopware's StockStorage recalculates product.available with a single SQL comparison that reads IFNULL(product.is_closeout, parent.is_closeout) multiplied by stock, compared against the same closeout flag multiplied by minPurchase, wrapped in an outer IFNULL that defaults to zero. When is_closeout is null on both the variant and its parent, the inner comparison evaluates to null rather than true or false, so the outer IFNULL collapses the whole expression to zero and available is forced to false, even though stock and minPurchase would otherwise satisfy availability.

Which Shopware version fixed the isCloseout null bug?

Shopware confirmed this as GitHub issue 5465 and internal ticket NEXT-39546, and shipped the fix in release 6.6.10.0. Any store running an earlier version, or any import or API path that leaves isCloseout null on both a variant and its parent, can still reproduce the bug regardless of version, since new products created without an explicit isCloseout value keep landing in the same null state.

Is it safe to patch isCloseout to false on every affected product?

Only when the product also has positive stock. Setting isCloseout to false on a product that already has zero or negative stock hides a real out-of-stock condition instead of fixing a false negative, so the safe script skips already-set values, skips products whose parent supplies a usable fallback, flags zero-stock products for a human to review, and only patches isCloseout to false when stock is positive and both the variant and its effective parent value are null.

Related field notes

Citations

On the problem:

  1. Shopware GitHub Issues: product.available set to "0" if is_closeout IS NULL instead of "0". github.com/shopware/shopware/issues/5465
  2. Shopware GitHub Issues: Parent products are incorrectly marked unavailable when using closeout. github.com/shopware/platform/issues/4096
  3. Shopware GitHub Discussions: Stock storage refactoring. github.com/shopware/shopware/discussions/3172

On the solution:

  1. Shopware changelog: Fix: product available set to 0 if isCloseout is null (release 6.6.10.0). github.com/shopware/shopware changelog release-6-6-10-0
  2. Shopware Developer Documentation: Available stock improvements (ADR). developer.shopware.com available-stock ADR
  3. Shopware Developer Documentation: Implementing Your Own Stock Storage. developer.shopware.com implementing-your-own-stock-storage

Stuck on a tricky one?

If you have a problem in Shopware order states, stock, message queue, or data integrity 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 bring a product back to life?

If this saved you from a silently unavailable product or a confusing support ticket about missing stock, 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 Shopware field notes