Skip to content

Diagnostic Webservice API Data Sync

Updating stock via the API can delete the linked product combination

You send a normal stock update through the webservice, and a combination that was selling fine a minute ago now shows zero quantity everywhere, as if someone deleted it. Nothing was deleted. On multistore installs that share stock across a shop group, a single stock write can knock the row out of the shared scope that every shop in the group is reading from. Here is why that happens and a small script that finds every combination this has quietly happened to.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
An empty industrial warehouse
Photo by Declan Sun on Unsplash
The short answer

On a multistore install where a shop group shares stock, a combination's stock_available row is stored once for the whole group at id_shop=0, id_shop_group=<group>. A PUT /api/stock_availables/{id} that includes id_shop and id_shop_group in the body writes that concrete id_shop straight onto the row without normalizing it back to the group's shared scope. The row is now pinned to a single shop instead of shared, so the shared-stock lookup that matches id_shop=0 never finds it again, and the combination reads as zero stock everywhere in the group, as if it had been deleted. Run a script that snapshots combinations and stock rows before any write, re-checks them after, and flags any row whose scope drifted off id_shop=0 while its shop group shares stock. Full code, tests, and a dry run guard are below.

The problem in plain words

In a PrestaShop multistore setup, a shop group can choose to share stock across every shop in that group. When it does, PrestaShop keeps exactly one stock_available row for a combination at the group level, marked with id_shop=0 and the group's id_shop_group. Every shop in that group reads the same row for the same quantity.

The Back Office knows this rule and always writes to that shared row correctly. The webservice does not enforce the same rule. A PUT to /api/stock_availables/{id} accepts id_shop and id_shop_group straight from the request body, and PrestaShop's request handler writes whatever concrete id_shop you send directly onto the row. There is a normalization step inside StockAvailable::setQuantity() that would have kept the row pinned to the shared group scope, but this particular write path bypasses it. Send id_shop=1 in a routine stock update and the row's scope quietly becomes shop 1 only, instead of staying at the shared id_shop=0.

PUT stock_availables includes id_shop=1 Row written as-is normalization bypassed shared scope lost Lookup misses row expects id_shop=0 Looks deleted the product_attribute row is never actually removed, only the stock row's shop scope drifts
The combination is still there. Its stock row just stopped being visible to the shared-stock lookup every shop in the group relies on.

Why it happens

This only bites multistore installs with shared stock turned on for the shop group. A single-shop install has no group-level row to knock out of scope, so it is unaffected. A few ways stores run into it:

PrestaShop staff have confirmed this is reproducible specifically with multistore plus shared stock enabled, and it has been root-caused and fixed against the core codebase. See the citations at the end for the exact reports and the fix.

The key insight

The combination itself, the product_attribute row, is never touched by this bug. What breaks is purely the scope of its shared stock row. StockAvailable::getQuantityAvailableByProduct() resolves shared stock by matching id_shop=0 for the group. Once a write pins the row to a concrete shop id instead, that lookup comes back empty for every shop in the group, and zero stock looks exactly like a deleted combination from the storefront and the Back Office. The fix is to never let a stock write carry a shop scope that does not belong there.

The fix, as a flow

We do not guess which id_shop is "correct" and try to auto-repair it, since writing to the wrong shop scope on a live store is its own risk. Instead the script snapshots combinations and stock rows before any write, re-reads them after, and flags any row that drifted off the shared scope in a group that actually shares stock. A confirmed drift is logged with the exact PUT body that would restore it, guarded by dry run.

Snapshot before combinations + stock rows Re-read after write stock_availables/<id> Check shop group share_stock = true? Scope drifted? yes no, skip Flag, propose PUT reset to id_shop=0 dry run guarded
The script only proposes a corrective write after confirming the shop group truly shares stock, and dry run keeps the actual PUT from firing until you say so.

Build it step by step

1

Get a webservice key with the right permissions

In the Back Office, go to Advanced Parameters, Webservice, and create a key with read access to combinations, stock_availables, and shop_groups, plus write access to stock_availables if you ever want the guarded repair step to run for real. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and key in environment variables, never in the file.

setup (shell)
pip install requests

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

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, change to false to write
2

Snapshot combinations and stock rows before any write

Before touching stock through the API, list the product's live combinations and every stock row tied to it. Keep the row's id, id_product_attribute, id_shop, id_shop_group, and quantity. This snapshot is what "existed before" means later, so a combination that never existed can never be misreported as orphaned.

step2.py
import os, requests

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]

def api_get(path, params):
    params = dict(params)
    params["output_format"] = "JSON"
    r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
    r.raise_for_status()
    return r.json()

def snapshot_combinations(id_product):
    data = api_get("combinations", {"display": "full", "filter[id_product]": id_product})
    return {int(c["id"]): c for c in (data.get("combinations") or [])}

def snapshot_stock_rows(id_product):
    data = api_get("stock_availables", {"display": "full", "filter[id_product]": id_product})
    rows = data.get("stock_availables") or []
    return [
        {
            "id": int(r["id"]),
            "id_product_attribute": int(r.get("id_product_attribute") or 0),
            "id_shop": int(r.get("id_shop") or 0),
            "id_shop_group": int(r.get("id_shop_group") or 0),
            "quantity": int(r.get("quantity") or 0),
        }
        for r in rows
    ]
step2.js
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";

function authHeader() {
  return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}

async function apiGet(path, params) {
  const url = new URL(`${BASE_URL}/api/${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: authHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function snapshotCombinations(idProduct) {
  const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
  const map = new Map();
  for (const c of data.combinations || []) map.set(Number(c.id), c);
  return map;
}

async function snapshotStockRows(idProduct) {
  const data = await apiGet("stock_availables", { display: "full", "filter[id_product]": idProduct });
  const rows = data.stock_availables || [];
  return rows.map((r) => ({
    id: Number(r.id),
    id_product_attribute: Number(r.id_product_attribute || 0),
    id_shop: Number(r.id_shop || 0),
    id_shop_group: Number(r.id_shop_group || 0),
    quantity: Number(r.quantity || 0),
  }));
}
3

Check whether the shop group actually shares stock

The bug only matters for a group with share_stock=1. A group that does not share stock has no shared row to knock out of scope, so there is nothing to flag. Read the shop group once and keep its share_stock flag alongside the rest of the check.

step3.py
def shop_group(id_shop_group):
    data = api_get(f"shop_groups/{id_shop_group}", {})
    g = data.get("shop_group") or {}
    return {
        "id_shop_group": int(g.get("id") or id_shop_group),
        "share_stock": str(g.get("share_stock", "0")) in ("1", "true", "True"),
    }
step3.js
async function shopGroup(idShopGroup) {
  const data = await apiGet(`shop_groups/${idShopGroup}`, {});
  const g = data.shop_group || {};
  return {
    id_shop_group: Number(g.id ?? idShopGroup),
    share_stock: ["1", "true", true].includes(g.share_stock),
  };
}
4

Decide, with one pure function

Keep the actual decision in its own function that takes the pre-write snapshot, the post-write stock row, and the shop group, and returns true or false. It never touches the network, so it is simple to test with hand built fixtures. A row is flagged only when the combination existed before the write, the group shares stock, and the post-write row's scope drifted off id_shop=0 or its quantity collapsed to zero from a positive value.

decide.py
def is_combination_stock_orphaned(pre_snapshot, post_stock_row, shop_group):
    if not pre_snapshot.get("existed"):
        return False
    if not shop_group.get("share_stock"):
        return False
    scope_drifted = post_stock_row.get("id_shop", 0) != 0
    quantity_collapsed = (
        pre_snapshot.get("quantity", 0) > 0
        and post_stock_row.get("quantity", 0) == 0
    )
    return scope_drifted or quantity_collapsed
decide.js
export function isCombinationStockOrphaned(preSnapshot, postStockRow, shopGroup) {
  if (!preSnapshot.existed) return false;
  if (!shopGroup.share_stock) return false;
  const scopeDrifted = (postStockRow.id_shop ?? 0) !== 0;
  const quantityCollapsed = (preSnapshot.quantity ?? 0) > 0 && (postStockRow.quantity ?? 0) === 0;
  return scopeDrifted || quantityCollapsed;
}
5

Re-read after a write and flag, do not auto-repair

After a stock write touches a row, re-fetch GET /api/stock_availables/{id} and re-resolve the combination with GET /api/combinations/{id_product_attribute}. Because the underlying combination was never actually deleted, guessing the correct id_shop to restore risks writing to the wrong shop scope, so this step only logs the finding. The dry run guarded repair, when you do trust it, resets id_shop and id_shop_group back to the group's shared value with the last known good quantity, and only fires after confirming share_stock=1 on that group again.

apply.py
def restore_shared_scope(id_stock_available, id_shop_group, quantity):
    body = {
        "stock_available": {
            "id": id_stock_available,
            "id_shop": 0,
            "id_shop_group": id_shop_group,
            "quantity": quantity,
        }
    }
    if DRY_RUN:
        log.info("DRY RUN would PUT stock_availables/%s body=%s", id_stock_available, body)
        return
    r = requests.put(
        f"{BASE_URL}/api/stock_availables/{id_stock_available}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
apply.js
async function restoreSharedScope(idStockAvailable, idShopGroup, quantity) {
  const body = {
    stock_available: {
      id: idStockAvailable,
      id_shop: 0,
      id_shop_group: idShopGroup,
      quantity,
    },
  };
  if (DRY_RUN) {
    console.log(`DRY RUN would PUT stock_availables/${idStockAvailable} body=${JSON.stringify(body)}`);
    return;
  }
  const url = new URL(`${BASE_URL}/api/stock_availables/${idStockAvailable}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
}
6

Wire it together with a dry run guard

The run loop snapshots each product before touching stock, performs (or simulates) the update elsewhere in your own sync, re-reads the stock row and shop group, and runs the pure decision function. Leave DRY_RUN on so the script only reports what it found and the exact PUT body that would restore the shared scope. The most durable habit, until the core fix lands everywhere you run, is to stop sending id_shop and id_shop_group in stock_availables PUT bodies at all on shared-stock multistore installs, so the existing row's scope is simply never touched.

Run it safe

Always start with DRY_RUN=true. This script only ever writes after confirming, on a fresh re-fetch, that the shop group truly shares stock and that the row genuinely drifted off the shared id_shop=0 scope. The safest option of all is to simply omit id_shop and id_shop_group from your own stock_availables PUT bodies going forward.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never repairs a row without re-confirming the shop group shares stock right before it writes.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
stock_update_deletes_combination.py
"""Detect PrestaShop combinations whose stock got knocked out of shared scope
by a stock_availables API write, making them look deleted.

On a multistore install where the shop group shares stock, a combination's
stock_available row is stored once for the whole group at id_shop=0. A PUT to
stock_availables can write a concrete id_shop straight onto that row without
normalizing it back to the shared scope, so the shared-stock lookup no longer
finds it for any shop in the group and the combination reads as zero stock
everywhere. The product_attribute row itself is never deleted. This snapshots
combinations and stock before a write, re-checks them after, and flags rows
whose scope drifted while their shop group truly shares stock. It never
auto-repairs without a fresh re-confirmation, and defaults to dry run.
"""
import os
import logging
import requests

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

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PRODUCT_IDS = [int(p) for p in os.environ.get("PRODUCT_IDS", "").split(",") if p.strip()]


def api_get(path, params):
    params = dict(params)
    params["output_format"] = "JSON"
    r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
    r.raise_for_status()
    return r.json()


def snapshot_combinations(id_product):
    data = api_get("combinations", {"display": "full", "filter[id_product]": id_product})
    return {int(c["id"]): c for c in (data.get("combinations") or [])}


def snapshot_stock_rows(id_product):
    data = api_get("stock_availables", {"display": "full", "filter[id_product]": id_product})
    rows = data.get("stock_availables") or []
    return [
        {
            "id": int(r["id"]),
            "id_product_attribute": int(r.get("id_product_attribute") or 0),
            "id_shop": int(r.get("id_shop") or 0),
            "id_shop_group": int(r.get("id_shop_group") or 0),
            "quantity": int(r.get("quantity") or 0),
        }
        for r in rows
    ]


def shop_group(id_shop_group):
    data = api_get(f"shop_groups/{id_shop_group}", {})
    g = data.get("shop_group") or {}
    return {
        "id_shop_group": int(g.get("id") or id_shop_group),
        "share_stock": str(g.get("share_stock", "0")) in ("1", "true", "True"),
    }


def is_combination_stock_orphaned(pre_snapshot, post_stock_row, shop_group_row):
    """
    pre_snapshot: {'id_product_attribute': int, 'existed': bool, 'quantity': int}
    post_stock_row: {'id_shop': int, 'id_shop_group': int, 'quantity': int, 'id_product_attribute': int}
    shop_group_row: {'id_shop_group': int, 'share_stock': bool}

    Returns True iff the combination existed before the write, the group
    shares stock, and the post-write row's shop scope has drifted off the
    shared id_shop=0 anchor (or its visible quantity collapsed to 0 while
    the pre-write quantity was positive).
    """
    if not pre_snapshot.get("existed"):
        return False
    if not shop_group_row.get("share_stock"):
        return False
    scope_drifted = post_stock_row.get("id_shop", 0) != 0
    quantity_collapsed = (
        pre_snapshot.get("quantity", 0) > 0
        and post_stock_row.get("quantity", 0) == 0
    )
    return scope_drifted or quantity_collapsed


def restore_shared_scope(id_stock_available, id_shop_group, quantity):
    body = {
        "stock_available": {
            "id": id_stock_available,
            "id_shop": 0,
            "id_shop_group": id_shop_group,
            "quantity": quantity,
        }
    }
    if DRY_RUN:
        log.info("DRY RUN would PUT stock_availables/%s body=%s", id_stock_available, body)
        return
    r = requests.put(
        f"{BASE_URL}/api/stock_availables/{id_stock_available}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()


def run():
    flagged = 0
    for id_product in PRODUCT_IDS:
        pre_combinations = snapshot_combinations(id_product)
        pre_rows = {row["id"]: row for row in snapshot_stock_rows(id_product)}

        post_rows = snapshot_stock_rows(id_product)
        for post_row in post_rows:
            id_pa = post_row["id_product_attribute"]
            pre_row = pre_rows.get(post_row["id"])
            pre_snapshot = {
                "id_product_attribute": id_pa,
                "existed": id_pa in pre_combinations or id_pa == 0,
                "quantity": (pre_row or {}).get("quantity", 0),
            }
            group = shop_group(post_row["id_shop_group"])

            if not is_combination_stock_orphaned(pre_snapshot, post_row, group):
                continue

            flagged += 1
            log.warning(
                "Product %s combination id_product_attribute=%s stock row id=%s looks orphaned "
                "(id_shop=%s quantity=%s, group %s share_stock=%s)",
                id_product, id_pa, post_row["id"], post_row["id_shop"],
                post_row["quantity"], group["id_shop_group"], group["share_stock"],
            )
            restore_shared_scope(post_row["id"], group["id_shop_group"], pre_snapshot["quantity"])

    log.info("Done. %d row(s) flagged as orphaned combination stock.", flagged)


if __name__ == "__main__":
    run()
stock-update-deletes-combination.js
/**
 * Detect PrestaShop combinations whose stock got knocked out of shared scope
 * by a stock_availables API write, making them look deleted.
 *
 * On a multistore install where the shop group shares stock, a combination's
 * stock_available row is stored once for the whole group at id_shop=0. A PUT
 * to stock_availables can write a concrete id_shop straight onto that row
 * without normalizing it back to the shared scope, so the shared-stock lookup
 * no longer finds it for any shop in the group and the combination reads as
 * zero stock everywhere. The product_attribute row itself is never deleted.
 * This snapshots combinations and stock before a write, re-checks them after,
 * and flags rows whose scope drifted while their shop group truly shares
 * stock. It never auto-repairs without a fresh re-confirmation, and defaults
 * to dry run.
 *
 * Guide: https://www.allanninal.dev/prestashop/stock-update-deletes-combination/
 */
import { pathToFileURL } from "node:url";

const BASE_URL = (process.env.PRESTASHOP_URL || "https://example.test").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "dummy_key";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PRODUCT_IDS = (process.env.PRODUCT_IDS || "").split(",").map((p) => p.trim()).filter(Boolean).map(Number);

function authHeader() {
  return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}

async function apiGet(path, params) {
  const url = new URL(`${BASE_URL}/api/${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: authHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function snapshotCombinations(idProduct) {
  const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
  const map = new Map();
  for (const c of data.combinations || []) map.set(Number(c.id), c);
  return map;
}

async function snapshotStockRows(idProduct) {
  const data = await apiGet("stock_availables", { display: "full", "filter[id_product]": idProduct });
  const rows = data.stock_availables || [];
  return rows.map((r) => ({
    id: Number(r.id),
    id_product_attribute: Number(r.id_product_attribute || 0),
    id_shop: Number(r.id_shop || 0),
    id_shop_group: Number(r.id_shop_group || 0),
    quantity: Number(r.quantity || 0),
  }));
}

async function shopGroup(idShopGroup) {
  const data = await apiGet(`shop_groups/${idShopGroup}`, {});
  const g = data.shop_group || {};
  return {
    id_shop_group: Number(g.id ?? idShopGroup),
    share_stock: ["1", "true", true].includes(g.share_stock),
  };
}

/**
 * pre_snapshot: {id_product_attribute, existed, quantity}
 * post_stock_row: {id_shop, id_shop_group, quantity, id_product_attribute}
 * shop_group: {id_shop_group, share_stock}
 *
 * Returns true iff the combination existed before the write, the group
 * shares stock, and the post-write row's shop scope has drifted off the
 * shared id_shop=0 anchor (or its visible quantity collapsed to 0 while
 * the pre-write quantity was positive).
 */
export function isCombinationStockOrphaned(preSnapshot, postStockRow, shopGroupRow) {
  if (!preSnapshot.existed) return false;
  if (!shopGroupRow.share_stock) return false;
  const scopeDrifted = (postStockRow.id_shop ?? 0) !== 0;
  const quantityCollapsed = (preSnapshot.quantity ?? 0) > 0 && (postStockRow.quantity ?? 0) === 0;
  return scopeDrifted || quantityCollapsed;
}

async function restoreSharedScope(idStockAvailable, idShopGroup, quantity) {
  const body = {
    stock_available: {
      id: idStockAvailable,
      id_shop: 0,
      id_shop_group: idShopGroup,
      quantity,
    },
  };
  if (DRY_RUN) {
    console.log(`DRY RUN would PUT stock_availables/${idStockAvailable} body=${JSON.stringify(body)}`);
    return;
  }
  const url = new URL(`${BASE_URL}/api/stock_availables/${idStockAvailable}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
}

export async function run() {
  let flagged = 0;

  for (const idProduct of PRODUCT_IDS) {
    const preCombinations = await snapshotCombinations(idProduct);
    const preRowsList = await snapshotStockRows(idProduct);
    const preRows = new Map(preRowsList.map((row) => [row.id, row]));

    const postRows = await snapshotStockRows(idProduct);
    for (const postRow of postRows) {
      const idPa = postRow.id_product_attribute;
      const preRow = preRows.get(postRow.id);
      const preSnapshot = {
        id_product_attribute: idPa,
        existed: preCombinations.has(idPa) || idPa === 0,
        quantity: preRow ? preRow.quantity : 0,
      };
      const group = await shopGroup(postRow.id_shop_group);

      if (!isCombinationStockOrphaned(preSnapshot, postRow, group)) continue;

      flagged++;
      console.warn(
        `Product ${idProduct} combination id_product_attribute=${idPa} stock row id=${postRow.id} looks orphaned ` +
        `(id_shop=${postRow.id_shop} quantity=${postRow.quantity}, group ${group.id_shop_group} share_stock=${group.share_stock})`
      );
      await restoreSharedScope(postRow.id, group.id_shop_group, preSnapshot.quantity);
    }
  }

  console.log(`Done. ${flagged} row(s) flagged as orphaned combination stock.`);
}

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

Add a test

The decision function is the part most worth testing, because it decides which rows get reported and which get a repair proposed. Because is_combination_stock_orphaned is pure, the test needs no PrestaShop instance and no network. It just feeds in plain objects and checks the answer.

test_stock_orphaned.py
from stock_update_deletes_combination import is_combination_stock_orphaned


def pre(**over):
    base = {"id_product_attribute": 5, "existed": True, "quantity": 10}
    base.update(over)
    return base


def post(**over):
    base = {"id_shop": 0, "id_shop_group": 2, "quantity": 10, "id_product_attribute": 5}
    base.update(over)
    return base


def group(**over):
    base = {"id_shop_group": 2, "share_stock": True}
    base.update(over)
    return base


def test_not_orphaned_when_scope_and_quantity_are_fine():
    assert is_combination_stock_orphaned(pre(), post(), group()) is False


def test_not_orphaned_when_combination_never_existed():
    assert is_combination_stock_orphaned(pre(existed=False), post(id_shop=1), group()) is False


def test_not_orphaned_when_group_does_not_share_stock():
    assert is_combination_stock_orphaned(pre(), post(id_shop=1), group(share_stock=False)) is False


def test_orphaned_when_scope_drifted_off_zero():
    assert is_combination_stock_orphaned(pre(), post(id_shop=1), group()) is True


def test_orphaned_when_quantity_collapsed_to_zero():
    assert is_combination_stock_orphaned(pre(quantity=10), post(quantity=0), group()) is True


def test_not_orphaned_when_quantity_was_already_zero():
    assert is_combination_stock_orphaned(pre(quantity=0), post(quantity=0), group()) is False
stock-orphaned.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isCombinationStockOrphaned } from "./stock-update-deletes-combination.js";

const pre = (over = {}) => ({ id_product_attribute: 5, existed: true, quantity: 10, ...over });
const post = (over = {}) => ({ id_shop: 0, id_shop_group: 2, quantity: 10, id_product_attribute: 5, ...over });
const group = (over = {}) => ({ id_shop_group: 2, share_stock: true, ...over });

test("not orphaned when scope and quantity are fine", () => {
  assert.equal(isCombinationStockOrphaned(pre(), post(), group()), false);
});

test("not orphaned when combination never existed", () => {
  assert.equal(isCombinationStockOrphaned(pre({ existed: false }), post({ id_shop: 1 }), group()), false);
});

test("not orphaned when group does not share stock", () => {
  assert.equal(isCombinationStockOrphaned(pre(), post({ id_shop: 1 }), group({ share_stock: false })), false);
});

test("orphaned when scope drifted off zero", () => {
  assert.equal(isCombinationStockOrphaned(pre(), post({ id_shop: 1 }), group()), true);
});

test("orphaned when quantity collapsed to zero", () => {
  assert.equal(isCombinationStockOrphaned(pre({ quantity: 10 }), post({ quantity: 0 }), group()), true);
});

test("not orphaned when quantity was already zero", () => {
  assert.equal(isCombinationStockOrphaned(pre({ quantity: 0 }), post({ quantity: 0 }), group()), false);
});

Case studies

Multistore sync

The ERP integration that round-tripped every field

An ERP integration synced stock to a three-shop group that shared inventory. Every night it read a stock row and PUT it back with a fresh quantity, faithfully sending back every field it had read, including id_shop and id_shop_group. Combinations that had sold well for months started showing zero stock across all three shops overnight, with no delete anywhere in the logs.

The detector flagged the pattern immediately: rows where the group's share_stock was true but id_shop had drifted to a concrete shop id after every sync. Once the integration stopped sending shop scope fields in its stock PUTs, the drift stopped happening.

Migrated script

The script that worked fine until multistore

A stock update script had run for years against a single-shop store without ever causing an issue, because a single shop has no shared group row to lose scope from. When the business expanded and enabled a shared-stock group across two new regional shops, the same unmodified script started making combinations vanish from the storefront within days.

Running the detector against the affected products confirmed every flagged row belonged to the new shared group and had a concrete id_shop instead of 0. The team applied the dry run guarded repair once, then changed the script to omit shop scope fields entirely going forward.

What good looks like

After this runs alongside your stock sync, a routine stock update never quietly knocks a combination's shared row out of scope. Every flagged row is confirmed against a shop group that genuinely shares stock, nothing is repaired without a fresh re-check right before the write, and the durable fix, leaving id_shop and id_shop_group out of your PUT bodies, keeps the problem from recurring at all.

FAQ

Why does updating stock through the API make my combination disappear?

On a multistore install where the shop group shares stock, a combination's stock row is stored once for the whole group with id_shop=0. A PUT to stock_availables can write a concrete id_shop such as 1 straight onto that row instead of keeping it at the shared id_shop=0 scope, so the shared-stock lookup no longer finds it for any shop in the group and the combination reads as having zero quantity everywhere, as if it had been deleted.

Is the product_attribute combination actually deleted?

No. The combination row itself is untouched. Only the stock_available row's shop scope has drifted off the shared id_shop=0 anchor, so the resolved quantity collapses to zero and the combination looks gone in the storefront and Back Office even though it still exists underneath.

Is it safe to auto-fix this with a script?

Treat it as flag first, not auto-write. Guessing the right id_shop to restore risks writing to the wrong shop scope. The safer pattern is to detect the drift, confirm the shop group truly shares stock, and only then, behind a dry run flag, reset id_shop and id_shop_group back to the group's shared value. The most durable habit is to stop sending id_shop and id_shop_group in stock_availables PUT bodies at all on shared-stock multistore installs.

Related field notes

Citations

On the problem:

  1. Updating stock_availables via API deletes combination from product. github.com/PrestaShop/PrestaShop/issues/38049
  2. Updating a product combination location via API no longer is possible now. github.com/PrestaShop/PrestaShop/issues/35026
  3. Trying to update stock via webservice. github.com/PrestaShop/PrestaShop/issues/17857

On the solution:

  1. Keep shared stock attached to its group when saving a stock_available. github.com/PrestaShop/PrestaShop/pull/41863
  2. PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/8/webservice/resources/stock_availables/
  3. PrestaShop Developer Documentation: Combinations webservice resource. devdocs.prestashop-project.org/8/webservice/resources/combinations/

Stuck on a tricky one?

If you have a problem in PrestaShop stock, combinations, multistore, or the Webservice API 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 save a vanished combination?

If this saved you a scramble over a "deleted" combination that was never actually deleted, 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 PrestaShop field notes