Reconciler Pricing & Tax
Catalog price rules silently skip products that already have a specific price
You built a catalog price rule, it is active, the product is in the category you targeted, and the storefront still shows the old price. No error, no warning in the admin, nothing red anywhere. The rule looks like it should be working and just is not, on some products but not others. Here is why PrestaShop quietly lets a product's own specific price beat any catalog rule every time, how to find every product this is happening to, and a safe way to report it without touching a merchant's deliberate override.
PrestaShop keeps both individually-set specific prices and catalog-rule-generated discounts in the same specific_price table. A catalog price rule (specific_price_rules) is materialized into specific_price rows through SpecificPriceRule::apply() and getAffectedProducts(), and each generated row carries the owning rule's id in id_specific_price_rule. When Product::getPriceStatic() resolves the effective price through SpecificPrice::getSpecificPrice(), its priority order always picks a manually-created row, one with id_specific_price_rule = 0, ahead of any row that came from a catalog rule. So if a product already has an individual override, the rule is never selected for that product, even though it is active, in scope, and its own generated row exists right there in the table. Nothing in the admin flags this. Run a small Python or Node.js script that lists every active catalog rule, resolves its target products, and cross-checks each product's specific_price rows to find the ones with an active manual override blocking the rule. Full code, tests, and a dry run guard are below.
The problem in plain words
A catalog price rule in PrestaShop is a set it and forget it discount: apply 15% off to every product in a category, or knock a fixed amount off everything from a manufacturer. You expect every matching product to show the new price.
But some products in that same category were already given their own individual specific price at some point, maybe a negotiated deal for a wholesale account, a one-off clearance markdown, or a price someone set by hand months ago and forgot about. PrestaShop stores that individual override in the exact same specific_price table as the rows your catalog rule generates. When the storefront asks for a price, PrestaShop has a fixed answer for which row wins if more than one applies, and a manual row always wins over a rule-generated row. The catalog rule's own row for that product is sitting right there in the database, it is just never the one chosen. No error is raised, nothing looks broken in the admin, the rule is "active" and the product is "in scope." The discount just never shows up for that one product.
Why it happens
This is not a crash and not a bug in the sense of an exception being thrown. It is documented, intentional priority behavior that surprises almost every merchant who runs into it. A few concrete ways stores hit it:
- A negotiated wholesale price was set directly on a product months ago, with no end date, and a new seasonal catalog rule is created for the whole category later. The old override quietly outranks the new rule for that one product.
- A clearance markdown was applied by hand to move old stock, the stock sold out and got restocked, and the specific price row was never cleaned up. Any catalog rule that later targets that product is skipped.
- A merchant assumes "active rule plus product in scope" means the discount shows. PrestaShop instead resolves price through a fixed hierarchy, manual override first, then the rule, so a match on both is not a tie, it is decided before any comparison of amounts happens.
- The admin UI shows the rule as active and does not cross-reference existing manual specific prices when you save it, so there is no warning at creation time that some in-scope products already have something that will out-rank it.
PrestaShop core discussion tracks this directly. GitHub issue #14516, "Specific prices priority over catalog rules, a logic approach," and Discussion #33440 both describe merchants expecting the best discount to win and instead finding a fixed priority order that is not documented anywhere a store owner would naturally look. See the citations at the end for the exact threads.
The manual specific price is not wrong, it is doing its job. A merchant set it on purpose, for a reason PrestaShop has no way to know, a negotiated deal, a clearance price, a one-off adjustment. So the fix is never to delete it automatically. The fix is to detect the collision: a product that is in an active catalog rule's target scope and also has a manual specific_price row (id_specific_price_rule = 0) whose date window covers right now. That combination is exactly what makes SpecificPrice::getSpecificPrice() pick the manual row and skip the rule's own generated row for that product, even though both exist.
The fix, as a flow
We never delete or edit a merchant's manual specific price on our own. The script lists active specific_price_rules, resolves each rule's target product ids the way core does, then for each candidate product reads its specific_price rows and checks for an active manual row. Anything it finds gets reported for a human to review, and only with an explicit opt-in and DRY_RUN=false does it perform the one safe corrective action a merchant picks.
Build it step by step
Enable the Webservice API and get a key
In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the specific_price_rules, specific_prices, and products resources. 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 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 DRY_RUN="true" // start safe, change to false to write
Talk to the Webservice API
Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET and DELETE and raises on a bad status.
import os, requests
BASE_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"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_delete(path):
r = requests.delete(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return True
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 qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return true;
}
List active catalog rules and resolve their target products
Read every active row from specific_price_rules with display=full and filter[active]=1, keeping id, name, and id_category. For each rule, resolve its candidate products the same way core does, by category, using filter[id_category_default] on the products resource. To check one product's rows directly, query specific_prices with filter[id_product].
def active_catalog_rules():
data = api_get("specific_price_rules", {
"filter[active]": "1",
"display": "full",
})
rules = data.get("specific_price_rules") or []
if isinstance(rules, dict):
rules = [rules]
return [
{"id": int(r["id"]), "name": r["name"], "id_category": int(r["id_category"])}
for r in rules
]
def product_ids_in_category(id_category):
data = api_get("products", {
"filter[id_category_default]": id_category,
"display": "[id]",
})
products = data.get("products") or []
if isinstance(products, dict):
products = [products]
return [int(p["id"]) for p in products]
def specific_price_rows(id_product):
data = api_get("specific_prices", {
"filter[id_product]": id_product,
"display": "full",
})
rows = data.get("specific_prices") or []
if isinstance(rows, dict):
rows = [rows]
return [
{
"id_product": int(r["id_product"]),
"id_specific_price_rule": int(r["id_specific_price_rule"]),
"from": r.get("from") or None,
"to": r.get("to") or None,
}
for r in rows
]
async function activeCatalogRules() {
const data = await apiGet("specific_price_rules", {
"filter[active]": "1",
display: "full",
});
let rules = data.specific_price_rules || [];
if (!Array.isArray(rules)) rules = [rules];
return rules.map((r) => ({
id: Number(r.id),
name: r.name,
id_category: Number(r.id_category),
}));
}
async function productIdsInCategory(idCategory) {
const data = await apiGet("products", {
"filter[id_category_default]": idCategory,
display: "[id]",
});
let products = data.products || [];
if (!Array.isArray(products)) products = [products];
return products.map((p) => Number(p.id));
}
async function specificPriceRows(idProduct) {
const data = await apiGet("specific_prices", {
"filter[id_product]": idProduct,
display: "full",
});
let rows = data.specific_prices || [];
if (!Array.isArray(rows)) rows = [rows];
return rows.map((r) => ({
id_product: Number(r.id_product),
id_specific_price_rule: Number(r.id_specific_price_rule),
from: r.from || null,
to: r.to || null,
}));
}
Decide, with one pure function
Keep the decision in its own function that takes a product, the set of product ids in the rule's scope, all of that product's specific_price rows, and the current time, then returns whether the rule is being silently skipped for it. A product is only skipped when it is in scope and a manual row (id_specific_price_rule = 0) with a date window covering now exists. That is the exact row that outranks the catalog rule in Product::getPriceStatic().
def _within_window(row, now):
from_ok = not row.get("from") or row["from"] <= now
to_ok = not row.get("to") or row["to"] >= now
return from_ok and to_ok
def classify_skipped_product(product, rule_scope_product_ids, specific_price_rows, now):
id_product = product["id_product"]
if id_product not in rule_scope_product_ids:
return {"skipped": False, "reason": None}
rows_for_product = [r for r in specific_price_rows if r["id_product"] == id_product]
for row in rows_for_product:
if row["id_specific_price_rule"] == 0 and _within_window(row, now):
return {"skipped": True, "reason": "manual_specific_price_override_active"}
return {"skipped": False, "reason": "no_override_found"}
function withinWindow(row, now) {
const fromOk = !row.from || row.from <= now;
const toOk = !row.to || row.to >= now;
return fromOk && toOk;
}
export function classifySkippedProduct(product, ruleScopeProductIds, specificPriceRows, now) {
const idProduct = product.id_product;
if (!ruleScopeProductIds.has(idProduct)) {
return { skipped: false, reason: null };
}
const rowsForProduct = specificPriceRows.filter((r) => r.id_product === idProduct);
for (const row of rowsForProduct) {
if (row.id_specific_price_rule === 0 && withinWindow(row, now)) {
return { skipped: true, reason: "manual_specific_price_override_active" };
}
}
return { skipped: false, reason: "no_override_found" };
}
Also recognize when the rule did apply
To tell a merchant "the rule worked here" and not just "nothing was found," check whether any of the product's specific_price rows has id_specific_price_rule matching the active rule's own id. If so, and no manual row is blocking it, report that product as rule_applied rather than leaving it unexplained.
def classify_skipped_product(product, rule_scope_product_ids, specific_price_rows, now, id_rule=None):
id_product = product["id_product"]
if id_product not in rule_scope_product_ids:
return {"skipped": False, "reason": None}
rows_for_product = [r for r in specific_price_rows if r["id_product"] == id_product]
for row in rows_for_product:
if row["id_specific_price_rule"] == 0 and _within_window(row, now):
return {"skipped": True, "reason": "manual_specific_price_override_active"}
if id_rule is not None:
for row in rows_for_product:
if row["id_specific_price_rule"] == id_rule:
return {"skipped": False, "reason": "rule_applied"}
return {"skipped": False, "reason": "no_override_found"}
export function classifySkippedProduct(product, ruleScopeProductIds, specificPriceRows, now, idRule = null) {
const idProduct = product.id_product;
if (!ruleScopeProductIds.has(idProduct)) {
return { skipped: false, reason: null };
}
const rowsForProduct = specificPriceRows.filter((r) => r.id_product === idProduct);
for (const row of rowsForProduct) {
if (row.id_specific_price_rule === 0 && withinWindow(row, now)) {
return { skipped: true, reason: "manual_specific_price_override_active" };
}
}
if (idRule !== null) {
for (const row of rowsForProduct) {
if (row.id_specific_price_rule === idRule) {
return { skipped: false, reason: "rule_applied" };
}
}
}
return { skipped: false, reason: "no_override_found" };
}
Wire it together with a dry run guard
The loop pulls every active rule, resolves its target products, reads each product's specific_price rows, and runs the pure function to build a report of every skipped product. On the first runs, leave DRY_RUN on so the script only prints and writes the CSV or JSON report for a merchant to review. Only with a merchant's explicit opt-in and DRY_RUN=false does it act, and only on the specific rows the merchant selected, never on all of them at once.
Always start with DRY_RUN=true. Never auto-delete or auto-modify a customer's manual specific_price row, that override was set on purpose, for example a negotiated deal or a clearance price. If a merchant opts in to a fix, the safe corrective action is either DELETE {PRESTASHOP_URL}/api/specific_prices/{id_specific_price} for only the rows they picked, or shortening the row's to date so it expires and the catalog rule's own row becomes effective again. Always show the before and after effective price for a product before writing anything.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, builds the skipped-product report, respects the dry run flag, and the only write it ever performs is deleting a specific, merchant-selected specific_price row when a human has explicitly opted in.
"""Find PrestaShop products where an active catalog price rule is silently skipped
because the product already has its own manual specific price.
PrestaShop stores manual specific prices and catalog-rule-generated discounts in the
same specific_price table. Product::getPriceStatic(), through
SpecificPrice::getSpecificPrice(), always picks a manual row (id_specific_price_rule = 0)
ahead of a row generated by a catalog rule, even when the rule is active and the product
is in scope. This script never deletes or edits a merchant's manual override on its own.
It reports every affected product. Only with DRY_RUN=false, and only for the specific
specific_price ids a merchant selects, does it delete the chosen rows. Safe to run
again and again.
"""
import os
import csv
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("catalog_rule_skip_report")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPORT_PATH = os.environ.get("REPORT_PATH", "skipped_catalog_rules.csv")
def api_get(path, params=None):
params = dict(params or {})
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 api_delete(path):
r = requests.delete(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return True
def _within_window(row, now):
from_ok = not row.get("from") or row["from"] <= now
to_ok = not row.get("to") or row["to"] >= now
return from_ok and to_ok
def classify_skipped_product(product, rule_scope_product_ids, specific_price_rows, now, id_rule=None):
id_product = product["id_product"]
if id_product not in rule_scope_product_ids:
return {"skipped": False, "reason": None}
rows_for_product = [r for r in specific_price_rows if r["id_product"] == id_product]
for row in rows_for_product:
if row["id_specific_price_rule"] == 0 and _within_window(row, now):
return {"skipped": True, "reason": "manual_specific_price_override_active"}
if id_rule is not None:
for row in rows_for_product:
if row["id_specific_price_rule"] == id_rule:
return {"skipped": False, "reason": "rule_applied"}
return {"skipped": False, "reason": "no_override_found"}
def active_catalog_rules():
data = api_get("specific_price_rules", {"filter[active]": "1", "display": "full"})
rules = data.get("specific_price_rules") or []
if isinstance(rules, dict):
rules = [rules]
return [
{"id": int(r["id"]), "name": r["name"], "id_category": int(r["id_category"])}
for r in rules
]
def product_ids_in_category(id_category):
data = api_get("products", {"filter[id_category_default]": id_category, "display": "[id]"})
products = data.get("products") or []
if isinstance(products, dict):
products = [products]
return [int(p["id"]) for p in products]
def product_reference(id_product):
data = api_get(f"products/{id_product}", {"display": "[reference]"})
product = data.get("product") or {}
return product.get("reference", "")
def specific_price_rows(id_product):
data = api_get("specific_prices", {"filter[id_product]": id_product, "display": "full"})
rows = data.get("specific_prices") or []
if isinstance(rows, dict):
rows = [rows]
return [
{
"id_specific_price": int(r["id"]),
"id_product": int(r["id_product"]),
"id_specific_price_rule": int(r["id_specific_price_rule"]),
"reduction": r.get("reduction"),
"reduction_type": r.get("reduction_type"),
"from": r.get("from") or None,
"to": r.get("to") or None,
}
for r in rows
]
def delete_specific_price(id_specific_price):
return api_delete(f"specific_prices/{id_specific_price}")
def build_report():
now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
report_rows = []
for rule in active_catalog_rules():
scope_ids = set(product_ids_in_category(rule["id_category"]))
for id_product in scope_ids:
rows = specific_price_rows(id_product)
result = classify_skipped_product(
{"id_product": id_product}, scope_ids, rows, now, id_rule=rule["id"]
)
if not result["skipped"]:
continue
manual_row = next(
r for r in rows
if r["id_specific_price_rule"] == 0 and _within_window(r, now)
)
report_rows.append({
"id_specific_price_rule": rule["id"],
"rule_name": rule["name"],
"id_product": id_product,
"product_reference": product_reference(id_product),
"manual_price_or_reduction": manual_row["reduction"],
"id_specific_price": manual_row["id_specific_price"],
"from": manual_row["from"],
"to": manual_row["to"],
})
return report_rows
def write_report(report_rows, path):
fieldnames = [
"id_specific_price_rule", "rule_name", "id_product", "product_reference",
"manual_price_or_reduction", "id_specific_price", "from", "to",
]
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(report_rows)
def run():
report_rows = build_report()
write_report(report_rows, REPORT_PATH)
log.info("Found %d product(s) where a catalog rule is silently skipped.", len(report_rows))
if DRY_RUN:
log.info("DRY RUN: report written to %s. No specific_prices rows were touched.", REPORT_PATH)
else:
log.info(
"DRY_RUN is false, but this script never bulk-deletes. "
"Review %s, then call delete_specific_price(id_specific_price) "
"only for the rows a merchant explicitly selects.",
REPORT_PATH,
)
if __name__ == "__main__":
run()
/**
* Find PrestaShop products where an active catalog price rule is silently skipped
* because the product already has its own manual specific price.
*
* PrestaShop stores manual specific prices and catalog-rule-generated discounts in the
* same specific_price table. Product::getPriceStatic(), through
* SpecificPrice::getSpecificPrice(), always picks a manual row (id_specific_price_rule = 0)
* ahead of a row generated by a catalog rule, even when the rule is active and the product
* is in scope. This script never deletes or edits a merchant's manual override on its own.
* It reports every affected product. Only with DRY_RUN=false, and only for the specific
* specific_price ids a merchant selects, does it delete the chosen rows. Safe to run
* again and again.
*
* Guide: https://www.allanninal.dev/prestashop/catalog-rule-skipped-by-specific-price/
*/
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";
const BASE_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 REPORT_PATH = process.env.REPORT_PATH || "skipped_catalog_rules.csv";
function withinWindow(row, now) {
const fromOk = !row.from || row.from <= now;
const toOk = !row.to || row.to >= now;
return fromOk && toOk;
}
export function classifySkippedProduct(product, ruleScopeProductIds, specificPriceRows, now, idRule = null) {
const idProduct = product.id_product;
if (!ruleScopeProductIds.has(idProduct)) {
return { skipped: false, reason: null };
}
const rowsForProduct = specificPriceRows.filter((r) => r.id_product === idProduct);
for (const row of rowsForProduct) {
if (row.id_specific_price_rule === 0 && withinWindow(row, now)) {
return { skipped: true, reason: "manual_specific_price_override_active" };
}
}
if (idRule !== null) {
for (const row of rowsForProduct) {
if (row.id_specific_price_rule === idRule) {
return { skipped: false, reason: "rule_applied" };
}
}
}
return { skipped: false, reason: "no_override_found" };
}
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(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return true;
}
async function activeCatalogRules() {
const data = await apiGet("specific_price_rules", { "filter[active]": "1", display: "full" });
let rules = data.specific_price_rules || [];
if (!Array.isArray(rules)) rules = [rules];
return rules.map((r) => ({ id: Number(r.id), name: r.name, id_category: Number(r.id_category) }));
}
async function productIdsInCategory(idCategory) {
const data = await apiGet("products", { "filter[id_category_default]": idCategory, display: "[id]" });
let products = data.products || [];
if (!Array.isArray(products)) products = [products];
return products.map((p) => Number(p.id));
}
async function productReference(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "[reference]" });
return (data.product || {}).reference || "";
}
async function specificPriceRows(idProduct) {
const data = await apiGet("specific_prices", { "filter[id_product]": idProduct, display: "full" });
let rows = data.specific_prices || [];
if (!Array.isArray(rows)) rows = [rows];
return rows.map((r) => ({
id_specific_price: Number(r.id),
id_product: Number(r.id_product),
id_specific_price_rule: Number(r.id_specific_price_rule),
reduction: r.reduction,
reduction_type: r.reduction_type,
from: r.from || null,
to: r.to || null,
}));
}
async function deleteSpecificPrice(idSpecificPrice) {
return apiDelete(`specific_prices/${idSpecificPrice}`);
}
async function buildReport() {
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
const reportRows = [];
for (const rule of await activeCatalogRules()) {
const scopeIds = new Set(await productIdsInCategory(rule.id_category));
for (const idProduct of scopeIds) {
const rows = await specificPriceRows(idProduct);
const result = classifySkippedProduct({ id_product: idProduct }, scopeIds, rows, now, rule.id);
if (!result.skipped) continue;
const manualRow = rows.find((r) => r.id_specific_price_rule === 0 && withinWindow(r, now));
reportRows.push({
id_specific_price_rule: rule.id,
rule_name: rule.name,
id_product: idProduct,
product_reference: await productReference(idProduct),
manual_price_or_reduction: manualRow.reduction,
id_specific_price: manualRow.id_specific_price,
from: manualRow.from,
to: manualRow.to,
});
}
}
return reportRows;
}
function writeReport(reportRows, path) {
const fieldnames = [
"id_specific_price_rule", "rule_name", "id_product", "product_reference",
"manual_price_or_reduction", "id_specific_price", "from", "to",
];
const lines = [fieldnames.join(",")];
for (const row of reportRows) {
lines.push(fieldnames.map((f) => JSON.stringify(row[f] ?? "")).join(","));
}
writeFileSync(path, lines.join("\n"));
}
export async function run() {
const reportRows = await buildReport();
writeReport(reportRows, REPORT_PATH);
console.log(`Found ${reportRows.length} product(s) where a catalog rule is silently skipped.`);
if (DRY_RUN) {
console.log(`DRY RUN: report written to ${REPORT_PATH}. No specific_prices rows were touched.`);
} else {
console.log(
`DRY_RUN is false, but this script never bulk-deletes. Review ${REPORT_PATH}, ` +
`then call deleteSpecificPrice(idSpecificPrice) only for the rows a merchant explicitly selects.`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which products get reported as silently skipped. Because classify_skipped_product is pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.
from catalog_rule_skip_report import classify_skipped_product
NOW = "2026-07-10 12:00:00"
def row(id_product, id_specific_price_rule, from_=None, to=None):
return {
"id_product": id_product,
"id_specific_price_rule": id_specific_price_rule,
"from": from_,
"to": to,
}
def test_not_targeted_is_not_skipped():
result = classify_skipped_product({"id_product": 99}, {1, 2, 3}, [], NOW)
assert result == {"skipped": False, "reason": None}
def test_manual_override_with_no_dates_blocks_rule():
rows = [row(1, 0)]
result = classify_skipped_product({"id_product": 1}, {1}, rows, NOW)
assert result == {"skipped": True, "reason": "manual_specific_price_override_active"}
def test_manual_override_within_date_window_blocks_rule():
rows = [row(1, 0, from_="2026-01-01 00:00:00", to="2026-12-31 23:59:59")]
result = classify_skipped_product({"id_product": 1}, {1}, rows, NOW)
assert result == {"skipped": True, "reason": "manual_specific_price_override_active"}
def test_manual_override_outside_date_window_does_not_block():
rows = [row(1, 0, from_="2020-01-01 00:00:00", to="2020-12-31 23:59:59")]
result = classify_skipped_product({"id_product": 1}, {1}, rows, NOW)
assert result["skipped"] is False
def test_rule_applied_when_only_rule_row_exists():
rows = [row(1, 42)]
result = classify_skipped_product({"id_product": 1}, {1}, rows, NOW, id_rule=42)
assert result == {"skipped": False, "reason": "rule_applied"}
def test_no_override_found_when_no_rows_at_all():
result = classify_skipped_product({"id_product": 1}, {1}, [], NOW)
assert result == {"skipped": False, "reason": "no_override_found"}
def test_manual_row_wins_even_when_rule_row_also_exists():
rows = [row(1, 42), row(1, 0)]
result = classify_skipped_product({"id_product": 1}, {1}, rows, NOW, id_rule=42)
assert result == {"skipped": True, "reason": "manual_specific_price_override_active"}
def test_only_checks_rows_for_the_given_product():
rows = [row(2, 0)]
result = classify_skipped_product({"id_product": 1}, {1, 2}, rows, NOW)
assert result == {"skipped": False, "reason": "no_override_found"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifySkippedProduct } from "./catalog-rule-skip-report.js";
const NOW = "2026-07-10 12:00:00";
const row = (id_product, id_specific_price_rule, from = null, to = null) => ({
id_product,
id_specific_price_rule,
from,
to,
});
test("not targeted is not skipped", () => {
const result = classifySkippedProduct({ id_product: 99 }, new Set([1, 2, 3]), [], NOW);
assert.deepEqual(result, { skipped: false, reason: null });
});
test("manual override with no dates blocks the rule", () => {
const rows = [row(1, 0)];
const result = classifySkippedProduct({ id_product: 1 }, new Set([1]), rows, NOW);
assert.deepEqual(result, { skipped: true, reason: "manual_specific_price_override_active" });
});
test("manual override within the date window blocks the rule", () => {
const rows = [row(1, 0, "2026-01-01 00:00:00", "2026-12-31 23:59:59")];
const result = classifySkippedProduct({ id_product: 1 }, new Set([1]), rows, NOW);
assert.deepEqual(result, { skipped: true, reason: "manual_specific_price_override_active" });
});
test("manual override outside the date window does not block", () => {
const rows = [row(1, 0, "2020-01-01 00:00:00", "2020-12-31 23:59:59")];
const result = classifySkippedProduct({ id_product: 1 }, new Set([1]), rows, NOW);
assert.equal(result.skipped, false);
});
test("rule applied when only the rule row exists", () => {
const rows = [row(1, 42)];
const result = classifySkippedProduct({ id_product: 1 }, new Set([1]), rows, NOW, 42);
assert.deepEqual(result, { skipped: false, reason: "rule_applied" });
});
test("no override found when there are no rows at all", () => {
const result = classifySkippedProduct({ id_product: 1 }, new Set([1]), [], NOW);
assert.deepEqual(result, { skipped: false, reason: "no_override_found" });
});
test("manual row wins even when the rule row also exists", () => {
const rows = [row(1, 42), row(1, 0)];
const result = classifySkippedProduct({ id_product: 1 }, new Set([1]), rows, NOW, 42);
assert.deepEqual(result, { skipped: true, reason: "manual_specific_price_override_active" });
});
test("only checks rows for the given product", () => {
const rows = [row(2, 0)];
const result = classifySkippedProduct({ id_product: 1 }, new Set([1, 2]), rows, NOW);
assert.deepEqual(result, { skipped: false, reason: "no_override_found" });
});
Case studies
A seasonal sale skipped the store's best customer
A homeware store launched a 20% off catalog rule on its cookware category for a two week sale. One long-time wholesale customer's login had negotiated a 12% discount on a specific cookware set months earlier, set directly on the product with no end date. During the sale, that one product never showed the 20% price for anyone, storefront visitors included, because the old manual row still outranked the new rule for that product.
The reconciler script found it in the very first scan, in the report next to the rule name and the exact manual reduction that was blocking it. The merchant recognized the stale wholesale override, confirmed the customer no longer needed that specific deal, and chose to expire the manual row's to date so the sale price took over.
Restocked items kept their old clearance price forever
A store cleared out end-of-season stock with individual specific prices on a handful of products. The items sold out, got restocked at full price the following year, but the old clearance specific_price rows were never removed. A new catalog rule targeting that category the next season silently skipped every one of those restocked products.
Running the script across all active rules surfaced a CSV with each affected product, its reference, and the leftover clearance reduction still in effect. The merchant reviewed the list, confirmed none of those clearance deals were still intended, and ran the cleanup with DRY_RUN=false to delete only the specific rows they had checked off.
After a scan, every product where a catalog rule is quietly losing to an old manual override is listed in one report, with the rule name, the product, and the exact price that is blocking it. Nothing gets touched automatically. A merchant reviews the list, recognizes which overrides are stale and which are still deliberate, and only then chooses to expire or delete the specific rows that should step aside. The catalog rule's own generated row was there the whole time, waiting to be picked, and now it finally can be.
FAQ
Why does my PrestaShop catalog price rule not apply to some products?
PrestaShop stores manual specific prices and catalog-rule-generated discounts in the same specific_price table. When Product::getPriceStatic() resolves the price through SpecificPrice::getSpecificPrice(), a manually-created row (id_specific_price_rule = 0) is picked ahead of any row that came from a catalog rule, even if the rule is active and the product is in scope. The rule's own generated row still exists, it is just never the one selected for that product.
Is this a bug in PrestaShop?
No, it is documented upstream as intentional but confusing behavior: a fixed priority hierarchy rather than a best discount wins comparison. Nothing crashes and no exception is thrown, so the admin UI never flags it. Merchants just see the rule not working on certain products, usually the ones that already carry a negotiated or clearance price.
Is it safe to delete a product's manual specific price to let the catalog rule apply?
Not automatically. A manual specific_price row with id_specific_price_rule = 0 is usually a deliberate override, such as a negotiated or clearance price, and blind deletion could undercharge or overcharge that product. The safe default is to report which products are affected and let a merchant choose to delete or expire the specific override, never to delete it for them.
Related field notes
Citations
On the problem:
- PrestaShop GitHub Issue #14516: Specific prices priority over catalog rules, a logic approach. github.com/PrestaShop/PrestaShop/issues/14516
- PrestaShop GitHub Discussion #33440: Specific prices priority over catalog rules, a logic approach. github.com/PrestaShop/PrestaShop/discussions/33440
- PrestaShop Help Center: Create a catalog price rule. help-center.prestashop.com create a catalog price rule
On the solution:
- PrestaShop Developer Documentation: the specific_prices Webservice resource. devdocs.prestashop-project.org webservice resources specific_prices
- PrestaShop Developer Documentation: the specific_price_rules Webservice resource. devdocs.prestashop-project.org webservice resources specific_price_rules
- PrestaShop Developer Documentation: the Webservice API overview. devdocs.prestashop-project.org webservice
Stuck on a tricky one?
If you have a problem in PrestaShop pricing, catalog rules, taxes, 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 explain your missing discount?
If this saved you from hunting through catalog rules that looked fine but were not, 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