Skip to content

Diagnostic Multistore

Catalog listing shows the wrong price when currency differs per shop

A merchant with two or more shops opens the backoffice Catalog list, or a storefront category page, and the price looks off. Click into the single product and the price shown there is different, and it is the correct one for that shop. The two views of the exact same id_product disagree, and nobody edited anything in between. Here is why listings and single product pages can resolve price differently in multistore, and a diagnostic script that finds every product and shop where the two views actually disagree, before you touch anything.

Python and Node.js PrestaShop Webservice API Diagnostic first, dry run repair
A calculator on a yellow background
Photo by Behnam Norouzi on Unsplash
The short answer

Per shop overrides for price and discounts live in ps_product_shop and ps_specific_price, keyed by id_shop or id_shop_group, while the base ps_product row holds only a default fallback value. Several core controllers and list queries, including the backoffice catalog product list, have historically joined or read from ps_product instead of the shop scoped table, and price resolution through Product::getFinalPrice() and specific price lookups can also fail to filter strictly by the loaded shop, so a listing can surface one shop's price while the single product page, which does resolve the shop context correctly, shows a different shop's real price for the same id_product. Run a small Python or Node.js script that reads the listing context price and the single product context price for every product and shop pair and flags any difference beyond a rounding tolerance. It only reports by default. Full code, tests, and a dry run guarded repair are below.

The problem in plain words

PrestaShop multistore lets each shop in an install carry its own price, its own currency, and its own discounts for the same underlying product. That per shop data is supposed to live in ps_product_shop for the base price and ps_specific_price for discounts, keyed by id_shop or id_shop_group. The single, shared ps_product row is only meant to hold a default or fallback value, never the number a shop actually charges.

The trouble is that not every place in PrestaShop that shows a price actually goes through that shop scoped path. Some list queries, most notably the backoffice Catalog product list, historically join or select straight from ps_product rather than ps_product_shop. On top of that, the price resolver itself, Product::getFinalPrice() and the specific price lookup behind it, can fail to filter strictly by the loaded shop context, so a discount scoped to shop A can still surface while you are looking at shop B. The single product page tends to get this right because it always loads with an explicit id_shop, but the listing that sits one click away does not, so the same id_product shows two different prices depending only on which screen you happen to be looking at.

Same id_product viewed in shop B context Catalog list query reads ps_product Shows shop A price wrong shop's number Single product page reads ps_product_shop shows the real, correct shop B price
The same product resolves two different prices depending only on which screen loaded it. The listing can read the unscoped ps_product row while the single product page correctly resolves ps_product_shop for the loaded id_shop.

Why it happens

The root cause is that price resolution is not uniformly shop aware across every code path, and a few ordinary situations surface it:

This is a known rough edge in multistore price resolution, not a one-off data problem in a single store. PrestaShop's own tracker has open reports of the exact listing versus single product page disagreement, and forum threads describing local shop prices reverting to the default shop's number. See the citations at the end for the exact threads.

The key insight

Because the discrepancy can come from a core resolver bug, this is not something a script should try to patch with a blind write. The safe move is to separate finding the mismatch from fixing it. A diagnostic pulls the listing context price and the single product context price for the same id_product and id_shop, and only flags a pair when the two numbers actually disagree beyond a small rounding tolerance, such as 0.01 in the shop's currency.

The fix, as a flow

We do not patch core or touch the database directly. A script lists every shop from the shops resource, then for each active product pulls the listing equivalent price with the products resource filtered by id_shop, and separately pulls the canonical single product view for the same id_product and id_shop. One pure function compares the two and decides whether it is a mismatch. Anything flagged gets reported. Only when a merchant explicitly turns off dry run, and only after confirming the cause is a stray specific_price row rather than a core bug, does the script attempt a single scoped PUT.

GET shops list every id_shop GET listing price and single product both scoped by id_shop decide_price _mismatch diff over tolerance? no, skip Prices agree yes Report product plus shop plus diff If DRY_RUN=false and cause confirmed, PUT scoped price
The script only reports by default. Behind an explicit DRY_RUN=false flag, and only when the cause is confirmed to be a stray specific_price row, it sends a single scoped PUT for that one id_shop.

Build it step by step

1

Get a Webservice key

In the PrestaShop back office, go to Advanced Parameters, Webservice, and create a key with read access to the products, shops, and specific_prices resources. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the 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 PRICE_TOLERANCE="0.01"
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 PRICE_TOLERANCE="0.01"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Webservice API

Every call is plain HTTP with the key as the Basic auth username. Ask for JSON with output_format=JSON, since the default is XML. A small helper sends the request and raises if PrestaShop returns an error status.

step2.py
import os, requests

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

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(
        f"{PRESTASHOP_URL}/api/{path}",
        params=params,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const PRESTASHOP_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 qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
3

Read the listing price and the single product price, for the same shop

For each id_shop, pull the listing equivalent price with products filtered by id_shop and filter[active]=1 with display=full. Then, for the same id_product, fetch the canonical single product view with products/{id_product} also scoped by id_shop. Read the price field from each response. This is the exact pair the diagnostic compares.

step3.py
from decimal import Decimal

def all_shops():
    data = api_get("shops", {"display": "full"})
    return data.get("shops") or []

def listing_price(id_product, id_shop):
    data = api_get("products", {
        "id_shop": id_shop,
        "filter[id]": id_product,
        "filter[active]": 1,
        "display": "full",
    })
    rows = data.get("products") or []
    return Decimal(str(rows[0]["price"])) if rows else None

def single_product_price(id_product, id_shop):
    data = api_get(f"products/{id_product}", {"id_shop": id_shop, "display": "full"})
    product = data.get("product") or {}
    return Decimal(str(product["price"])) if "price" in product else None
step3.js
async function allShops() {
  const data = await apiGet("shops", { display: "full" });
  return data.shops || [];
}

async function listingPrice(idProduct, idShop) {
  const data = await apiGet("products", {
    id_shop: idShop,
    "filter[id]": idProduct,
    "filter[active]": 1,
    display: "full",
  });
  const rows = data.products || [];
  return rows.length ? Number(rows[0].price) : null;
}

async function singleProductPrice(idProduct, idShop) {
  const data = await apiGet(`products/${idProduct}`, { id_shop: idShop, display: "full" });
  const product = data.product || {};
  return "price" in product ? Number(product.price) : null;
}
4

Decide, with one pure function

The decision that matters is a single comparison: does the listing context price differ from the single product context price by more than a small tolerance in that shop's currency. Keeping this pure and free of any HTTP call means every price pair can be tested with plain decimals, no PrestaShop store required, and it is the only place the diagnostic makes a decision.

decide.py
from decimal import Decimal

def decide_price_mismatch(listing_price, single_product_price, id_product, id_shop, tolerance=Decimal("0.01")):
    diff = abs(listing_price - single_product_price)
    return {
        "id_product": id_product,
        "id_shop": id_shop,
        "mismatch": diff > tolerance,
        "diff": diff,
        "listing_price": listing_price,
        "single_product_price": single_product_price,
    }
decide.js
export function decidePriceMismatch(listingPrice, singleProductPrice, idProduct, idShop, tolerance = 0.01) {
  const diff = Math.abs(listingPrice - singleProductPrice);
  return {
    id_product: idProduct,
    id_shop: idShop,
    mismatch: diff > tolerance,
    diff,
    listing_price: listingPrice,
    single_product_price: singleProductPrice,
  };
}
5

Walk every shop and product, and report

For each id_shop in the shops list, and each product in range, fetch both prices, run the decision function, and log anything flagged as a mismatch. This is the default and safe mode: a report only, no write of any kind.

scan.py
def scan_product(id_product, shops, tolerance):
    findings = []
    for shop in shops:
        id_shop = int(shop["id"])
        listing = listing_price(id_product, id_shop)
        single = single_product_price(id_product, id_shop)
        if listing is None or single is None:
            continue
        result = decide_price_mismatch(listing, single, id_product, id_shop, tolerance)
        if result["mismatch"]:
            findings.append(result)
    return findings
scan.js
async function scanProduct(idProduct, shops, tolerance) {
  const findings = [];
  for (const shop of shops) {
    const idShop = Number(shop.id);
    const listing = await listingPrice(idProduct, idShop);
    const single = await singleProductPrice(idProduct, idShop);
    if (listing === null || single === null) continue;
    const result = decidePriceMismatch(listing, single, idProduct, idShop, tolerance);
    if (result.mismatch) findings.push(result);
  }
  return findings;
}
6

Wire it together with a dry run guarded repair

The run loop scans a range of products across every shop and reports every mismatch. This is a core price resolution bug between ps_product and ps_product_shop, or a mis-scoped specific_price row, not a simple data write problem, so the script never auto-repairs a listing versus core join issue. The only guarded corrective action is for the narrow case a human has confirmed is a stray specific_price row scoped to the wrong shop: only when DRY_RUN=false, it sends one scoped PUT per id_product and id_shop carrying the correct price, then re-fetches both the listing and single product prices to verify the fix actually landed.

Run it safe

Always start with DRY_RUN=true and read the report before changing anything. Most listing versus single product mismatches trace back to a core resolver bug and the correct remediation is applying or upgrading to the PrestaShop core fix for the tracked issue, not a webservice write. Only use the guarded repair once you have confirmed the cause is a stray, mis-scoped specific_price row, and always re-verify both prices for that shop after writing.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every product across every shop, compares the listing context price against the single product context price with the pure function above, logs every mismatch, and only attempts the narrow, guarded repair when DRY_RUN is explicitly turned off, one shop scoped write at a time, re-verifying after each one.

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.
diagnose_multistore_listing_price.py
"""Diagnose catalog listing price mismatches across PrestaShop shops.

In multistore, per shop overrides for price and discounts live in
ps_product_shop and ps_specific_price, keyed by id_shop or id_shop_group,
while the base ps_product row holds only a default fallback value. Several
core controllers and list queries, notably the backoffice Catalog product
list (GitHub #12853), join or read from ps_product instead of the shop
scoped ps_product_shop, and Product::getFinalPrice() / specific price
resolution can also fail to filter strictly by the loaded shop context
(GitHub #20780), so a listing can surface one shop's price or discount
while the single product page, which does resolve context correctly via
id_shop, shows a different shop's real price for the same id_product.

This script reads every shop, then for each product in a given id range
pulls the listing context price and the single product context price for
that id_shop and compares them with a pure decision function. It only
reports by default. This is a core price resolution bug, not a simple data
write problem, so auto-fixing via the webservice is unsafe in general; the
correct remediation is applying or upgrading to the PrestaShop core fix for
the relevant tracker issue. Set DRY_RUN=false only after confirming the
discrepancy is a stray specific_price row scoped to the wrong shop, in
which case the script sends one scoped PUT per id_product and id_shop
carrying the correct price, then re-verifies both prices.
"""
import os
import logging
from decimal import Decimal
import requests

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

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PRICE_TOLERANCE = Decimal(os.environ.get("PRICE_TOLERANCE", "0.01"))
ID_PRODUCT_START = int(os.environ.get("ID_PRODUCT_START", "1"))
ID_PRODUCT_END = int(os.environ.get("ID_PRODUCT_END", "1"))


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


def api_put(path, body, params=None):
    p = dict(params or {})
    p["output_format"] = "JSON"
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params=p,
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def decide_price_mismatch(listing_price, single_product_price, id_product, id_shop, tolerance=Decimal("0.01")):
    """
    listing_price: Decimal, the price as it would appear in a catalog listing for id_shop.
    single_product_price: Decimal, the price from the canonical single product view for id_shop.
    Pure decision logic, no I/O, so it is easy to unit test with hardcoded price pairs.
    Returns a dict describing the comparison and whether it counts as a mismatch.
    """
    diff = abs(listing_price - single_product_price)
    return {
        "id_product": id_product,
        "id_shop": id_shop,
        "mismatch": diff > tolerance,
        "diff": diff,
        "listing_price": listing_price,
        "single_product_price": single_product_price,
    }


def all_shops():
    data = api_get("shops", {"display": "full"})
    return data.get("shops") or []


def listing_price(id_product, id_shop):
    data = api_get("products", {
        "id_shop": id_shop,
        "filter[id]": id_product,
        "filter[active]": 1,
        "display": "full",
    })
    rows = data.get("products") or []
    return Decimal(str(rows[0]["price"])) if rows else None


def single_product_price(id_product, id_shop):
    data = api_get(f"products/{id_product}", {"id_shop": id_shop, "display": "full"})
    product = data.get("product") or {}
    return Decimal(str(product["price"])) if "price" in product else None


def scan_product(id_product, shops, tolerance):
    findings = []
    for shop in shops:
        id_shop = int(shop["id"])
        listing = listing_price(id_product, id_shop)
        single = single_product_price(id_product, id_shop)
        if listing is None or single is None:
            continue
        result = decide_price_mismatch(listing, single, id_product, id_shop, tolerance)
        if result["mismatch"]:
            findings.append(result)
    return findings


def repair_finding(finding):
    """Guarded corrective action for a confirmed stray specific_price row.
    Only ever called when DRY_RUN is False. Writes the single product's
    correct price back for that one id_shop, then re-verifies both views.
    """
    id_product = finding["id_product"]
    id_shop = finding["id_shop"]
    correct_price = finding["single_product_price"]

    log.info("Product %s shop %s: writing scoped price %s. %s",
              id_product, id_shop, correct_price, "would write" if DRY_RUN else "writing")
    if DRY_RUN:
        return

    api_put(f"products/{id_product}", {"price": str(correct_price)}, params={"id_shop": id_shop})

    new_listing = listing_price(id_product, id_shop)
    new_single = single_product_price(id_product, id_shop)
    recheck = decide_price_mismatch(new_listing, new_single, id_product, id_shop, PRICE_TOLERANCE)
    if recheck["mismatch"]:
        log.warning("Product %s shop %s: still mismatched after write (diff %s).",
                    id_product, id_shop, recheck["diff"])
    else:
        log.info("Product %s shop %s: verified in agreement after write.", id_product, id_shop)


def run():
    shops = all_shops()
    total_findings = 0
    for id_product in range(ID_PRODUCT_START, ID_PRODUCT_END + 1):
        findings = scan_product(id_product, shops, PRICE_TOLERANCE)
        for finding in findings:
            log.warning("Product %s shop %s: listing=%s single=%s diff=%s",
                        finding["id_product"], finding["id_shop"],
                        finding["listing_price"], finding["single_product_price"], finding["diff"])
            repair_finding(finding)
            total_findings += 1
    log.info("Done. %d product/shop mismatch(es) %s.", total_findings, "to repair" if DRY_RUN else "handled")


if __name__ == "__main__":
    run()
diagnose-multistore-listing-price.js
/**
 * Diagnose catalog listing price mismatches across PrestaShop shops.
 *
 * In multistore, per shop overrides for price and discounts live in
 * ps_product_shop and ps_specific_price, keyed by id_shop or id_shop_group,
 * while the base ps_product row holds only a default fallback value.
 * Several core controllers and list queries, notably the backoffice
 * Catalog product list (GitHub #12853), join or read from ps_product
 * instead of the shop scoped ps_product_shop, and Product::getFinalPrice()
 * / specific price resolution can also fail to filter strictly by the
 * loaded shop context (GitHub #20780), so a listing can surface one shop's
 * price or discount while the single product page, which does resolve
 * context correctly via id_shop, shows a different shop's real price for
 * the same id_product.
 *
 * This script reads every shop, then for each product in a given id range
 * pulls the listing context price and the single product context price
 * for that id_shop and compares them with a pure decision function. It
 * only reports by default. This is a core price resolution bug, not a
 * simple data write problem, so auto-fixing via the webservice is unsafe
 * in general; the correct remediation is applying or upgrading to the
 * PrestaShop core fix for the relevant tracker issue. Set DRY_RUN=false
 * only after confirming the discrepancy is a stray specific_price row
 * scoped to the wrong shop, in which case the script sends one scoped PUT
 * per id_product and id_shop carrying the correct price, then re-verifies
 * both prices.
 *
 * Guide: https://www.allanninal.dev/prestashop/multistore-listing-price-mismatch/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PRICE_TOLERANCE = Number(process.env.PRICE_TOLERANCE || 0.01);
const ID_PRODUCT_START = Number(process.env.ID_PRODUCT_START || 1);
const ID_PRODUCT_END = Number(process.env.ID_PRODUCT_END || 1);

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

export function decidePriceMismatch(listingPrice, singleProductPrice, idProduct, idShop, tolerance = 0.01) {
  const diff = Math.abs(listingPrice - singleProductPrice);
  return {
    id_product: idProduct,
    id_shop: idShop,
    mismatch: diff > tolerance,
    diff,
    listing_price: listingPrice,
    single_product_price: singleProductPrice,
  };
}

async function apiGet(path, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiPut(path, body, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${qs}`, {
    method: "PUT",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function allShops() {
  const data = await apiGet("shops", { display: "full" });
  return data.shops || [];
}

async function listingPrice(idProduct, idShop) {
  const data = await apiGet("products", {
    id_shop: idShop,
    "filter[id]": idProduct,
    "filter[active]": 1,
    display: "full",
  });
  const rows = data.products || [];
  return rows.length ? Number(rows[0].price) : null;
}

async function singleProductPrice(idProduct, idShop) {
  const data = await apiGet(`products/${idProduct}`, { id_shop: idShop, display: "full" });
  const product = data.product || {};
  return "price" in product ? Number(product.price) : null;
}

async function scanProduct(idProduct, shops, tolerance) {
  const findings = [];
  for (const shop of shops) {
    const idShop = Number(shop.id);
    const listing = await listingPrice(idProduct, idShop);
    const single = await singleProductPrice(idProduct, idShop);
    if (listing === null || single === null) continue;
    const result = decidePriceMismatch(listing, single, idProduct, idShop, tolerance);
    if (result.mismatch) findings.push(result);
  }
  return findings;
}

async function repairFinding(finding) {
  const { id_product: idProduct, id_shop: idShop, single_product_price: correctPrice } = finding;

  console.log(`Product ${idProduct} shop ${idShop}: writing scoped price ${correctPrice}. ${DRY_RUN ? "would write" : "writing"}`);
  if (DRY_RUN) return;

  await apiPut(`products/${idProduct}`, { price: String(correctPrice) }, { id_shop: idShop });

  const newListing = await listingPrice(idProduct, idShop);
  const newSingle = await singleProductPrice(idProduct, idShop);
  const recheck = decidePriceMismatch(newListing, newSingle, idProduct, idShop, PRICE_TOLERANCE);
  if (recheck.mismatch) {
    console.warn(`Product ${idProduct} shop ${idShop}: still mismatched after write (diff ${recheck.diff}).`);
  } else {
    console.log(`Product ${idProduct} shop ${idShop}: verified in agreement after write.`);
  }
}

export async function run() {
  const shops = await allShops();
  let totalFindings = 0;
  for (let idProduct = ID_PRODUCT_START; idProduct <= ID_PRODUCT_END; idProduct++) {
    const findings = await scanProduct(idProduct, shops, PRICE_TOLERANCE);
    for (const finding of findings) {
      console.warn(`Product ${finding.id_product} shop ${finding.id_shop}: listing=${finding.listing_price} single=${finding.single_product_price} diff=${finding.diff}`);
      await repairFinding(finding);
      totalFindings++;
    }
  }
  console.log(`Done. ${totalFindings} product/shop mismatch(es) ${DRY_RUN ? "to repair" : "handled"}.`);
}

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 and shop pair gets reported at all. Because we kept decide_price_mismatch pure, the tests need no network and no PrestaShop store. They just feed in hardcoded price pairs and check the verdict.

test_listing_price_mismatch.py
from decimal import Decimal
from diagnose_multistore_listing_price import decide_price_mismatch


def test_no_mismatch_when_prices_equal():
    result = decide_price_mismatch(Decimal("19.99"), Decimal("19.99"), 42, 1)
    assert result["mismatch"] is False
    assert result["diff"] == Decimal("0.00")


def test_no_mismatch_within_rounding_tolerance():
    result = decide_price_mismatch(Decimal("19.995"), Decimal("19.99"), 42, 1, Decimal("0.01"))
    assert result["mismatch"] is False


def test_mismatch_when_prices_differ_beyond_tolerance():
    result = decide_price_mismatch(Decimal("24.99"), Decimal("19.99"), 42, 2)
    assert result["mismatch"] is True
    assert result["diff"] == Decimal("5.00")


def test_mismatch_direction_does_not_matter():
    a = decide_price_mismatch(Decimal("19.99"), Decimal("24.99"), 42, 2)
    b = decide_price_mismatch(Decimal("24.99"), Decimal("19.99"), 42, 2)
    assert a["mismatch"] is True and b["mismatch"] is True
    assert a["diff"] == b["diff"]


def test_custom_tolerance_is_respected():
    result = decide_price_mismatch(Decimal("19.99"), Decimal("20.09"), 7, 3, Decimal("0.20"))
    assert result["mismatch"] is False


def test_result_carries_ids_and_prices():
    result = decide_price_mismatch(Decimal("10.00"), Decimal("12.00"), 99, 5)
    assert result["id_product"] == 99
    assert result["id_shop"] == 5
    assert result["listing_price"] == Decimal("10.00")
    assert result["single_product_price"] == Decimal("12.00")
listing-price-mismatch.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decidePriceMismatch } from "./diagnose-multistore-listing-price.js";

test("no mismatch when prices are equal", () => {
  const result = decidePriceMismatch(19.99, 19.99, 42, 1);
  assert.equal(result.mismatch, false);
  assert.equal(result.diff, 0);
});

test("no mismatch within rounding tolerance", () => {
  const result = decidePriceMismatch(19.995, 19.99, 42, 1, 0.01);
  assert.equal(result.mismatch, false);
});

test("mismatch when prices differ beyond tolerance", () => {
  const result = decidePriceMismatch(24.99, 19.99, 42, 2);
  assert.equal(result.mismatch, true);
  assert.ok(Math.abs(result.diff - 5.0) < 1e-9);
});

test("mismatch direction does not matter", () => {
  const a = decidePriceMismatch(19.99, 24.99, 42, 2);
  const b = decidePriceMismatch(24.99, 19.99, 42, 2);
  assert.equal(a.mismatch, true);
  assert.equal(b.mismatch, true);
  assert.ok(Math.abs(a.diff - b.diff) < 1e-9);
});

test("custom tolerance is respected", () => {
  const result = decidePriceMismatch(19.99, 20.09, 7, 3, 0.2);
  assert.equal(result.mismatch, false);
});

test("result carries ids and prices", () => {
  const result = decidePriceMismatch(10.0, 12.0, 99, 5);
  assert.equal(result.id_product, 99);
  assert.equal(result.id_shop, 5);
  assert.equal(result.listing_price, 10.0);
  assert.equal(result.single_product_price, 12.0);
});

Case studies

Regional storefront

A second shop that quietly showed the wrong currency's price

A homeware brand ran a regional shop alongside its main store, each with its own currency and price list. Shoppers on the regional storefront's category page saw the main shop's number, but the product page itself showed the correct, converted price for their currency. Support tickets kept coming in about prices "changing" the moment someone clicked into a product.

Running the diagnostic across both shops surfaced the exact products where the listing context price disagreed with the single product context price, all pointing at the same unscoped listing read. The dry run report matched the tickets exactly, and the team applied the PrestaShop core patch for the tracked issue rather than trying to patch it store by store.

Specific price cleanup

A discount that leaked from one shop into another's listing

A multistore catalog had a seasonal discount configured with a specific_price row meant only for shop one. Shop two's category listing started showing the discounted number for the same products, while shop two's own product pages still showed the full, correct price.

The diagnostic flagged every affected product and shop pair with the exact price difference. Because the pattern was isolated to a handful of products all tied to the same seasonal promotion, the team confirmed it was a mis-scoped specific_price row, not a core join bug, and used the guarded, dry run first repair to correct the shop two listing price, re-verifying each one after the write.

What good looks like

After running this diagnostic regularly, a listing that disagrees with its own product page stops being a confusing one-off and becomes a short list of product and shop pairs with an exact price difference attached. Nothing gets written until a human reads the dry run report, the script never guesses at a core resolver bug, and the one guarded repair path only ever touches a confirmed, mis-scoped row, one shop at a time, with a re-check after every write.

FAQ

Why does the product list show a different price than the product page?

In multistore, per shop overrides for price and discounts live in ps_product_shop and ps_specific_price, keyed by id_shop or id_shop_group, while the base ps_product row only holds a default fallback value. Some listing queries read from ps_product instead of the shop scoped table, and price resolution can fail to filter strictly by the loaded shop, so a listing can surface one shop's price or discount while the single product page, which resolves the shop context correctly, shows a different shop's real price for the same id_product.

How do I find which products and shops are affected?

For each id_shop from the shops resource, pull the listing equivalent price with the products resource filtered by id_shop and active, then separately fetch the same id_product with the single product view, also scoped by id_shop. Compare the price fields from each call. A pair is mismatched when the difference is larger than a small rounding tolerance, such as 0.01 in the shop's currency.

Is it safe to auto-fix a multistore price mismatch?

Not by default. Most cases are a core price resolution bug between ps_product and ps_product_shop or a mis-scoped specific_price row, and the correct fix is the PrestaShop core patch for the tracked issue, not a blind webservice write. The only guarded corrective action is a dry run logged PUT that carries the correct shop scoped price for one id_shop at a time, and only after a script has confirmed the discrepancy is a stray specific_price row rather than a core resolver bug.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: incorrect product price in backoffice Catalog product list with multistore feature. github.com/PrestaShop/PrestaShop/issues/12853
  2. PrestaShop GitHub: wrong product special prices in multistore setup. github.com/PrestaShop/PrestaShop/issues/20780
  3. PrestaShop Forums: multistore local store prices are overwritten with the price from the default shop. prestashop.com/forums/topic/573943

On the solution:

  1. PrestaShop Developer Documentation: Manage Multishop, webservice reads and writes scoped by id_shop. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/manage-multishop
  2. PrestaShop Developer Documentation: the products resource. devdocs.prestashop-project.org/9/webservice/resources/products
  3. PrestaShop Developer Documentation: specific prices, the tutorial on discounts scoped by shop. devdocs.prestashop-project.org/8/webservice/tutorials/advanced-use/specific-price

Stuck on a tricky one?

If you have a problem in PrestaShop products, pricing, 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 clear up your multistore pricing?

If this saved you a confusing support ticket or a wrong price on a category page, 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