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.
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.
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:
- The backoffice Catalog product list joining or reading from the shared
ps_productrow instead of the shop scopedps_product_shop, a pattern tracked against core in issue #12853. - A
ps_specific_pricediscount row scoped toid_shop_groupor a differentid_shopthan the one currently loaded still being applied, because the resolver inProduct::getFinalPrice()does not always filter strictly enough by the active shop context, as reported in issue #20780. - A merchant setting a local, per shop price in the back office, only to see it silently reappear as the default shop's price after a save, sync, or cache rebuild, which forum reports describe as local prices being overwritten by the default shop.
- Any storefront listing template or module that queries price in bulk for a category or search page using the same unscoped pattern as the backoffice list, rather than the per shop resolver the single product page uses.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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
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;
}
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.
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,
}
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,
};
}
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.
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
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;
}
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.
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.
"""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 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.
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")
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
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.
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.
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:
- PrestaShop GitHub: incorrect product price in backoffice Catalog product list with multistore feature. github.com/PrestaShop/PrestaShop/issues/12853
- PrestaShop GitHub: wrong product special prices in multistore setup. github.com/PrestaShop/PrestaShop/issues/20780
- PrestaShop Forums: multistore local store prices are overwritten with the price from the default shop. prestashop.com/forums/topic/573943
On the solution:
- PrestaShop Developer Documentation: Manage Multishop, webservice reads and writes scoped by id_shop. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/manage-multishop
- PrestaShop Developer Documentation: the products resource. devdocs.prestashop-project.org/9/webservice/resources/products
- 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.
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