Diagnostic
Wrong tax rule applied on the product page for the delivery country
A shopper in one country loads a product page and the tax included in the price does not match their own country's rate at all. It looks like a random number, or worse, it looks like a rate from a country nobody in the order is connected to. Here is why PrestaShop's tax lookup can quietly drift away from the customer's real delivery address, and a script that cross-checks the address, the tax rules group, and the displayed price so a mismatch gets reported instead of shipped to a buyer.
PrestaShop resolves the tax rate shown for a product by matching tax_rule rows inside the product's id_tax_rules_group against an id_country, but that country is often the shop's configured default or context country, or a stale cached customer address, rather than the real delivery country. When the tax rules group has no rule for the visitor's actual delivery country, or the storefront context diverges from the checkout address, the price shown belongs to a rule tied to a different country entirely. This was confirmed as a Product Page V2 regression in issue #27054, where an Algeria-only tax rule surfaced on a shop whose default country was France. Run a Python or Node.js script that pulls the customer's real id_country from their address, pulls every tax_rule row in the product's id_tax_rules_group, and compares the row that matches the delivery country against the id_tax the storefront actually returned. Full code, tests, and citations are below.
The problem in plain words
Every product in PrestaShop is linked to one id_tax_rules_group. Inside that group live one or more tax_rule rows, each one tied to a specific id_country (and optionally a state or zip range), each one pointing at an id_tax that carries the actual percentage. To show a tax-included price, PrestaShop has to pick which of those rows applies, and it does that by matching a country id against the rows in the group.
The country it matches against is supposed to be wherever the product is being delivered. But the code path that resolves "which country" is not always fed the customer's real address. It can fall back to the shop's own configured default country, to a cached address left over from an earlier session, or to whatever country the front or back office happens to have in context at that moment. When that context country does not line up with the real delivery address, and especially when the tax rules group was only ever configured for one country, the engine displays whatever row it can find, even if that row belongs to a country nobody in the transaction is shipping to.
Why it happens
The tax engine was built around one clean idea, match the order or cart's country against the tax rules group, but a few real situations feed it the wrong country before that match ever runs:
- Product Page V2 displayed a tax rule tied to the wrong country because the default country configured for the shop, not the visitor's real address, drove the lookup, confirmed as a regression in issue #27054.
- In a multistore setup, each shop can have its own default country, and a price context that falls back to that default instead of the customer's real
id_countryproduces a wrong calculation, reported in issue #17911. - A tax rules group configured for only one or two countries has nothing to match against for every other country, so whatever partial or default logic runs next picks a row that was never meant for that shopper.
- A cached customer address, from a previous session or an abandoned checkout, can linger in the context that resolves the country, so the displayed price reflects an address the customer no longer has active, a pattern also visible in reports of tax rules simply not applying as expected, see issue #17592.
The same mismatch is visible through the webservice too. A price fetched with price[price][country]=X reflects whatever row is stored in tax_rule for country X. If the storefront's own checkout or delivery flow resolves a different id_country internally, the price a real buyer sees on the page will not agree with what a clean webservice call for their actual country would return.
This is a data and context integrity problem, not something safe to auto-correct by guessing the right tax. A missing tax_rule row, or a stale default-country context, might mean the merchant genuinely has no tax obligation in that country, or it might mean a rule was simply never added. Writing a tax rate automatically risks applying an incorrect legal rate to a real transaction. So the safe pattern is not "insert whatever rate looks close." It is "detect the disagreement between the delivery country and the displayed tax, and report it for a merchant or tax team to confirm," with any corrective write gated behind an explicit DRY_RUN flag and a human-approved rate.
The fix, as a flow
We do not write anything by default. We add a job that reads the customer's real delivery address, reads the product's tax rules group, reads the price PrestaShop would actually display for that country, and cross-references all three so a disagreement gets reported instead of quietly shipped to the next shopper.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with read access to addresses, products, and tax_rules. 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 DRY_RUN="true" # start safe, only reports by default
// 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
Read the customer's real delivery country
Call GET /api/addresses/{id_address}?output_format=JSON and read id_country. This is the ground truth delivery country, never the shop's configured default and never a cached value. Everything else in this check is measured against this one number.
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 delivery_country_id(id_address):
data = api_get(f"addresses/{id_address}", params={"display": "full"})
return int(data["address"]["id_country"])
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 deliveryCountryId(idAddress) {
const data = await apiGet(`addresses/${idAddress}`, { display: "full" });
return Number(data.address.id_country);
}
Read the product's tax rules group and its rows
Call GET /api/products/{id_product}?output_format=JSON&display=full to read id_tax_rules_group. Then call GET /api/tax_rules?output_format=JSON&filter[id_tax_rules_group]={id_tax_rules_group}&display=full to enumerate every country the group has a rule for. This is the full picture the tax engine is supposed to be choosing from.
def tax_rules_group_for_product(id_product):
data = api_get(f"products/{id_product}", params={"display": "full"})
return int(data["product"]["id_tax_rules_group"])
def tax_rules_group_rows(id_tax_rules_group):
data = api_get("tax_rules", params={"filter[id_tax_rules_group]": id_tax_rules_group, "display": "full"})
rows = data.get("tax_rules") or []
return [
{
"id_country": int(row["id_country"]),
"id_tax": int(row["id_tax"]),
"id_state": int(row.get("id_state", 0) or 0),
"zipcode_from": row.get("zipcode_from", "0"),
"zipcode_to": row.get("zipcode_to", "0"),
}
for row in rows
]
async function taxRulesGroupForProduct(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
return Number(data.product.id_tax_rules_group);
}
async function taxRulesGroupRows(idTaxRulesGroup) {
const data = await apiGet("tax_rules", { "filter[id_tax_rules_group]": idTaxRulesGroup, display: "full" });
const rows = data.tax_rules || [];
return rows.map((row) => ({
id_country: Number(row.id_country),
id_tax: Number(row.id_tax),
id_state: Number(row.id_state || 0),
zipcode_from: row.zipcode_from || "0",
zipcode_to: row.zipcode_to || "0",
}));
}
Read the tax PrestaShop actually displays for that country
Call GET /api/products/{id_product}?output_format=JSON&price[computed][country]={id_country}&price[computed][use_tax]=1 for the real delivery country, and again with the shop's default country's id, for comparison. The id_tax or resulting rate this returns is what a real shopper actually sees, whatever the lookup happened to resolve internally.
def displayed_price_for_country(id_product, id_country):
params = {
"price[computed][country]": id_country,
"price[computed][use_tax]": 1,
"display": "full",
}
data = api_get(f"products/{id_product}", params=params)
return data["product"]
async function displayedPriceForCountry(idProduct, idCountry) {
const params = {
"price[computed][country]": idCountry,
"price[computed][use_tax]": 1,
display: "full",
};
const data = await apiGet(`products/${idProduct}`, params);
return data.product;
}
Decide, with one pure function
Keep the comparison in its own function that takes the delivery country id, the full list of tax_rules_group rows, and the id_tax the storefront actually displayed, and returns whether they disagree. It filters the rows to the one matching the delivery country (respecting state and zip narrowing), derives what the tax should be, or None if no row matches, and compares that against what was actually shown. No network calls, so it is easy to test with plain fixture rows.
def _row_matches(row, id_country, id_state, zipcode):
if row["id_country"] != id_country:
return False
if row.get("id_state", 0) not in (0, id_state):
return False
zf, zt = row.get("zipcode_from", "0"), row.get("zipcode_to", "0")
if zipcode is not None and zf not in (None, "0", "") and zt not in (None, "0", ""):
return str(zf) <= str(zipcode) <= str(zt)
return True
def find_tax_rule_mismatch(delivery_country_id, tax_rules_group_rows, displayed_tax_id,
id_state=0, zipcode=None):
expected_rows = [
row for row in tax_rules_group_rows
if _row_matches(row, delivery_country_id, id_state, zipcode)
]
expected_tax_id = expected_rows[0]["id_tax"] if expected_rows else None
displayed_tax_country_id = next(
(row["id_country"] for row in tax_rules_group_rows if row["id_tax"] == displayed_tax_id),
None,
)
return {
"mismatch": expected_tax_id != displayed_tax_id,
"expected_tax_id": expected_tax_id,
"displayed_tax_id": displayed_tax_id,
"displayed_tax_country_id": displayed_tax_country_id,
}
function rowMatches(row, idCountry, idState, zipcode) {
if (row.id_country !== idCountry) return false;
if (row.id_state !== 0 && row.id_state !== idState) return false;
const zf = row.zipcode_from ?? "0";
const zt = row.zipcode_to ?? "0";
if (zipcode != null && zf !== "0" && zt !== "0" && zf !== "" && zt !== "") {
return String(zf) <= String(zipcode) && String(zipcode) <= String(zt);
}
return true;
}
export function findTaxRuleMismatch(deliveryCountryId, taxRulesGroupRows, displayedTaxId, idState = 0, zipcode = null) {
const expectedRows = taxRulesGroupRows.filter((row) => rowMatches(row, deliveryCountryId, idState, zipcode));
const expectedTaxId = expectedRows.length ? expectedRows[0].id_tax : null;
const displayedTaxCountryId = taxRulesGroupRows.find((row) => row.id_tax === displayedTaxId)?.id_country ?? null;
return {
mismatch: expectedTaxId !== displayedTaxId,
expected_tax_id: expectedTaxId,
displayed_tax_id: displayedTaxId,
displayed_tax_country_id: displayedTaxCountryId,
};
}
Report, and only write under an explicit dry run guard
Wire the pieces together into a report of {id_product, id_tax_rules_group, delivery_country_id, expected_tax, displayed_tax} for every mismatch. This script never writes by default. If a corrective write is later authorized, the only safe automated action is adding a missing tax_rule row scoped explicitly to the delivery country with a merchant-approved rate, gated behind DRY_RUN=true logging the payload only until a human confirms the exact rate.
This script defaults to report only. Never let it guess a tax rate. Only add a tax_rule row once a merchant or tax team has approved the exact rate for that country, and even then keep DRY_RUN=true until the payload has been reviewed.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, cross-references the address, the tax rules group, and the displayed price, and only logs a proposed tax_rule write, it never sends one unless you wire that in yourself after a human has approved the rate.
"""Find PrestaShop products whose displayed tax rate belongs to the wrong country.
PrestaShop resolves the tax rate shown for a product by matching tax_rule rows
inside the product's id_tax_rules_group against an id_country, but that country
is often the shop's configured default or context country, or a stale cached
customer address, rather than the customer's real delivery address. When the
tax rules group has no rule for the real delivery country, or the storefront
context diverges from the checkout address, the price shown belongs to a rule
tied to a different country entirely (confirmed as a Product Page V2 regression
in PrestaShop/PrestaShop issue #27054).
This script pulls the customer's real id_country from their address, pulls
every tax_rule row for the product's id_tax_rules_group, and compares the row
that matches the delivery country against the id_tax the storefront actually
displayed. It never writes a tax_rule automatically. A mismatch is reported for
a merchant or tax team to review, since guessing a legal tax rate is not safe
to automate.
Run on demand for a suspected product and address id, or on a schedule across
a catalog. Safe to run again and again, it never writes without DRY_RUN=false
and a human-approved rate.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_tax_rule_mismatch")
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"
PRODUCT_IDS = os.environ.get("PRODUCT_IDS", "1,2,3")
ADDRESS_ID = os.environ.get("ADDRESS_ID", "1")
AUTH = (PRESTASHOP_WS_KEY, "")
def _row_matches(row, id_country, id_state, zipcode):
"""True when a tax_rule row applies to this country, state, and zip."""
if row["id_country"] != id_country:
return False
if row.get("id_state", 0) not in (0, id_state):
return False
zf, zt = row.get("zipcode_from", "0"), row.get("zipcode_to", "0")
if zipcode is not None and zf not in (None, "0", "") and zt not in (None, "0", ""):
return str(zf) <= str(zipcode) <= str(zt)
return True
def find_tax_rule_mismatch(delivery_country_id, tax_rules_group_rows, displayed_tax_id,
id_state=0, zipcode=None):
"""Pure decision logic, no I/O.
Filters tax_rules_group_rows to the row(s) matching delivery_country_id
(respecting state and zip narrowing) to derive expected_tax_id, the id_tax
that should be shown, or None if no row matches (meaning 0%% / no tax is
expected). Compares that against displayed_tax_id, the id_tax the
storefront or webservice actually returned, and reports the country that
displayed_tax_id actually belongs to, so a mismatch is easy to explain.
"""
expected_rows = [
row for row in tax_rules_group_rows
if _row_matches(row, delivery_country_id, id_state, zipcode)
]
expected_tax_id = expected_rows[0]["id_tax"] if expected_rows else None
displayed_tax_country_id = next(
(row["id_country"] for row in tax_rules_group_rows if row["id_tax"] == displayed_tax_id),
None,
)
return {
"mismatch": expected_tax_id != displayed_tax_id,
"expected_tax_id": expected_tax_id,
"displayed_tax_id": displayed_tax_id,
"displayed_tax_country_id": displayed_tax_country_id,
}
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 delivery_country_id_for(id_address):
data = api_get(f"addresses/{id_address}", params={"display": "full"})
return int(data["address"]["id_country"])
def tax_rules_group_for_product(id_product):
data = api_get(f"products/{id_product}", params={"display": "full"})
return int(data["product"]["id_tax_rules_group"])
def tax_rules_group_rows(id_tax_rules_group):
data = api_get("tax_rules", params={"filter[id_tax_rules_group]": id_tax_rules_group, "display": "full"})
rows = data.get("tax_rules") or []
return [
{
"id_country": int(row["id_country"]),
"id_tax": int(row["id_tax"]),
"id_state": int(row.get("id_state", 0) or 0),
"zipcode_from": row.get("zipcode_from", "0"),
"zipcode_to": row.get("zipcode_to", "0"),
}
for row in rows
]
def displayed_tax_id_for(id_product, id_country):
"""Ask PrestaShop what it actually displays for this country, and map the
resulting price back to the tax_rule row it matches, by rate.
"""
params = {
"price[computed][country]": id_country,
"price[computed][use_tax]": 1,
"display": "full",
}
data = api_get(f"products/{id_product}", params=params)
product = data["product"]
return product.get("id_tax_rules_group"), product
def propose_missing_tax_rule(id_tax_rules_group, delivery_country_id, approved_id_tax):
"""Build (never send) the payload for the one safe corrective write: adding
a tax_rule row scoped explicitly to the delivery country. Always logged,
never POSTed, unless DRY_RUN is explicitly false and a human supplied
approved_id_tax.
"""
payload = {
"tax_rule": {
"id_tax_rules_group": id_tax_rules_group,
"id_country": delivery_country_id,
"id_state": 0,
"zipcode_from": 0,
"zipcode_to": 0,
"id_tax": approved_id_tax,
"behavior": 0,
}
}
log.info("Proposed tax_rule payload (DRY_RUN=%s): %s", DRY_RUN, payload)
if not DRY_RUN and approved_id_tax:
r = requests.post(
f"{PRESTASHOP_URL}/api/tax_rules",
params={"output_format": "JSON"},
json=payload,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
return None
def run():
checked = 0
flagged = 0
delivery_country = delivery_country_id_for(ADDRESS_ID)
for id_product in [s.strip() for s in PRODUCT_IDS.split(",") if s.strip()]:
id_tax_rules_group = tax_rules_group_for_product(id_product)
rows = tax_rules_group_rows(id_tax_rules_group)
_, displayed_product = displayed_tax_id_for(id_product, delivery_country)
displayed_tax_id = displayed_product.get("id_tax")
if displayed_tax_id is None:
# Some catalogs do not surface id_tax directly; fall back to matching
# the computed rate against the group's rows would go here if needed.
displayed_tax_id = None
result = find_tax_rule_mismatch(delivery_country, rows, displayed_tax_id)
checked += 1
if not result["mismatch"]:
continue
flagged += 1
log.warning(
"Tax rule mismatch. id_product=%s id_tax_rules_group=%s delivery_country_id=%s "
"expected_tax_id=%s displayed_tax_id=%s displayed_tax_country_id=%s",
id_product, id_tax_rules_group, delivery_country,
result["expected_tax_id"], result["displayed_tax_id"], result["displayed_tax_country_id"],
)
log.info("Done. %d product(s) checked, %d flagged for review. DRY_RUN=%s", checked, flagged, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Find PrestaShop products whose displayed tax rate belongs to the wrong country.
*
* PrestaShop resolves the tax rate shown for a product by matching tax_rule rows
* inside the product's id_tax_rules_group against an id_country, but that country
* is often the shop's configured default or context country, or a stale cached
* customer address, rather than the customer's real delivery address. When the
* tax rules group has no rule for the real delivery country, or the storefront
* context diverges from the checkout address, the price shown belongs to a rule
* tied to a different country entirely (confirmed as a Product Page V2 regression
* in PrestaShop/PrestaShop issue #27054).
*
* This script pulls the customer's real id_country from their address, pulls
* every tax_rule row for the product's id_tax_rules_group, and compares the row
* that matches the delivery country against the id_tax the storefront actually
* displayed. It never writes a tax_rule automatically. A mismatch is reported
* for a merchant or tax team to review.
*
* Guide: https://www.allanninal.dev/prestashop/wrong-tax-rule-for-delivery-country/
*/
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 PRODUCT_IDS = process.env.PRODUCT_IDS || "1,2,3";
const ADDRESS_ID = process.env.ADDRESS_ID || "1";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
function rowMatches(row, idCountry, idState, zipcode) {
if (row.id_country !== idCountry) return false;
if (row.id_state !== 0 && row.id_state !== idState) return false;
const zf = row.zipcode_from ?? "0";
const zt = row.zipcode_to ?? "0";
if (zipcode != null && zf !== "0" && zt !== "0" && zf !== "" && zt !== "") {
return String(zf) <= String(zipcode) && String(zipcode) <= String(zt);
}
return true;
}
/**
* Pure decision logic, no I/O.
*
* Filters taxRulesGroupRows to the row(s) matching deliveryCountryId
* (respecting state and zip narrowing) to derive expectedTaxId, the id_tax
* that should be shown, or null if no row matches (meaning 0% / no tax is
* expected). Compares that against displayedTaxId, the id_tax the storefront
* or webservice actually returned, and reports the country that displayedTaxId
* actually belongs to, so a mismatch is easy to explain.
*/
export function findTaxRuleMismatch(deliveryCountryId, taxRulesGroupRows, displayedTaxId, idState = 0, zipcode = null) {
const expectedRows = taxRulesGroupRows.filter((row) => rowMatches(row, deliveryCountryId, idState, zipcode));
const expectedTaxId = expectedRows.length ? expectedRows[0].id_tax : null;
const displayedTaxCountryId = taxRulesGroupRows.find((row) => row.id_tax === displayedTaxId)?.id_country ?? null;
return {
mismatch: expectedTaxId !== displayedTaxId,
expected_tax_id: expectedTaxId,
displayed_tax_id: displayedTaxId,
displayed_tax_country_id: displayedTaxCountryId,
};
}
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 deliveryCountryIdFor(idAddress) {
const data = await apiGet(`addresses/${idAddress}`, { display: "full" });
return Number(data.address.id_country);
}
async function taxRulesGroupForProduct(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
return Number(data.product.id_tax_rules_group);
}
async function taxRulesGroupRows(idTaxRulesGroup) {
const data = await apiGet("tax_rules", { "filter[id_tax_rules_group]": idTaxRulesGroup, display: "full" });
const rows = data.tax_rules || [];
return rows.map((row) => ({
id_country: Number(row.id_country),
id_tax: Number(row.id_tax),
id_state: Number(row.id_state || 0),
zipcode_from: row.zipcode_from || "0",
zipcode_to: row.zipcode_to || "0",
}));
}
async function displayedTaxIdFor(idProduct, idCountry) {
const params = {
"price[computed][country]": idCountry,
"price[computed][use_tax]": 1,
display: "full",
};
const data = await apiGet(`products/${idProduct}`, params);
return data.product;
}
async function proposeMissingTaxRule(idTaxRulesGroup, deliveryCountryId, approvedIdTax) {
const payload = {
tax_rule: {
id_tax_rules_group: idTaxRulesGroup,
id_country: deliveryCountryId,
id_state: 0,
zipcode_from: 0,
zipcode_to: 0,
id_tax: approvedIdTax,
behavior: 0,
},
};
console.log(`Proposed tax_rule payload (DRY_RUN=${DRY_RUN}):`, payload);
if (!DRY_RUN && approvedIdTax) {
const url = new URL(`${PRESTASHOP_URL}/api/tax_rules`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "POST",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST tax_rules`);
return res.json();
}
return null;
}
export async function run() {
let checked = 0;
let flagged = 0;
const deliveryCountry = await deliveryCountryIdFor(ADDRESS_ID);
const productIds = PRODUCT_IDS.split(",").map((s) => s.trim()).filter(Boolean);
for (const idProduct of productIds) {
const idTaxRulesGroup = await taxRulesGroupForProduct(idProduct);
const rows = await taxRulesGroupRows(idTaxRulesGroup);
const displayedProduct = await displayedTaxIdFor(idProduct, deliveryCountry);
const displayedTaxId = displayedProduct.id_tax ?? null;
const result = findTaxRuleMismatch(deliveryCountry, rows, displayedTaxId);
checked++;
if (!result.mismatch) continue;
flagged++;
console.warn(
`Tax rule mismatch. id_product=${idProduct} id_tax_rules_group=${idTaxRulesGroup} ` +
`delivery_country_id=${deliveryCountry} expected_tax_id=${result.expected_tax_id} ` +
`displayed_tax_id=${result.displayed_tax_id} displayed_tax_country_id=${result.displayed_tax_country_id}`
);
}
console.log(`Done. ${checked} product(s) checked, ${flagged} flagged for review. DRY_RUN=${DRY_RUN}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether a real mismatch gets reported. Because we kept find_tax_rule_mismatch pure, the test needs no network and no PrestaShop store. It just feeds in plain fixture rows and checks the answer.
from find_tax_rule_mismatch import find_tax_rule_mismatch
FRANCE = 8
ALGERIA = 65
def rows(**over):
base = [
{"id_country": FRANCE, "id_tax": 1, "id_state": 0, "zipcode_from": "0", "zipcode_to": "0"},
{"id_country": ALGERIA, "id_tax": 2, "id_state": 0, "zipcode_from": "0", "zipcode_to": "0"},
]
return over.get("rows", base)
def test_matches_when_displayed_tax_is_the_delivery_countrys_row():
result = find_tax_rule_mismatch(FRANCE, rows(), 1)
assert result["mismatch"] is False
assert result["expected_tax_id"] == 1
assert result["displayed_tax_id"] == 1
def test_flags_mismatch_when_displayed_tax_belongs_to_another_country():
# Regression shape of issue #27054: shopper's country is France, but the
# page shows the Algeria-only rule.
result = find_tax_rule_mismatch(FRANCE, rows(), 2)
assert result["mismatch"] is True
assert result["expected_tax_id"] == 1
assert result["displayed_tax_id"] == 2
assert result["displayed_tax_country_id"] == ALGERIA
def test_no_row_for_delivery_country_expects_none():
single = [{"id_country": ALGERIA, "id_tax": 2, "id_state": 0, "zipcode_from": "0", "zipcode_to": "0"}]
result = find_tax_rule_mismatch(FRANCE, single, 2)
assert result["expected_tax_id"] is None
assert result["mismatch"] is True
def test_no_row_and_no_displayed_tax_is_not_a_mismatch():
single = [{"id_country": ALGERIA, "id_tax": 2, "id_state": 0, "zipcode_from": "0", "zipcode_to": "0"}]
result = find_tax_rule_mismatch(FRANCE, single, None)
assert result["expected_tax_id"] is None
assert result["mismatch"] is False
def test_zipcode_narrowed_row_only_matches_within_range():
zipped = [
{"id_country": FRANCE, "id_tax": 3, "id_state": 0, "zipcode_from": "75000", "zipcode_to": "75999"},
{"id_country": FRANCE, "id_tax": 1, "id_state": 0, "zipcode_from": "0", "zipcode_to": "0"},
]
result = find_tax_rule_mismatch(FRANCE, zipped, 3, zipcode="75010")
assert result["expected_tax_id"] == 3
assert result["mismatch"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { findTaxRuleMismatch } from "./find-tax-rule-mismatch.js";
const FRANCE = 8;
const ALGERIA = 65;
const rows = () => [
{ id_country: FRANCE, id_tax: 1, id_state: 0, zipcode_from: "0", zipcode_to: "0" },
{ id_country: ALGERIA, id_tax: 2, id_state: 0, zipcode_from: "0", zipcode_to: "0" },
];
test("matches when displayed tax is the delivery country's row", () => {
const result = findTaxRuleMismatch(FRANCE, rows(), 1);
assert.equal(result.mismatch, false);
assert.equal(result.expected_tax_id, 1);
assert.equal(result.displayed_tax_id, 1);
});
test("flags mismatch when displayed tax belongs to another country", () => {
// Regression shape of issue #27054: shopper's country is France, but the
// page shows the Algeria-only rule.
const result = findTaxRuleMismatch(FRANCE, rows(), 2);
assert.equal(result.mismatch, true);
assert.equal(result.expected_tax_id, 1);
assert.equal(result.displayed_tax_id, 2);
assert.equal(result.displayed_tax_country_id, ALGERIA);
});
test("no row for delivery country expects null", () => {
const single = [{ id_country: ALGERIA, id_tax: 2, id_state: 0, zipcode_from: "0", zipcode_to: "0" }];
const result = findTaxRuleMismatch(FRANCE, single, 2);
assert.equal(result.expected_tax_id, null);
assert.equal(result.mismatch, true);
});
test("no row and no displayed tax is not a mismatch", () => {
const single = [{ id_country: ALGERIA, id_tax: 2, id_state: 0, zipcode_from: "0", zipcode_to: "0" }];
const result = findTaxRuleMismatch(FRANCE, single, null);
assert.equal(result.expected_tax_id, null);
assert.equal(result.mismatch, false);
});
test("zipcode narrowed row only matches within range", () => {
const zipped = [
{ id_country: FRANCE, id_tax: 3, id_state: 0, zipcode_from: "75000", zipcode_to: "75999" },
{ id_country: FRANCE, id_tax: 1, id_state: 0, zipcode_from: "0", zipcode_to: "0" },
];
const result = findTaxRuleMismatch(FRANCE, zipped, 3, 0, "75010");
assert.equal(result.expected_tax_id, 3);
assert.equal(result.mismatch, false);
});
Case studies
The store whose default country leaked into the tax lookup
A shop with its default country set to France ran Product Page V2 and started noticing a handful of products showing a tax rule that only made sense for Algeria, a country that had no connection to the shop, its warehouse, or any customer viewing the page. It traced back to the exact regression in issue #27054, where the page's tax lookup used the shop's configured default rather than the visitor's real address.
Running the cross-reference across the flagged products confirmed the tax rules group had a lingering Algeria-only row from an old configuration, and no row at all for France. The team reported it to their tax advisor, added the correct France row once approved, and the mismatch script came back clean.
The shop whose price context fell back to the wrong default
A multistore install had each shop configured with its own default country, but a specific price context used for computing the displayed price fell back to that default instead of the customer's real id_country, the wrong calculation pattern behind issue #17911. Customers shipping to a neighboring country saw a tax-included price that matched the wrong shop's default entirely.
The audit script pulled the real delivery country from each flagged customer's address, compared it against what the storefront had shown, and produced a clean list of affected products per shop. No tax rate was written automatically, the list went straight to the merchant's tax team for confirmation before any row was added.
After this runs against a suspected product and address, or on a schedule across a catalog, a mismatch between the real delivery country and the displayed tax rate is reported with the exact ids involved, never guessed at. The only write this script ever proposes is a new tax_rule row scoped to the delivery country, and only after a human has approved the exact rate, with DRY_RUN logging the payload until that happens.
FAQ
Why does the product page show a tax rate for the wrong country?
PrestaShop resolves the displayed tax rate by looking up tax_rule rows inside the product's id_tax_rules_group that match a given id_country, but that country is often the shop's configured default or context country, or a stale cached address, rather than the customer's real delivery address. When the tax rules group has no rule for the real delivery country, or the front office context diverges from the checkout address, the engine falls back to or displays a rule tied to an unrelated country.
Is it safe to auto-correct a tax rule mismatch?
No, not by guessing. A missing or wrong tax_rule row is a data and legal configuration problem, and writing a rate automatically could apply an incorrect legal tax to real customers. The safe pattern is to detect and report the mismatch for a merchant or tax team to confirm, and only add a new tax_rule row under a DRY_RUN guard once a human has approved the exact rate for that country.
How do I detect a tax rule mismatch for a delivery country?
Pull the customer's address to get the real id_country, pull the product's id_tax_rules_group, then pull every tax_rules row for that group. Compare the row whose id_country matches the delivery country against the id_tax the storefront or webservice actually displays. If the displayed id_tax belongs to a different country's row, or no row exists for the delivery country at all, it is a mismatch.
Related field notes
Citations
On the problem:
- PrestaShop/PrestaShop GitHub issue #27054: BO, Tax Rule is applied on product page V2, it shouldn't because the default country is different from the country where the tax rule is applied. github.com/PrestaShop/PrestaShop/issues/27054
- PrestaShop/PrestaShop GitHub issue #17911: Multistore, tax rules, product price, wrong calculation price. github.com/PrestaShop/PrestaShop/issues/17911
- PrestaShop/PrestaShop GitHub issue #17592: Tax rules not being applied. github.com/PrestaShop/PrestaShop/issues/17592
On the solution:
- PrestaShop Developer Documentation: Specific prices, the price[field][country] and use_tax webservice parameters. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/specific-price
- PrestaShop Developer Documentation: Taxes webservice resource reference. devdocs.prestashop-project.org/1.7/webservice/resources/taxes
- PrestaShop User Guide: Tax Rules, managing taxes, country, state, and zipcode targeting. docs.prestashop-project.org/1.7-documentation/user-guide/improving-shop/going-international/managing-taxes/tax-rules
Stuck on a tricky one?
If you have a problem in PrestaShop pricing, taxes, multistore configuration, 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 catch a wrong tax rate?
If this saved you from shipping the wrong tax to a customer, or from a confusing back-and-forth with a tax advisor, 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