Skip to content

Diagnostic Multistore

Tax calculation uses the wrong shop's default country rate

A customer in one country places an order on a shop whose own default country carries a different tax rate, and the invoice comes out taxed at the shop's rate instead of the customer's. The order still looks fine at a glance, the totals add up internally, but the VAT or sales tax owed is simply wrong. Here is why the tax engine can silently fall back to the shop's own country and a diagnostic script that finds every order where that happened, without touching a single total on its own.

Python and Node.js PrestaShop Webservice API Diagnostic first, dry run repair
Using a calculator at a desk
Photo by Towfiqu barbhuiya on Unsplash
The short answer

PrestaShop's tax engine is supposed to price an order using the invoice address's id_country, resolved through PS_TAX_ADDRESS_TYPE and the TaxRulesGroup/TaxManager classes. In multistore, each shop also carries its own default country, and when the customer's address is incomplete, when an order arrives through pickup in store or the webservice without a fully set id_address_invoice, or when a price context falls back to the shop's own country, the tax engine can quietly use the shop's default country tax rule instead. Run a small Python or Node.js script that recomputes the expected tax from each order line's id_tax_rules_group and the address's real id_country, and compares it to the stored total_paid_tax_incl. It only reports by default. Full code, tests, and a dry run guarded repair path for still-editable orders are below.

The problem in plain words

Every PrestaShop order is supposed to be taxed according to where the customer is, not where the shop is registered. The engine reads the invoice address (or the delivery address, depending on PS_TAX_ADDRESS_TYPE), takes its id_country, and looks up the matching row in the product or cart rule's id_tax_rules_group for that country. That lookup is what should decide the rate.

In a multistore install, every shop also has its own default country and currency context. That context exists to give each shop sensible defaults for new customers and catalog pricing, not to override a real customer's address. But when the invoice address is missing or incomplete, when a cart or order is created through front-office pickup in store or through the webservice without a fully populated id_address_invoice, or when a specific price or cart rule's context resolution falls back to the shop's own country instead of the address country, the TaxManager can silently resolve the rate against the shop's default country instead of the customer's real one. The order still totals correctly on its own terms, so nothing looks broken until someone compares the tax actually charged to what the customer's country should have produced.

Invoice address real id_country of customer Shop default country shop's own context TaxManager resolves picks id_country to use Falls back to shop's country wrong tax_rules row Wrong tax total_paid_tax_incl
The invoice address carries the customer's real country, but an incomplete address, a pickup in store order, or a webservice write with no full id_address_invoice can let the tax engine fall back to the shop's own default country instead.

Why it happens

The root cause is that the tax rate lookup depends on resolving one specific country id from the order, and multistore adds a second, unrelated country id (the shop's own default) that can leak in when the first one is not cleanly available:

This is a documented class of bug, not a one-off misconfiguration. PrestaShop's own tracker has an open report of multistore installs with different default countries per shop producing the wrong tax on the order total, and store owners have separately reported the same wrong VAT showing up until a country is explicitly chosen in the cart, and again specifically for pickup in store orders where the delivery address country never gets consulted. See the citations at the end for the exact threads.

The key insight

A stored order total is a financial and legal figure. It may already be on a printed invoice, and it may already be inside a filed tax return. So a script should never silently rewrite total_paid_tax_incl just because it disagrees with a recomputation. The safe move is to separate finding the mismatch from fixing it: recompute the expected tax independently from the order line's id_tax_rules_group and the customer's real id_country, using a small pure function that never falls back to the shop's default country when a real match exists for the customer, and only report what disagrees.

The fix, as a flow

We do not touch the tax engine or the shop configuration. A script reads each order's invoice address to get the real id_country, reads each order line to get its id_tax_rules_group and price, looks up the correct tax rate for that country independently, and recomputes what the tax inclusive total should have been. Anything more than a few cents off the stored total gets written to an audit report. Only when a merchant explicitly turns off dry run, and only for orders still in an editable, unpaid state, does the script even offer a corrective write, and it always requires a human to confirm it.

GET order plus invoice address GET order_details id_tax_rules_group, price select_applicable _tax_rate Matches stored total? yes, skip Nothing to report no Write to audit report for review If DRY_RUN=false and order is editable, confirm then correct
The script only reports by default. Behind an explicit DRY_RUN=false flag, and only for orders still in an editable, unpaid state, it offers a confirmed correction rather than a silent rewrite.

Build it step by step

1

Get a Webservice key

In the PrestaShop back office, go to Advanced Parameters, Webservice, and create a key with read access to the orders, addresses, order_details, tax_rules, shops, and order_states resources. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Webservice API

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

step2.py
import os, requests

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

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(
        f"{PRESTASHOP_URL}/api/{path}",
        params=params,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

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

Read the order, its invoice address, and its lines

Fetch the order to get id_shop, id_address_invoice, and the stored total_paid_tax_incl. Fetch the invoice address to read its real id_country. Then fetch every order_details row filtered to that order with display=full, to read each line's id_tax_rules_group, unit_price_tax_excl, and product_quantity.

step3.py
def get_order(id_order):
    data = api_get(f"orders/{id_order}", {"display": "full"})
    return data.get("order") or {}

def get_address_country(id_address):
    data = api_get(f"addresses/{id_address}", {"display": "full"})
    address = data.get("address") or {}
    return int(address.get("id_country") or 0) or None

def get_order_lines(id_order):
    data = api_get("order_details", {
        "filter[id_order]": id_order,
        "display": "full",
    })
    return data.get("order_details") or []

def get_tax_rate(id_tax_rules_group, id_country):
    data = api_get("tax_rules", {
        "filter[id_tax_rules_group]": id_tax_rules_group,
        "filter[id_country]": id_country,
        "display": "full",
    })
    rules = data.get("tax_rules") or []
    return float(rules[0]["rate"]) if rules else 0.0
step3.js
async function getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`, { display: "full" });
  return data.order || {};
}

async function getAddressCountry(idAddress) {
  const data = await apiGet(`addresses/${idAddress}`, { display: "full" });
  const raw = data.address?.id_country;
  return raw ? Number(raw) : null;
}

async function getOrderLines(idOrder) {
  const data = await apiGet("order_details", {
    "filter[id_order]": idOrder,
    display: "full",
  });
  return data.order_details || [];
}

async function getTaxRate(idTaxRulesGroup, idCountry) {
  const data = await apiGet("tax_rules", {
    "filter[id_tax_rules_group]": idTaxRulesGroup,
    "filter[id_country]": idCountry,
    display: "full",
  });
  const rules = data.tax_rules || [];
  return rules.length ? Number(rules[0].rate) : 0;
}
4

Decide, with two pure functions

The math itself is one small pure function: given the line price, the quantity, and a tax rate, compute the expected tax inclusive total. The bug detection decision is a second pure function that picks which tax rule applies. It must always prefer the rule matching the customer's real order_country_id, and it must never fall back to the rule matching the shop's default country when a matching rule for the order country exists. Keeping both pure and free of any HTTP call means we can test every case with plain numbers, no PrestaShop store required.

decide.py
def compute_expected_tax(unit_price_tax_excl, quantity, tax_rate_pct):
    """Pure math: expected tax inclusive total for one order line."""
    expected_tax_excl_total = round(unit_price_tax_excl * quantity, 2)
    expected_tax_incl_total = round(expected_tax_excl_total * (1 + tax_rate_pct / 100), 2)
    return expected_tax_incl_total


def select_applicable_tax_rate(order_country_id, shop_default_country_id, tax_rules):
    """
    tax_rules: list of {"id_country": int, "rate": float} for one id_tax_rules_group.
    Must select the rule for order_country_id (the invoice address country), and must
    NOT fall back to shop_default_country_id when a matching rule for order_country_id
    exists and the two ids differ. Returns the rate as a float, or 0.0 if nothing matches.
    """
    for rule in tax_rules:
        if int(rule["id_country"]) == int(order_country_id):
            return float(rule["rate"])
    for rule in tax_rules:
        if int(rule["id_country"]) == int(shop_default_country_id):
            return float(rule["rate"])
    return 0.0
decide.js
export function computeExpectedTax(unitPriceTaxExcl, quantity, taxRatePct) {
  const expectedTaxExclTotal = Math.round(unitPriceTaxExcl * quantity * 100) / 100;
  const expectedTaxInclTotal = Math.round(expectedTaxExclTotal * (1 + taxRatePct / 100) * 100) / 100;
  return expectedTaxInclTotal;
}

export function selectApplicableTaxRate(orderCountryId, shopDefaultCountryId, taxRules) {
  for (const rule of taxRules) {
    if (Number(rule.id_country) === Number(orderCountryId)) return Number(rule.rate);
  }
  for (const rule of taxRules) {
    if (Number(rule.id_country) === Number(shopDefaultCountryId)) return Number(rule.rate);
  }
  return 0;
}
5

Recompute the order total and compare

Sum compute_expected_tax across every order line, using the rate returned by select_applicable_tax_rate for the customer's real country, then compare the sum to the stored total_paid_tax_incl. Anything past a small epsilon, such as 0.02 in the order's currency, is a flag worth reporting, including the shop, the address country, the tax rules group, and both the expected and stored figures.

scan.py
EPSILON = 0.02

def scan_order(id_order):
    order = get_order(id_order)
    id_shop = int(order.get("id_shop") or 0)
    id_address_invoice = int(order.get("id_address_invoice") or 0)
    stored_total = float(order.get("total_paid_tax_incl") or 0)

    order_country_id = get_address_country(id_address_invoice)
    shop = api_get(f"shops/{id_shop}", {"display": "full"}).get("shop") or {}
    shop_default_country_id = int(shop.get("id_country") or order_country_id or 0)

    lines = get_order_lines(id_order)
    expected_total = 0.0
    for line in lines:
        id_tax_rules_group = int(line.get("id_tax_rules_group") or 0)
        unit_price = float(line.get("unit_price_tax_excl") or 0)
        quantity = int(line.get("product_quantity") or 0)
        tax_rules = api_get("tax_rules", {
            "filter[id_tax_rules_group]": id_tax_rules_group,
            "display": "full",
        }).get("tax_rules") or []
        rate = select_applicable_tax_rate(order_country_id, shop_default_country_id, tax_rules)
        expected_total += compute_expected_tax(unit_price, quantity, rate)

    if abs(stored_total - expected_total) > EPSILON:
        return {
            "id_order": id_order,
            "id_shop": id_shop,
            "id_address_invoice": id_address_invoice,
            "order_country_id": order_country_id,
            "stored_total_paid_tax_incl": stored_total,
            "expected_total_paid_tax_incl": round(expected_total, 2),
        }
    return None
scan.js
const EPSILON = 0.02;

async function scanOrder(idOrder) {
  const order = await getOrder(idOrder);
  const idShop = Number(order.id_shop || 0);
  const idAddressInvoice = Number(order.id_address_invoice || 0);
  const storedTotal = Number(order.total_paid_tax_incl || 0);

  const orderCountryId = await getAddressCountry(idAddressInvoice);
  const shopData = await apiGet(`shops/${idShop}`, { display: "full" });
  const shopDefaultCountryId = Number(shopData.shop?.id_country || orderCountryId || 0);

  const lines = await getOrderLines(idOrder);
  let expectedTotal = 0;
  for (const line of lines) {
    const idTaxRulesGroup = Number(line.id_tax_rules_group || 0);
    const unitPrice = Number(line.unit_price_tax_excl || 0);
    const quantity = Number(line.product_quantity || 0);
    const taxRulesData = await apiGet("tax_rules", {
      "filter[id_tax_rules_group]": idTaxRulesGroup,
      display: "full",
    });
    const rate = selectApplicableTaxRate(orderCountryId, shopDefaultCountryId, taxRulesData.tax_rules || []);
    expectedTotal += computeExpectedTax(unitPrice, quantity, rate);
  }

  if (Math.abs(storedTotal - expectedTotal) > EPSILON) {
    return {
      id_order: idOrder,
      id_shop: idShop,
      id_address_invoice: idAddressInvoice,
      order_country_id: orderCountryId,
      stored_total_paid_tax_incl: storedTotal,
      expected_total_paid_tax_incl: Math.round(expectedTotal * 100) / 100,
    };
  }
  return null;
}
6

Wire it together with a dry run guarded, editable-order-only repair

The run loop scans a range of orders and writes every finding to an audit report. A stored total tied to an invoice must never be auto-corrected in place. Only when DRY_RUN=false, and only for orders whose current_state maps to an editable status such as awaiting payment (checked against order_states), does the script even offer a corrective path: recompute the order_details lines, PUT the corrected order_details and orders totals, and POST a new order_histories row noting the correction. That path always requires an explicit human confirmation before it writes anything.

Run it safe

Always start with DRY_RUN=true and read the audit report before changing anything. A wrong tax total on a placed order is a financial and possibly legal figure, so never rewrite it in place. Paid or shipped orders should go through a credit note process, not a script, and only orders still awaiting payment should ever be offered a scripted correction, gated behind explicit human confirmation.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks a range of orders, recomputes the expected tax from each order line's tax rules group and the customer's real country, writes every mismatch to an audit report, and only offers a repair path for editable, unpaid orders when DRY_RUN is explicitly turned off and a human confirms it.

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.
audit_multistore_tax_rate.py
"""Audit PrestaShop multistore orders for tax calculated at the wrong country's rate.

Each shop in a multistore install has its own default country, but the tax
engine is supposed to resolve the rate from the invoice address's
id_country through PS_TAX_ADDRESS_TYPE and the TaxRulesGroup/TaxManager
classes. When the address is incomplete, when an order arrives through
pickup in store or the webservice without a full id_address_invoice, or
when a price context falls back to the shop's own country, the TaxManager
can silently use the shop's default country tax rule instead of the
customer's real one.

This script reads a range of orders, recomputes the expected tax from each
order line's id_tax_rules_group and the invoice address's real id_country,
and compares it to the stored total_paid_tax_incl. It only writes an audit
report by default. A stored total tied to an invoice must never be
auto-corrected in place, so DRY_RUN=false only offers a repair path for
orders still in an editable, unpaid current_state, and even then requires
an explicit human confirmation before it writes anything.
"""
import os
import logging
import requests

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

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ID_ORDER_START = int(os.environ.get("ID_ORDER_START", "1"))
ID_ORDER_END = int(os.environ.get("ID_ORDER_END", "1"))
EDITABLE_STATE_NAMES = {"awaiting payment", "awaiting check payment", "awaiting bank wire payment"}

EPSILON = 0.02


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


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


def api_post(path, body):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def compute_expected_tax(unit_price_tax_excl, quantity, tax_rate_pct):
    """Pure math: expected tax inclusive total for one order line."""
    expected_tax_excl_total = round(unit_price_tax_excl * quantity, 2)
    expected_tax_incl_total = round(expected_tax_excl_total * (1 + tax_rate_pct / 100), 2)
    return expected_tax_incl_total


def select_applicable_tax_rate(order_country_id, shop_default_country_id, tax_rules):
    """
    tax_rules: list of {"id_country": int, "rate": float} for one id_tax_rules_group.
    Must select the rule for order_country_id (the invoice address country), and must
    NOT fall back to shop_default_country_id when a matching rule for order_country_id
    exists and the two ids differ. Pure decision logic, no I/O.
    """
    for rule in tax_rules:
        if int(rule["id_country"]) == int(order_country_id):
            return float(rule["rate"])
    for rule in tax_rules:
        if int(rule["id_country"]) == int(shop_default_country_id):
            return float(rule["rate"])
    return 0.0


def get_order(id_order):
    data = api_get(f"orders/{id_order}", {"display": "full"})
    return data.get("order") or {}


def get_address_country(id_address):
    data = api_get(f"addresses/{id_address}", {"display": "full"})
    address = data.get("address") or {}
    return int(address.get("id_country") or 0) or None


def get_order_lines(id_order):
    data = api_get("order_details", {
        "filter[id_order]": id_order,
        "display": "full",
    })
    return data.get("order_details") or []


def get_tax_rules(id_tax_rules_group):
    data = api_get("tax_rules", {
        "filter[id_tax_rules_group]": id_tax_rules_group,
        "display": "full",
    })
    return data.get("tax_rules") or []


def is_editable_state(id_order_state):
    data = api_get(f"order_states/{id_order_state}", {"display": "full"})
    state = data.get("order_state") or {}
    name = state.get("name")
    if isinstance(name, dict):
        name = next(iter(name.values()), "")
    return str(name or "").strip().lower() in EDITABLE_STATE_NAMES


def scan_order(id_order):
    order = get_order(id_order)
    id_shop = int(order.get("id_shop") or 0)
    id_address_invoice = int(order.get("id_address_invoice") or 0)
    stored_total = float(order.get("total_paid_tax_incl") or 0)
    current_state = int(order.get("current_state") or 0)

    order_country_id = get_address_country(id_address_invoice)
    shop = api_get(f"shops/{id_shop}", {"display": "full"}).get("shop") or {}
    shop_default_country_id = int(shop.get("id_country") or order_country_id or 0)

    lines = get_order_lines(id_order)
    expected_total = 0.0
    line_findings = []
    for line in lines:
        id_tax_rules_group = int(line.get("id_tax_rules_group") or 0)
        unit_price = float(line.get("unit_price_tax_excl") or 0)
        quantity = int(line.get("product_quantity") or 0)
        tax_rules = get_tax_rules(id_tax_rules_group)
        rate = select_applicable_tax_rate(order_country_id, shop_default_country_id, tax_rules)
        expected_line_total = compute_expected_tax(unit_price, quantity, rate)
        expected_total += expected_line_total
        line_findings.append({
            "id_order_detail": line.get("id"),
            "id_tax_rules_group": id_tax_rules_group,
            "unit_price_tax_excl": unit_price,
            "product_quantity": quantity,
            "expected_rate": rate,
            "expected_total_price_tax_incl": expected_line_total,
        })

    if abs(stored_total - expected_total) <= EPSILON:
        return None

    return {
        "id_order": id_order,
        "id_shop": id_shop,
        "id_address_invoice": id_address_invoice,
        "order_country_id": order_country_id,
        "shop_default_country_id": shop_default_country_id,
        "current_state": current_state,
        "stored_total_paid_tax_incl": stored_total,
        "expected_total_paid_tax_incl": round(expected_total, 2),
        "lines": line_findings,
    }


def apply_correction(finding, confirmed):
    if not confirmed:
        log.info("Order %s: correction available but not confirmed, skipping write.", finding["id_order"])
        return
    if not is_editable_state(finding["current_state"]):
        log.warning("Order %s: current_state %s is not editable, refusing to write.",
                    finding["id_order"], finding["current_state"])
        return

    for line in finding["lines"]:
        api_put(f"order_details/{line['id_order_detail']}", {
            "total_price_tax_incl": line["expected_total_price_tax_incl"],
            "total_price_tax_excl": round(line["unit_price_tax_excl"] * line["product_quantity"], 2),
            "unit_price_tax_incl": round(line["expected_total_price_tax_incl"] / max(line["product_quantity"], 1), 2),
        })

    api_put(f"orders/{finding['id_order']}", {
        "total_paid_tax_incl": finding["expected_total_paid_tax_incl"],
        "total_paid": finding["expected_total_paid_tax_incl"],
        "total_paid_tax_excl": round(sum(l["unit_price_tax_excl"] * l["product_quantity"] for l in finding["lines"]), 2),
    })

    api_post("order_histories", {
        "id_order": finding["id_order"],
        "id_order_state": finding["current_state"],
    })
    log.info("Order %s: corrected to expected total %.2f.", finding["id_order"], finding["expected_total_paid_tax_incl"])


def run():
    findings = []
    for id_order in range(ID_ORDER_START, ID_ORDER_END + 1):
        finding = scan_order(id_order)
        if finding:
            findings.append(finding)
            log.warning(
                "Order %s (shop %s): stored total_paid_tax_incl %.2f, expected %.2f for country %s.",
                finding["id_order"], finding["id_shop"],
                finding["stored_total_paid_tax_incl"], finding["expected_total_paid_tax_incl"],
                finding["order_country_id"],
            )
            if not DRY_RUN:
                apply_correction(finding, confirmed=False)
    log.info("Done. %d order(s) flagged for review.", len(findings))
    return findings


if __name__ == "__main__":
    run()
audit-multistore-tax-rate.js
/**
 * Audit PrestaShop multistore orders for tax calculated at the wrong country's rate.
 *
 * Each shop in a multistore install has its own default country, but the tax
 * engine is supposed to resolve the rate from the invoice address's
 * id_country through PS_TAX_ADDRESS_TYPE and the TaxRulesGroup/TaxManager
 * classes. When the address is incomplete, when an order arrives through
 * pickup in store or the webservice without a full id_address_invoice, or
 * when a price context falls back to the shop's own country, the
 * TaxManager can silently use the shop's default country tax rule instead
 * of the customer's real one.
 *
 * This script reads a range of orders, recomputes the expected tax from
 * each order line's id_tax_rules_group and the invoice address's real
 * id_country, and compares it to the stored total_paid_tax_incl. It only
 * writes an audit report by default. A stored total tied to an invoice
 * must never be auto-corrected in place, so DRY_RUN=false only offers a
 * repair path for orders still in an editable, unpaid current_state, and
 * even then requires an explicit human confirmation before it writes
 * anything.
 *
 * Guide: https://www.allanninal.dev/prestashop/multistore-wrong-country-tax-rate/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ID_ORDER_START = Number(process.env.ID_ORDER_START || 1);
const ID_ORDER_END = Number(process.env.ID_ORDER_END || 1);
const EDITABLE_STATE_NAMES = new Set(["awaiting payment", "awaiting check payment", "awaiting bank wire payment"]);

const EPSILON = 0.02;

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

export function computeExpectedTax(unitPriceTaxExcl, quantity, taxRatePct) {
  const expectedTaxExclTotal = Math.round(unitPriceTaxExcl * quantity * 100) / 100;
  const expectedTaxInclTotal = Math.round(expectedTaxExclTotal * (1 + taxRatePct / 100) * 100) / 100;
  return expectedTaxInclTotal;
}

export function selectApplicableTaxRate(orderCountryId, shopDefaultCountryId, taxRules) {
  for (const rule of taxRules) {
    if (Number(rule.id_country) === Number(orderCountryId)) return Number(rule.rate);
  }
  for (const rule of taxRules) {
    if (Number(rule.id_country) === Number(shopDefaultCountryId)) return Number(rule.rate);
  }
  return 0;
}

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

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

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

async function getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`, { display: "full" });
  return data.order || {};
}

async function getAddressCountry(idAddress) {
  const data = await apiGet(`addresses/${idAddress}`, { display: "full" });
  const raw = data.address?.id_country;
  return raw ? Number(raw) : null;
}

async function getOrderLines(idOrder) {
  const data = await apiGet("order_details", {
    "filter[id_order]": idOrder,
    display: "full",
  });
  return data.order_details || [];
}

async function getTaxRules(idTaxRulesGroup) {
  const data = await apiGet("tax_rules", {
    "filter[id_tax_rules_group]": idTaxRulesGroup,
    display: "full",
  });
  return data.tax_rules || [];
}

async function isEditableState(idOrderState) {
  const data = await apiGet(`order_states/${idOrderState}`, { display: "full" });
  let name = data.order_state?.name;
  if (name && typeof name === "object") name = Object.values(name)[0];
  return EDITABLE_STATE_NAMES.has(String(name || "").trim().toLowerCase());
}

async function scanOrder(idOrder) {
  const order = await getOrder(idOrder);
  const idShop = Number(order.id_shop || 0);
  const idAddressInvoice = Number(order.id_address_invoice || 0);
  const storedTotal = Number(order.total_paid_tax_incl || 0);
  const currentState = Number(order.current_state || 0);

  const orderCountryId = await getAddressCountry(idAddressInvoice);
  const shopData = await apiGet(`shops/${idShop}`, { display: "full" });
  const shopDefaultCountryId = Number(shopData.shop?.id_country || orderCountryId || 0);

  const lines = await getOrderLines(idOrder);
  let expectedTotal = 0;
  const lineFindings = [];
  for (const line of lines) {
    const idTaxRulesGroup = Number(line.id_tax_rules_group || 0);
    const unitPrice = Number(line.unit_price_tax_excl || 0);
    const quantity = Number(line.product_quantity || 0);
    const taxRules = await getTaxRules(idTaxRulesGroup);
    const rate = selectApplicableTaxRate(orderCountryId, shopDefaultCountryId, taxRules);
    const expectedLineTotal = computeExpectedTax(unitPrice, quantity, rate);
    expectedTotal += expectedLineTotal;
    lineFindings.push({
      id_order_detail: line.id,
      id_tax_rules_group: idTaxRulesGroup,
      unit_price_tax_excl: unitPrice,
      product_quantity: quantity,
      expected_rate: rate,
      expected_total_price_tax_incl: expectedLineTotal,
    });
  }

  if (Math.abs(storedTotal - expectedTotal) <= EPSILON) return null;

  return {
    id_order: idOrder,
    id_shop: idShop,
    id_address_invoice: idAddressInvoice,
    order_country_id: orderCountryId,
    shop_default_country_id: shopDefaultCountryId,
    current_state: currentState,
    stored_total_paid_tax_incl: storedTotal,
    expected_total_paid_tax_incl: Math.round(expectedTotal * 100) / 100,
    lines: lineFindings,
  };
}

async function applyCorrection(finding, confirmed) {
  if (!confirmed) {
    console.log(`Order ${finding.id_order}: correction available but not confirmed, skipping write.`);
    return;
  }
  if (!(await isEditableState(finding.current_state))) {
    console.warn(`Order ${finding.id_order}: current_state ${finding.current_state} is not editable, refusing to write.`);
    return;
  }

  for (const line of finding.lines) {
    await apiPut(`order_details/${line.id_order_detail}`, {
      total_price_tax_incl: line.expected_total_price_tax_incl,
      total_price_tax_excl: Math.round(line.unit_price_tax_excl * line.product_quantity * 100) / 100,
      unit_price_tax_incl: Math.round((line.expected_total_price_tax_incl / Math.max(line.product_quantity, 1)) * 100) / 100,
    });
  }

  const totalExcl = finding.lines.reduce((sum, l) => sum + l.unit_price_tax_excl * l.product_quantity, 0);
  await apiPut(`orders/${finding.id_order}`, {
    total_paid_tax_incl: finding.expected_total_paid_tax_incl,
    total_paid: finding.expected_total_paid_tax_incl,
    total_paid_tax_excl: Math.round(totalExcl * 100) / 100,
  });

  await apiPost("order_histories", {
    id_order: finding.id_order,
    id_order_state: finding.current_state,
  });
  console.log(`Order ${finding.id_order}: corrected to expected total ${finding.expected_total_paid_tax_incl.toFixed(2)}.`);
}

export async function run() {
  const findings = [];
  for (let idOrder = ID_ORDER_START; idOrder <= ID_ORDER_END; idOrder++) {
    const finding = await scanOrder(idOrder);
    if (finding) {
      findings.push(finding);
      console.warn(
        `Order ${finding.id_order} (shop ${finding.id_shop}): stored total_paid_tax_incl ${finding.stored_total_paid_tax_incl.toFixed(2)}, expected ${finding.expected_total_paid_tax_incl.toFixed(2)} for country ${finding.order_country_id}.`
      );
      if (!DRY_RUN) await applyCorrection(finding, false);
    }
  }
  console.log(`Done. ${findings.length} order(s) flagged for review.`);
  return findings;
}

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

Add a test

The two pure functions are the part most worth testing, because together they decide whether an order gets flagged and what a correct total should have been. Because we kept compute_expected_tax and select_applicable_tax_rate pure, the tests need no network and no PrestaShop store. They just feed in plain numbers and lists and check the answer, including the specific case where the order country and the shop default country disagree.

test_multistore_tax_rate.py
from audit_multistore_tax_rate import compute_expected_tax, select_applicable_tax_rate


def test_compute_expected_tax_basic():
    assert compute_expected_tax(100.0, 2, 20.0) == 240.0


def test_compute_expected_tax_rounds_to_cents():
    assert compute_expected_tax(19.99, 3, 7.7) == round(19.99 * 3 * 1.077, 2)


def test_compute_expected_tax_zero_rate():
    assert compute_expected_tax(50.0, 1, 0.0) == 50.0


def test_selects_rule_matching_order_country():
    rules = [{"id_country": 1, "rate": 20.0}, {"id_country": 8, "rate": 7.7}]
    assert select_applicable_tax_rate(8, 1, rules) == 7.7


def test_does_not_fall_back_to_shop_default_country_when_order_country_matches():
    # order country is 8, shop default country is 1; both have rules, but the
    # customer's own country must win, not the shop's.
    rules = [{"id_country": 1, "rate": 20.0}, {"id_country": 8, "rate": 7.7}]
    assert select_applicable_tax_rate(8, 1, rules) != 20.0


def test_falls_back_to_shop_default_country_only_when_no_order_country_rule():
    rules = [{"id_country": 1, "rate": 20.0}]
    assert select_applicable_tax_rate(8, 1, rules) == 20.0


def test_returns_zero_when_no_rule_matches_either_country():
    rules = [{"id_country": 99, "rate": 15.0}]
    assert select_applicable_tax_rate(8, 1, rules) == 0.0
multistore-tax-rate.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeExpectedTax, selectApplicableTaxRate } from "./audit-multistore-tax-rate.js";

test("computes expected tax for a basic line", () => {
  assert.equal(computeExpectedTax(100.0, 2, 20.0), 240.0);
});

test("rounds to cents", () => {
  const expected = Math.round(19.99 * 3 * 1.077 * 100) / 100;
  assert.equal(computeExpectedTax(19.99, 3, 7.7), expected);
});

test("handles a zero tax rate", () => {
  assert.equal(computeExpectedTax(50.0, 1, 0.0), 50.0);
});

test("selects the rule matching the order country", () => {
  const rules = [{ id_country: 1, rate: 20.0 }, { id_country: 8, rate: 7.7 }];
  assert.equal(selectApplicableTaxRate(8, 1, rules), 7.7);
});

test("does not fall back to the shop default country when the order country matches", () => {
  const rules = [{ id_country: 1, rate: 20.0 }, { id_country: 8, rate: 7.7 }];
  assert.notEqual(selectApplicableTaxRate(8, 1, rules), 20.0);
});

test("falls back to the shop default country only when no order country rule exists", () => {
  const rules = [{ id_country: 1, rate: 20.0 }];
  assert.equal(selectApplicableTaxRate(8, 1, rules), 20.0);
});

test("returns zero when no rule matches either country", () => {
  const rules = [{ id_country: 99, rate: 15.0 }];
  assert.equal(selectApplicableTaxRate(8, 1, rules), 0);
});

Case studies

Pickup in store

The shop that never asked for a shipping address

A homeware brand ran a second shop for local pickup, so customers never entered a delivery address at checkout, and the invoice address that got attached to the order was thin, largely defaulted from the shop's own settings rather than the customer's real location. Orders on that shop were quietly taxed at the shop's own country rate, which was higher than several customers' actual country.

Running the audit script across a month of pickup orders surfaced every one where the stored total did not match a recomputation using the address's real country. Finance treated each as a candidate for a credit note rather than an in-place edit, since some of the orders were already on filed invoices.

Webservice order creation

Orders created by an integration without a full invoice address

A marketplace integration created orders through the webservice and set the customer and the products correctly, but the request did not always populate id_address_invoice with a complete, shop-agnostic address. Tax on those orders ended up computed against the shop's default country instead of the marketplace buyer's actual country.

The audit script flagged the exact orders and the exact tax rules group involved, with both the stored and expected totals side by side. The integration was fixed to always send a complete invoice address going forward, and the flagged historical orders were handed to finance for review rather than corrected automatically.

What good looks like

After running this audit regularly, a wrong country tax rate stops being invisible and becomes a short, specific list: which order, which shop, which country, and how far the stored total is from what it should have been. Nothing gets rewritten until a human reviews it, paid or shipped orders route to a credit note process, and only orders still awaiting payment are ever offered a scripted, confirmed correction.

FAQ

Why does a PrestaShop multistore order get taxed at the wrong country's rate?

Each shop in a multistore install has its own default country, but the tax engine is supposed to resolve the rate from the invoice address's id_country. When the address record is incomplete, when an order is created through pickup in store or the webservice without a full id_address_invoice, or when a price context falls back to the shop's own country, the TaxManager can quietly use the shop's default country tax rule instead of the customer's real country.

How do I find orders that were taxed at the wrong rate?

For each order, read id_address_invoice from the orders resource and id_country from the addresses resource, then read each order line's id_tax_rules_group and unit_price_tax_excl from order_details. Look up the tax_rules entry for that group filtered to the real id_country, recompute the expected tax inclusive total, and compare it to the stored total_paid_tax_incl. A gap larger than a small rounding epsilon means the wrong rate was applied.

Is it safe to auto-correct the tax on an order that already shows the wrong rate?

No, not silently. The stored total is a financial and legal figure tied to an invoice and possibly a filed tax return, so a mismatch should be written to an audit report for manual review or a credit note. Only orders still in an editable, unpaid state should ever be considered for a scripted correction, and only behind a DRY_RUN flag with a human confirming the write.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: multistore, tax rules, product price, wrong calculation price. github.com/PrestaShop/PrestaShop/issues/17911
  2. PrestaShop Forums: multistore, wrong VAT until a country is chosen in the cart. prestashop.com/forums/topic/1086997-multistore-wrong-vat-until-country-chosen-in-cart
  3. PrestaShop Forums: wrong tax applied for pickup in store, customer delivery address country versus store country. prestashop.com/forums/topic/1075017-wrong-tax-is-applied-in-the-event-of-pickup-in-store

On the solution:

  1. PrestaShop Developer Documentation: the orders resource. devdocs.prestashop-project.org/9/webservice/resources/orders
  2. PrestaShop Developer Documentation: the order_details resource. devdocs.prestashop-project.org/9/webservice/resources/order_details
  3. PrestaShop Developer Documentation: the tax_rules resource. devdocs.prestashop-project.org/8/webservice/resources/tax_rules

Stuck on a tricky one?

If you have a problem in PrestaShop orders, tax rules, multistore, or the Webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a wrong tax total?

If this saved you a bad invoice or a confusing VAT report, 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