Skip to content

Reconciler

Wrong price selected when multiple pricing rules overlap for a customer group

A merchant sets an 89 price scoped to a specific customer group, and a broader 90 price sitting on All Groups. The customer belongs to the narrower group and should legitimately see 89. PrestaShop shows 90 instead. Nothing throws an error, nothing looks wrong in the back office, the store simply picked the wrong row out of several that all technically matched. Here is why the priority order is not the same thing as the lowest price, and a script that recomputes the price the customer actually qualifies for and flags every case where the store got it wrong.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
A calculator on a pile of papers
Photo by FIN on Unsplash
The short answer

PrestaShop resolves a product's effective price by scanning every specific_price row (and specific_price_rule catalog rule) that matches the request context, id_shop, id_currency, id_country, id_group, id_customer, and picking the first one that matches according to a fixed evaluation order: Shop beats Currency beats Country beats Group, and within Group the most specific id_group or id_customer is supposed to beat "all groups" or "all customers." It does not compute every matching rule and choose the numerically lowest resulting price. Because All Groups (id_group=0) and generic country or currency wildcards sit in a priority position that is not strictly "more specific wins," a broader rule can be selected over a narrower, better rule that actually applies to the customer's real group or currency, confirmed in PrestaShop core issue #33736. Run a Python or Node.js script that reads the customer's real group and currency context, pulls every overlapping specific_price and specific_price_rule row for the product, independently recomputes the best legitimate price for every rule that matches that context, and compares it to what the store's live price resolution actually returned. This is a core pricing engine defect, not a bad data row, so the default action is to flag the mismatch, not silently rewrite it. Full code, tests, and citations are below.

The problem in plain words

A specific_price row is PrestaShop's way of saying "this product costs a different amount under this condition." The condition can be a shop, a currency, a country, a customer group, a single customer, a quantity tier, or a date window, and a product can carry many of these rows at once, alongside broader specific_price_rule catalog rules that apply by category or condition rather than by product id directly.

The trouble is what happens when two or more of those rows both match the same customer at the same moment. PrestaShop does not add up every row that applies and pick whichever produces the cheapest final price. It walks the candidate rows in a fixed priority order, Shop first, then Currency, then Country, then Group, and stops at the first one that fits. A general "All Groups" row sitting at price 90 can win over a specific group's row sitting at price 89, even though the customer is a paid-up member of that specific group and should legitimately see 89. Nothing errors. The store just quietly serves the wrong number.

Row A: id_group = 12 price 89, matches this customer Row B: id_group = 0 price 90, All Groups, also matches Fixed priority order Shop > Currency > Country > Group not "lowest price wins" Row B wins: 90 customer should see 89 Customer is overcharged, no error is raised
Both rows match this customer's context. The store's priority order settles on the broader row instead of the numerically lowest one the customer legitimately qualifies for.

Why it happens

The root cause is architectural: PrestaShop's price resolution was built as "find a matching rule and stop," not "find every matching rule and rank them." A few documented ways this surfaces:

None of this is a data corruption bug. Every row involved can be perfectly valid on its own. The defect is in how the core decides which valid row to serve. See the citations at the end for the exact issues and docs.

The key insight

This is a priority-resolution defect in the core, not a bad row you can safely delete on sight. The correct move is to independently recompute, for every candidate rule that actually matches the customer's group, currency, country, and date window, the price it would produce, take the minimum of those, and compare that number to whatever price the store actually served. When they disagree by more than a rounding epsilon, flag the id_product and id_customer pair. Only touch a rule automatically when it is unambiguously stale, such as a date window that should already have closed.

The fix, as a flow

We do not patch the core's priority logic. We add a job that reads a customer's real group and currency, pulls every specific_price and specific_price_rule row for a product, recomputes the best price the customer actually qualifies for with a pure decision function, and compares that to the price the store's own resolution returned. Anything that disagrees becomes a report row. Only a clearly superseded row gets a guarded repair.

Read context customer group, currency Pull candidate rows specific_prices, specific_price_rules resolveBestSpecificPrice filter by context, take numeric minimum Matches API price? yes, move on Flag no Repair only a confirmed stale row: DELETE or PUT that single specific_price id, DRY_RUN guarded
The job always reads and recomputes first. Writing only ever targets a single, operator-confirmed superseded specific_price id, never a bulk delete and never the core's priority logic.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with read access to customers, currencies, products, specific_prices, and specific_price_rules, plus write access to specific_prices only if you plan to run the guarded single-row repair. 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 DRY_RUN="true"   # start safe, only reports by default
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, only reports by default
2

Read the customer's real group and currency context

Call GET /api/customers/{id_customer}?output_format=JSON&display=full for id_default_group and the groups the customer actually belongs to under associations.groups, and GET /api/currencies?output_format=JSON&display=full to resolve the currency id in play. The context needs every group id the customer belongs to, not just the default, since a specific_price row can be scoped to any of them.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (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=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()

def customer_group_ids(id_customer):
    data = api_get(f"customers/{id_customer}", params={"display": "full"})
    customer = data.get("customer") or {}
    groups = ((customer.get("associations") or {}).get("groups")) or []
    ids = {int(g["id"]) for g in groups if g.get("id")}
    if customer.get("id_default_group"):
        ids.add(int(customer["id_default_group"]))
    return ids

def all_currencies():
    data = api_get("currencies", params={"display": "full"})
    return data.get("currencies") or []
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

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

async function customerGroupIds(idCustomer) {
  const data = await apiGet(`customers/${idCustomer}`, { display: "full" });
  const customer = data.customer || {};
  const groups = (customer.associations && customer.associations.groups) || [];
  const ids = new Set(groups.filter((g) => g.id).map((g) => Number(g.id)));
  if (customer.id_default_group) ids.add(Number(customer.id_default_group));
  return ids;
}

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

Pull every overlapping specific_price and catalog rule row

Call GET /api/products/{id_product}?output_format=JSON&display=full for the base pre-tax price, then GET /api/specific_prices?output_format=JSON&display=full&filter[id_product]={id_product} for every row scoped to that product, and GET /api/specific_price_rules?output_format=JSON&display=full for the broader catalog rules. Keep every field the decision needs: id_shop, id_currency, id_country, id_group, id_customer, price, reduction, reduction_type, reduction_tax, from_quantity, from, to.

step3.py
def product_base_price(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    product = data.get("product") or {}
    return float(product.get("price") or 0)

def specific_prices_for(id_product):
    data = api_get("specific_prices", params={
        "filter[id_product]": id_product,
        "display": "full",
    })
    return data.get("specific_prices") or []

def specific_price_rules():
    data = api_get("specific_price_rules", params={"display": "full"})
    return data.get("specific_price_rules") or []
step3.js
async function productBasePrice(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  const product = data.product || {};
  return Number(product.price || 0);
}

async function specificPricesFor(idProduct) {
  const data = await apiGet("specific_prices", {
    "filter[id_product]": idProduct,
    display: "full",
  });
  return data.specific_prices || [];
}

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

Decide, with one pure function

Keep the decision in its own function that takes the base price, the candidate rules, and the customer's context, filters to rules that actually match, computes each one's resulting unit price, and returns whichever rule produces the numerically lowest price, the price the customer legitimately qualifies for. This is the same computation PrestaShop's priority order should produce but is not guaranteed to.

decide.py
ZERO_DATE_PREFIXES = ("0000-00-00",)

def _date_open(value):
    return not value or str(value).startswith(ZERO_DATE_PREFIXES)

def resolve_best_specific_price(base_price, candidate_rules, context):
    """Pure decision function, no I/O.

    context has customer_group_ids (set/list of int), currency_id, country_id,
    customer_id, quantity, now (ISO string, comparable lexicographically with
    PrestaShop's "YYYY-MM-DD HH:MM:SS" format). Returns a dict with best_price and
    winning_rule_index (None if no rule matches, meaning base_price applies).
    """
    now = context["now"]
    group_ids = set(context.get("customer_group_ids") or [])
    best_price = None
    winning_index = None
    for index, rule in enumerate(candidate_rules):
        if rule["id_group"] != 0 and rule["id_group"] not in group_ids:
            continue
        if rule["id_currency"] != 0 and rule["id_currency"] != context["currency_id"]:
            continue
        if rule["id_country"] != 0 and rule["id_country"] != context["country_id"]:
            continue
        if rule["id_customer"] != 0 and rule["id_customer"] != context["customer_id"]:
            continue
        if context["quantity"] < rule["from_quantity"]:
            continue
        if not _date_open(rule.get("from")) and now < rule["from"]:
            continue
        if not _date_open(rule.get("to")) and now > rule["to"]:
            continue

        if rule["reduction_type"] == "percentage":
            price = base_price * (1 - rule["reduction"])
        else:
            price = base_price - rule["reduction"]

        if best_price is None or price < best_price:
            best_price = price
            winning_index = index

    if best_price is None:
        return {"best_price": base_price, "winning_rule_index": None}
    return {"best_price": best_price, "winning_rule_index": winning_index}
decide.js
const ZERO_DATE_PREFIX = "0000-00-00";

function dateOpen(value) {
  return !value || String(value).startsWith(ZERO_DATE_PREFIX);
}

/**
 * Pure decision function, no I/O.
 *
 * context has customerGroupIds (array of number), currencyId, countryId,
 * customerId, quantity, now (ISO-ish string, comparable lexicographically with
 * PrestaShop's "YYYY-MM-DD HH:MM:SS" format). Returns { bestPrice, winningRuleIndex }
 * (winningRuleIndex is null when no rule matches, meaning basePrice applies).
 */
export function resolveBestSpecificPrice(basePrice, candidateRules, context) {
  const groupIds = new Set(context.customerGroupIds || []);
  let bestPrice = null;
  let winningIndex = null;

  candidateRules.forEach((rule, index) => {
    if (rule.idGroup !== 0 && !groupIds.has(rule.idGroup)) return;
    if (rule.idCurrency !== 0 && rule.idCurrency !== context.currencyId) return;
    if (rule.idCountry !== 0 && rule.idCountry !== context.countryId) return;
    if (rule.idCustomer !== 0 && rule.idCustomer !== context.customerId) return;
    if (context.quantity < rule.fromQuantity) return;
    if (!dateOpen(rule.from) && context.now < rule.from) return;
    if (!dateOpen(rule.to) && context.now > rule.to) return;

    const price = rule.reductionType === "percentage"
      ? basePrice * (1 - rule.reduction)
      : basePrice - rule.reduction;

    if (bestPrice === null || price < bestPrice) {
      bestPrice = price;
      winningIndex = index;
    }
  });

  if (bestPrice === null) return { bestPrice: basePrice, winningRuleIndex: null };
  return { bestPrice, winningRuleIndex: winningIndex };
}
5

Compare the recomputed price to what the store actually served

Read the storefront-facing price PrestaShop's own resolution returns for the same product and context, either the price field from a simulated GET /api/products/{id} call in that customer's group and currency, or a second read of the product or combination price after simulating the session. Flag the id_product and id_customer pair when the recomputed best price is lower than what the API reported by more than a currency-rounding epsilon.

compare.py
EPSILON = 0.01

def find_price_mismatch(recalculated_best_price, api_reported_price):
    """Pure decision function, no I/O.

    Returns True when the store served a worse (higher) price than what the
    customer legitimately qualifies for, beyond a currency-rounding epsilon.
    """
    return (api_reported_price - recalculated_best_price) > EPSILON
compare.js
const EPSILON = 0.01;

/**
 * Pure decision function, no I/O.
 *
 * Returns true when the store served a worse (higher) price than what the
 * customer legitimately qualifies for, beyond a currency-rounding epsilon.
 */
export function findPriceMismatch(recalculatedBestPrice, apiReportedPrice) {
  return (apiReportedPrice - recalculatedBestPrice) > EPSILON;
}
6

Wire it together with a dry run guard

The loop ties every piece together: read the customer's context, pull the product's base price and every candidate rule, run resolve_best_specific_price, then compare that result to the price the API actually reports for the same context. Every mismatch is logged as a report row with the winning rule's details. DRY_RUN only gates the narrow single-row repair for a confirmed stale rule, it never gates the report itself. Run it on a schedule that matches how often pricing rules change, for example nightly.

Run it safe

Never bulk-delete or bulk-edit specific_price rows. This is a core priority-resolution defect, so the default action is always to flag the id_product and id_customer pair for review. Only ever repair a single, operator-confirmed superseded row by its own id, with DRY_RUN=true as the default so nothing writes until a human has looked at the exact row being touched.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, recomputes the best legitimate price for every candidate product and customer pair, flags every mismatch, and only ever writes when DRY_RUN is explicitly turned off and a single specific_price id has been confirmed as the target.

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.
check_specific_price_priority.py
"""Detect PrestaShop products whose live price resolution disagrees with the
best legitimate price a customer actually qualifies for.

PrestaShop resolves a product's effective price by scanning specific_price rows
(and specific_price_rule catalog rules) that match the request context, id_shop,
id_currency, id_country, id_group, id_customer, and picking the first one that
matches according to a fixed priority order: Shop, then Currency, then Country,
then Group, and within Group the most specific id_group or id_customer is meant
to beat "all groups" or "all customers." It does not compute every matching rule
and choose the numerically lowest resulting price. Because All Groups
(id_group=0) and generic country or currency wildcards sit in a priority
position that is not strictly "more specific wins," a broader rule can be
selected over a narrower, better rule that actually applies to the customer's
real group or currency. Confirmed in PrestaShop/PrestaShop issue #33736 and the
related discussion in #33440 and #14516 on specific_price versus catalog rule
priority.

This is a core pricing-engine priority-resolution defect, not a bad data row,
so the default action is to flag every mismatch for manual review. A
DRY_RUN-guarded repair is available only for a single, operator-confirmed
superseded specific_price row, targeted by its own id, never a bulk delete.

Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STALE_ROW_ID = os.environ.get("CONFIRMED_STALE_SPECIFIC_PRICE_ID")
AUTH = (PRESTASHOP_WS_KEY, "")

EPSILON = 0.01
ZERO_DATE_PREFIXES = ("0000-00-00",)


def _date_open(value):
    return not value or str(value).startswith(ZERO_DATE_PREFIXES)


def resolve_best_specific_price(base_price, candidate_rules, context):
    """Pure decision function, no I/O.

    context has customer_group_ids (set/list of int), currency_id, country_id,
    customer_id, quantity, now (PrestaShop "YYYY-MM-DD HH:MM:SS" string, compared
    lexicographically). Returns a dict with best_price and winning_rule_index
    (None if no rule matches, meaning base_price applies).
    """
    now = context["now"]
    group_ids = set(context.get("customer_group_ids") or [])
    best_price = None
    winning_index = None
    for index, rule in enumerate(candidate_rules):
        if rule["id_group"] != 0 and rule["id_group"] not in group_ids:
            continue
        if rule["id_currency"] != 0 and rule["id_currency"] != context["currency_id"]:
            continue
        if rule["id_country"] != 0 and rule["id_country"] != context["country_id"]:
            continue
        if rule["id_customer"] != 0 and rule["id_customer"] != context["customer_id"]:
            continue
        if context["quantity"] < rule["from_quantity"]:
            continue
        if not _date_open(rule.get("from")) and now < rule["from"]:
            continue
        if not _date_open(rule.get("to")) and now > rule["to"]:
            continue

        if rule["reduction_type"] == "percentage":
            price = base_price * (1 - rule["reduction"])
        else:
            price = base_price - rule["reduction"]

        if best_price is None or price < best_price:
            best_price = price
            winning_index = index

    if best_price is None:
        return {"best_price": base_price, "winning_rule_index": None}
    return {"best_price": best_price, "winning_rule_index": winning_index}


def find_price_mismatch(recalculated_best_price, api_reported_price):
    """Pure decision function, no I/O.

    Returns True when the store served a worse (higher) price than what the
    customer legitimately qualifies for, beyond a currency-rounding epsilon.
    """
    return (api_reported_price - recalculated_best_price) > EPSILON


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=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def api_delete(path):
    r = requests.delete(f"{PRESTASHOP_URL}/api/{path}", auth=AUTH, timeout=30)
    r.raise_for_status()


def customer_group_ids(id_customer):
    data = api_get(f"customers/{id_customer}", params={"display": "full"})
    customer = data.get("customer") or {}
    groups = ((customer.get("associations") or {}).get("groups")) or []
    ids = {int(g["id"]) for g in groups if g.get("id")}
    if customer.get("id_default_group"):
        ids.add(int(customer["id_default_group"]))
    return ids


def product_base_price(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    product = data.get("product") or {}
    return float(product.get("price") or 0), product


def specific_prices_for(id_product):
    data = api_get("specific_prices", params={"filter[id_product]": id_product, "display": "full"})
    return data.get("specific_prices") or []


def api_reported_price(id_product, id_customer, id_currency):
    """Re-read the product price the way the storefront would, using PrestaShop's
    own filter parameters so its live resolution logic runs, not ours."""
    data = api_get(f"products/{id_product}", params={
        "display": "full",
        "id_customer": id_customer,
        "id_currency": id_currency,
    })
    product = data.get("product") or {}
    return float(product.get("price") or 0)


def normalize_rule(row):
    return {
        "id_group": int(row.get("id_group") or 0),
        "id_currency": int(row.get("id_currency") or 0),
        "id_country": int(row.get("id_country") or 0),
        "id_customer": int(row.get("id_customer") or 0),
        "reduction": float(row.get("reduction") or 0),
        "reduction_type": row.get("reduction_type") or "amount",
        "from_quantity": int(row.get("from_quantity") or 1),
        "from": row.get("from"),
        "to": row.get("to"),
        "id": row.get("id"),
    }


def check_product_for_customer(id_product, id_customer, currency_id, country_id, quantity, now):
    base_price, _product = product_base_price(id_product)
    rows = [normalize_rule(r) for r in specific_prices_for(id_product)]
    context = {
        "customer_group_ids": customer_group_ids(id_customer),
        "currency_id": currency_id,
        "country_id": country_id,
        "customer_id": id_customer,
        "quantity": quantity,
        "now": now,
    }
    result = resolve_best_specific_price(base_price, rows, context)
    served = api_reported_price(id_product, id_customer, currency_id)
    mismatched = find_price_mismatch(result["best_price"], served)
    return {
        "id_product": id_product,
        "id_customer": id_customer,
        "recalculated_best_price": result["best_price"],
        "winning_rule_index": result["winning_rule_index"],
        "winning_rule": rows[result["winning_rule_index"]] if result["winning_rule_index"] is not None else None,
        "api_reported_price": served,
        "mismatched": mismatched,
    }


def repair_confirmed_stale_row(specific_price_id):
    log.warning(
        "%s specific_prices/%s: would DELETE this single confirmed-stale row",
        "DRY RUN" if DRY_RUN else "REPAIRING", specific_price_id,
    )
    if not DRY_RUN:
        api_delete(f"specific_prices/{specific_price_id}")


def run(pairs):
    """pairs is a list of (id_product, id_customer, currency_id, country_id, quantity, now)."""
    flagged = 0
    for id_product, id_customer, currency_id, country_id, quantity, now in pairs:
        row = check_product_for_customer(id_product, id_customer, currency_id, country_id, quantity, now)
        if row["mismatched"]:
            flagged += 1
            log.warning(
                "Price mismatch. id_product=%s id_customer=%s recalculated_best_price=%.2f "
                "api_reported_price=%.2f winning_rule=%s",
                row["id_product"], row["id_customer"], row["recalculated_best_price"],
                row["api_reported_price"], row["winning_rule"],
            )
    if STALE_ROW_ID:
        repair_confirmed_stale_row(STALE_ROW_ID)
    log.info("Done. %d id_product/id_customer pair(s) flagged for review.", flagged)


if __name__ == "__main__":
    run([])
check-specific-price-priority.js
/**
 * Detect PrestaShop products whose live price resolution disagrees with the
 * best legitimate price a customer actually qualifies for.
 *
 * PrestaShop resolves a product's effective price by scanning specific_price rows
 * (and specific_price_rule catalog rules) that match the request context, id_shop,
 * id_currency, id_country, id_group, id_customer, and picking the first one that
 * matches according to a fixed priority order: Shop, then Currency, then Country,
 * then Group, and within Group the most specific id_group or id_customer is meant
 * to beat "all groups" or "all customers." It does not compute every matching rule
 * and choose the numerically lowest resulting price. Because All Groups
 * (id_group=0) and generic country or currency wildcards sit in a priority
 * position that is not strictly "more specific wins," a broader rule can be
 * selected over a narrower, better rule that actually applies to the customer's
 * real group or currency. Confirmed in PrestaShop/PrestaShop issue #33736 and the
 * related discussion in #33440 and #14516 on specific_price versus catalog rule
 * priority.
 *
 * This is a core pricing-engine priority-resolution defect, not a bad data row,
 * so the default action is to flag every mismatch for manual review. A
 * DRY_RUN-guarded repair is available only for a single, operator-confirmed
 * superseded specific_price row, targeted by its own id, never a bulk delete.
 *
 * Guide: https://www.allanninal.dev/prestashop/specific-price-priority-wrong/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const STALE_ROW_ID = process.env.CONFIRMED_STALE_SPECIFIC_PRICE_ID || "";

const EPSILON = 0.01;
const ZERO_DATE_PREFIX = "0000-00-00";

function dateOpen(value) {
  return !value || String(value).startsWith(ZERO_DATE_PREFIX);
}

/**
 * Pure decision function, no I/O.
 *
 * context has customerGroupIds (array of number), currencyId, countryId,
 * customerId, quantity, now (PrestaShop "YYYY-MM-DD HH:MM:SS" string, compared
 * lexicographically). Returns { bestPrice, winningRuleIndex } (winningRuleIndex
 * is null when no rule matches, meaning basePrice applies).
 */
export function resolveBestSpecificPrice(basePrice, candidateRules, context) {
  const groupIds = new Set(context.customerGroupIds || []);
  let bestPrice = null;
  let winningIndex = null;

  candidateRules.forEach((rule, index) => {
    if (rule.idGroup !== 0 && !groupIds.has(rule.idGroup)) return;
    if (rule.idCurrency !== 0 && rule.idCurrency !== context.currencyId) return;
    if (rule.idCountry !== 0 && rule.idCountry !== context.countryId) return;
    if (rule.idCustomer !== 0 && rule.idCustomer !== context.customerId) return;
    if (context.quantity < rule.fromQuantity) return;
    if (!dateOpen(rule.from) && context.now < rule.from) return;
    if (!dateOpen(rule.to) && context.now > rule.to) return;

    const price = rule.reductionType === "percentage"
      ? basePrice * (1 - rule.reduction)
      : basePrice - rule.reduction;

    if (bestPrice === null || price < bestPrice) {
      bestPrice = price;
      winningIndex = index;
    }
  });

  if (bestPrice === null) return { bestPrice: basePrice, winningRuleIndex: null };
  return { bestPrice, winningRuleIndex: winningIndex };
}

/**
 * Pure decision function, no I/O.
 *
 * Returns true when the store served a worse (higher) price than what the
 * customer legitimately qualifies for, beyond a currency-rounding epsilon.
 */
export function findPriceMismatch(recalculatedBestPrice, apiReportedPrice) {
  return (apiReportedPrice - recalculatedBestPrice) > EPSILON;
}

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

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

async function apiDelete(path) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  const res = await fetch(url, { method: "DELETE", headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on DELETE ${path}`);
}

async function customerGroupIds(idCustomer) {
  const data = await apiGet(`customers/${idCustomer}`, { display: "full" });
  const customer = data.customer || {};
  const groups = (customer.associations && customer.associations.groups) || [];
  const ids = new Set(groups.filter((g) => g.id).map((g) => Number(g.id)));
  if (customer.id_default_group) ids.add(Number(customer.id_default_group));
  return ids;
}

async function productBasePrice(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  const product = data.product || {};
  return Number(product.price || 0);
}

async function specificPricesFor(idProduct) {
  const data = await apiGet("specific_prices", { "filter[id_product]": idProduct, display: "full" });
  return data.specific_prices || [];
}

async function apiReportedPrice(idProduct, idCustomer, idCurrency) {
  const data = await apiGet(`products/${idProduct}`, {
    display: "full",
    id_customer: idCustomer,
    id_currency: idCurrency,
  });
  const product = data.product || {};
  return Number(product.price || 0);
}

function normalizeRule(row) {
  return {
    idGroup: Number(row.id_group || 0),
    idCurrency: Number(row.id_currency || 0),
    idCountry: Number(row.id_country || 0),
    idCustomer: Number(row.id_customer || 0),
    reduction: Number(row.reduction || 0),
    reductionType: row.reduction_type || "amount",
    fromQuantity: Number(row.from_quantity || 1),
    from: row.from,
    to: row.to,
    id: row.id,
  };
}

async function checkProductForCustomer(idProduct, idCustomer, currencyId, countryId, quantity, now) {
  const basePrice = await productBasePrice(idProduct);
  const rows = (await specificPricesFor(idProduct)).map(normalizeRule);
  const context = {
    customerGroupIds: await customerGroupIds(idCustomer),
    currencyId,
    countryId,
    customerId: idCustomer,
    quantity,
    now,
  };
  const result = resolveBestSpecificPrice(basePrice, rows, context);
  const served = await apiReportedPrice(idProduct, idCustomer, currencyId);
  const mismatched = findPriceMismatch(result.bestPrice, served);
  return {
    idProduct,
    idCustomer,
    recalculatedBestPrice: result.bestPrice,
    winningRuleIndex: result.winningRuleIndex,
    winningRule: result.winningRuleIndex !== null ? rows[result.winningRuleIndex] : null,
    apiReportedPrice: served,
    mismatched,
  };
}

async function repairConfirmedStaleRow(specificPriceId) {
  console.warn(
    `${DRY_RUN ? "DRY RUN" : "REPAIRING"} specific_prices/${specificPriceId}: would DELETE this single confirmed-stale row`
  );
  if (!DRY_RUN) await apiDelete(`specific_prices/${specificPriceId}`);
}

export async function run(pairs = []) {
  let flagged = 0;
  for (const [idProduct, idCustomer, currencyId, countryId, quantity, now] of pairs) {
    const row = await checkProductForCustomer(idProduct, idCustomer, currencyId, countryId, quantity, now);
    if (row.mismatched) {
      flagged++;
      console.warn(
        `Price mismatch. id_product=${row.idProduct} id_customer=${row.idCustomer} ` +
          `recalculated_best_price=${row.recalculatedBestPrice.toFixed(2)} ` +
          `api_reported_price=${row.apiReportedPrice.toFixed(2)} winning_rule=${JSON.stringify(row.winningRule)}`
      );
    }
  }
  if (STALE_ROW_ID) await repairConfirmedStaleRow(STALE_ROW_ID);
  console.log(`Done. ${flagged} id_product/id_customer pair(s) flagged for review.`);
}

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

Add a test

The resolution rule is the part most worth testing, because it decides which price is treated as the one the customer legitimately qualifies for. Because we kept resolveBestSpecificPrice and findPriceMismatch pure, the tests need no network and no PrestaShop store. They just feed in plain rule objects and check the winning price.

test_priority_resolution.py
from check_specific_price_priority import resolve_best_specific_price, find_price_mismatch

BASE_PRICE = 100.0
NOW = "2026-07-10 12:00:00"


def rule(**over):
    base = {
        "id_group": 0, "id_currency": 0, "id_country": 0, "id_customer": 0,
        "reduction": 0, "reduction_type": "amount", "from_quantity": 1,
        "from": None, "to": None,
    }
    base.update(over)
    return base


def context(**over):
    base = {
        "customer_group_ids": {12}, "currency_id": 1, "country_id": 1,
        "customer_id": 501, "quantity": 1, "now": NOW,
    }
    base.update(over)
    return base


def test_narrow_group_row_beats_all_groups_row_when_both_match():
    rules = [
        rule(id_group=0, reduction=10),   # all groups, price 90
        rule(id_group=12, reduction=11),  # this customer's group, price 89
    ]
    result = resolve_best_specific_price(BASE_PRICE, rules, context())
    assert result["best_price"] == 89.0
    assert result["winning_rule_index"] == 1


def test_rule_scoped_to_a_different_group_is_ignored():
    rules = [rule(id_group=99, reduction=50)]
    result = resolve_best_specific_price(BASE_PRICE, rules, context())
    assert result["best_price"] == BASE_PRICE
    assert result["winning_rule_index"] is None


def test_percentage_reduction_is_computed_correctly():
    rules = [rule(id_group=0, reduction=0.20, reduction_type="percentage")]
    result = resolve_best_specific_price(BASE_PRICE, rules, context())
    assert result["best_price"] == 80.0


def test_currency_mismatch_excludes_the_rule():
    rules = [rule(id_group=0, id_currency=2, reduction=50)]
    result = resolve_best_specific_price(BASE_PRICE, rules, context(currency_id=1))
    assert result["winning_rule_index"] is None


def test_from_quantity_tier_excludes_when_quantity_too_low():
    rules = [rule(id_group=0, reduction=30, from_quantity=5)]
    result = resolve_best_specific_price(BASE_PRICE, rules, context(quantity=1))
    assert result["winning_rule_index"] is None


def test_expired_date_window_excludes_the_rule():
    rules = [rule(id_group=0, reduction=30, to="2020-01-01 00:00:00")]
    result = resolve_best_specific_price(BASE_PRICE, rules, context())
    assert result["winning_rule_index"] is None


def test_zero_date_is_treated_as_unbounded():
    rules = [rule(id_group=0, reduction=15, from="0000-00-00 00:00:00", to="0000-00-00 00:00:00")]
    result = resolve_best_specific_price(BASE_PRICE, rules, context())
    assert result["best_price"] == 85.0


def test_find_price_mismatch_flags_when_store_served_a_worse_price():
    assert find_price_mismatch(89.0, 90.0) is True


def test_find_price_mismatch_ignores_rounding_epsilon():
    assert find_price_mismatch(89.995, 90.0) is False


def test_find_price_mismatch_false_when_store_agrees():
    assert find_price_mismatch(89.0, 89.0) is False
priority-resolution.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveBestSpecificPrice, findPriceMismatch } from "./check-specific-price-priority.js";

const BASE_PRICE = 100.0;
const NOW = "2026-07-10 12:00:00";

const rule = (over = {}) => ({
  idGroup: 0, idCurrency: 0, idCountry: 0, idCustomer: 0,
  reduction: 0, reductionType: "amount", fromQuantity: 1,
  from: null, to: null,
  ...over,
});

const context = (over = {}) => ({
  customerGroupIds: [12], currencyId: 1, countryId: 1,
  customerId: 501, quantity: 1, now: NOW,
  ...over,
});

test("narrow group row beats all groups row when both match", () => {
  const rules = [
    rule({ idGroup: 0, reduction: 10 }),   // all groups, price 90
    rule({ idGroup: 12, reduction: 11 }),  // this customer's group, price 89
  ];
  const result = resolveBestSpecificPrice(BASE_PRICE, rules, context());
  assert.equal(result.bestPrice, 89.0);
  assert.equal(result.winningRuleIndex, 1);
});

test("rule scoped to a different group is ignored", () => {
  const rules = [rule({ idGroup: 99, reduction: 50 })];
  const result = resolveBestSpecificPrice(BASE_PRICE, rules, context());
  assert.equal(result.bestPrice, BASE_PRICE);
  assert.equal(result.winningRuleIndex, null);
});

test("percentage reduction is computed correctly", () => {
  const rules = [rule({ idGroup: 0, reduction: 0.20, reductionType: "percentage" })];
  const result = resolveBestSpecificPrice(BASE_PRICE, rules, context());
  assert.equal(result.bestPrice, 80.0);
});

test("currency mismatch excludes the rule", () => {
  const rules = [rule({ idGroup: 0, idCurrency: 2, reduction: 50 })];
  const result = resolveBestSpecificPrice(BASE_PRICE, rules, context({ currencyId: 1 }));
  assert.equal(result.winningRuleIndex, null);
});

test("from_quantity tier excludes when quantity too low", () => {
  const rules = [rule({ idGroup: 0, reduction: 30, fromQuantity: 5 })];
  const result = resolveBestSpecificPrice(BASE_PRICE, rules, context({ quantity: 1 }));
  assert.equal(result.winningRuleIndex, null);
});

test("expired date window excludes the rule", () => {
  const rules = [rule({ idGroup: 0, reduction: 30, to: "2020-01-01 00:00:00" })];
  const result = resolveBestSpecificPrice(BASE_PRICE, rules, context());
  assert.equal(result.winningRuleIndex, null);
});

test("zero date is treated as unbounded", () => {
  const rules = [rule({ idGroup: 0, reduction: 15, from: "0000-00-00 00:00:00", to: "0000-00-00 00:00:00" })];
  const result = resolveBestSpecificPrice(BASE_PRICE, rules, context());
  assert.equal(result.bestPrice, 85.0);
});

test("findPriceMismatch flags when store served a worse price", () => {
  assert.equal(findPriceMismatch(89.0, 90.0), true);
});

test("findPriceMismatch ignores rounding epsilon", () => {
  assert.equal(findPriceMismatch(89.995, 90.0), false);
});

test("findPriceMismatch false when store agrees", () => {
  assert.equal(findPriceMismatch(89.0, 89.0), false);
});

Case studies

B2B wholesale group

The wholesale group that kept seeing the retail sale price

A hardware supplier gave its Wholesale customer group a fixed 89 price on a popular tool, while a storewide seasonal sale sat on the same product at 90 for everyone. The two rows both technically matched a wholesale customer during the sale window, but the store consistently served 90, the seasonal price meant for retail shoppers, instead of the tighter wholesale rate.

Running the reconciler against the wholesale customer list surfaced every id_product and id_customer pair where the recalculated best price undercut what the API actually returned. The merchant used the flagged list to manually confirm which rows were genuine overlaps and adjusted the seasonal rule's scope so it no longer applied to the Wholesale group, rather than trying to patch PrestaShop's own priority order.

Multi-currency storefront

The EUR-scoped discount that leaked into a USD checkout

A gift shop ran a currency-specific loyalty discount meant only for EUR customers, alongside a generic All Currencies catalog rule with a smaller discount. USD customers who belonged to the loyalty group should have fallen through to the generic rule, but a handful of accounts kept the earlier, more expensive resolution because of how the priority order weighed currency against group.

The script's recomputation, run in dry run first against a batch of recent USD orders, flagged the exact accounts and product pairs affected. Finance reviewed each flagged row, confirmed the intended discount, and manually corrected the specific handful of misconfigured rows rather than letting an automated job touch pricing data directly.

What good looks like

After this runs on a schedule, every mismatch between the price a customer legitimately qualifies for and the price PrestaShop actually served shows up as a clear, dated report row instead of a silent undercharge or overcharge someone stumbles on during a margin review. Pricing data itself stays untouched unless a human has confirmed a specific row is genuinely stale, and even then only that one row's id is ever targeted.

FAQ

Why does PrestaShop show the wrong price when several specific price rules apply?

PrestaShop does not compute every matching specific_price row and pick the lowest result. It scans the rows that match the request context and picks the first one according to a fixed priority order, Shop then Currency then Country then Group, and a broad All Groups or All Currencies row is not guaranteed to lose to a narrower rule that actually fits the customer, so a worse price can win even though a better one legitimately applies.

Is this the same as the specific price versus catalog rule priority bug?

It is related but broader. The specific price versus catalog rule ordering is one documented case of the same root cause, where a specific_price_rule catalog rule and a specific_price row can disagree on which should win. The general problem is that the evaluation order was never designed to guarantee the numerically lowest legitimate price, so overlaps between any two rows, not only catalog rules, can produce the same kind of mismatch.

Should a script just auto-fix the wrong price?

No, not by default. This is a core pricing engine priority defect, not a bad data row, so the safe action is to flag the id_product and id_customer pair for review. An automated repair is only appropriate for a single, clearly stale specific_price row, such as one whose date window should already have ended, and even then the fix targets that one row's id with DRY_RUN on by default, never a bulk delete.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Priorities of applying specific prices are wrong. Issue #33736. github.com/PrestaShop/PrestaShop/issues/33736
  2. PrestaShop GitHub: Specific prices priority over catalog rules, a logic approach. Issue #14516. github.com/PrestaShop/PrestaShop/issues/14516
  3. PrestaShop GitHub: SpecificPrice calculated for customer groups instead of default_group_id. Issue #23219. github.com/PrestaShop/PrestaShop/issues/23219

On the solution:

  1. PrestaShop Developer Documentation: Specific prices webservice resource. devdocs.prestashop-project.org/9/webservice/resources/specific_prices/
  2. PrestaShop Developer Documentation: Specific price rules webservice resource. devdocs.prestashop-project.org/9/webservice/resources/specific_price_rules/
  3. PrestaShop Developer Documentation: The PrestaShop Webservice API. devdocs.prestashop-project.org/9/webservice/

Stuck on a tricky one?

If you have a problem in PrestaShop orders, payments, pricing, 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 untangle a pricing mismatch?

If this saved you a confusing margin review or a customer complaint about the wrong price, 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