Skip to content

Diagnostic

Currency exchange rate update in one shop overwrites another shop's rate

You open Localization, Currencies in one shop context and update the exchange rate, meaning it for that shop only. Check a second shop that uses the same currency and its rate has changed too, to the exact number you just typed. Nothing errored. Nothing warned you. This is not a mistake in your update. PrestaShop stores a currency's exchange rate once per currency row, not once per shop, so every shop sharing that currency id inherits whatever value was written last. Here is why that happens and a small script that detects when it likely just did.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
Shopping online with a phone
Photo by Julio Lopez on Unsplash
The short answer

In PrestaShop multistore, a currency's exchange rate lives in one column, conversion_rate, on the single ps_currency row for that currency id. Shops are linked to currencies through ps_currency_shop, but that table only controls whether the currency is enabled for a shop, it has no rate column of its own. So when you edit the rate for All Shops or for a single shop in Localization, Currencies, the write updates the one shared column, and every other shop using that same currency id instantly gets the new, unrecalculated value instead of the rate it was actually meant to have. PrestaShop's team confirmed and closed this as expected as is in GitHub issue #23447, and the cron_currency_rates.php auto updater has the same blast radius (issue #12025). Because there is no safe automatic write that restores independent per-shop rates, run a Python or Node.js script that snapshots each shop's rate per currency over time and flags when shops that used to disagree on a rate suddenly report the same one. Full code, tests, and citations are below.

The problem in plain words

In a single shop PrestaShop install, a currency only ever has one exchange rate, so there is nothing to confuse. Multistore changes the picture. Two shops in the same install can each want their own EUR to USD rate, maybe because one shop runs its own margin or updates less often than the other.

PrestaShop's data model was not built for that. The ps_currency table has exactly one conversion_rate field per currency id. ps_currency_shop exists, but its job is only to say which shops have that currency enabled, not to hold a shop-specific rate. So when the back office writes a new rate, whether you are looking at the All Shops context or a single shop's context, it writes to that one shared column. Every shop associated with that currency id reads the same row, so every shop sees the new rate the instant it is saved, whether that shop's owner asked for it or not.

Shop A edits rate Localization, Currencies ps_currency row one shared conversion_rate column, not per shop no per-shop column exists ps_currency_shop enable or disable no rate field here Shop B inherits it
The edit looks scoped to one shop, but conversion_rate has no per-shop column at all. Every shop reading that currency id gets the same new value the moment it is saved.

Why it happens

This is confirmed, expected behavior in PrestaShop's data model, not a bug in any one merchant's setup. A few things make it easy to walk into:

This has come up often enough on the PrestaShop forums that merchants running a multistore install with genuinely different rates per shop keep rediscovering it the hard way, usually after a price looks wrong on a second storefront right after someone updated the first one. See the citations at the end for the exact threads and docs.

The key insight

Because conversion_rate is not truly per shop, there is no safe API write that restores independent rates for every shop without changing PrestaShop's data model. So the safe pattern is not "write back the rate I think this shop should have." It is "watch each shop's rate over time, and flag the moment shops that used to disagree on a rate suddenly report the exact same one," which is the signature of an overwrite. A human then decides which rate is authoritative before anything is written.

The fix, as a flow

We do not touch the live currency automatically. We add a job that snapshots each shop's view of every currency's rate, keyed by shop id and currency id, and compares each new snapshot against the last one. When two or more shops that previously disagreed on a currency's rate now report the identical value, and that value matches what exactly one shop's last write would have produced, we flag it as a suspected overwrite. A corrective PUT stays behind a dry run guard and is never sent automatically.

Snapshot rates per shop, per currency Store with timestamp previous_snapshot detect_rate_overwrite() pure decision, previous vs current Shops collapsed? yes, flag no, looks fine, skip Nothing to do Report DRY_RUN
The job only ever reports by default. A corrective PUT to restore one shop's rate would simultaneously re-break every other shop sharing that currency id, so it is never sent automatically.

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 access to currencies and shops. 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 SNAPSHOT_FILE="rate_snapshot.json"
export DRY_RUN="true"   # start safe, this script only ever reports
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 SNAPSHOT_FILE="rate_snapshot.json"
export DRY_RUN="true"   // start safe, this script only ever reports
2

List the shops in this install

Call GET /api/shops?output_format=JSON&display=full to get every shop id. Each shop is then used as the context for reading currencies, since a currency's active state and rate must be read per shop context to see what that shop actually sees.

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 all_shop_ids():
    data = api_get("shops", params={"display": "full"})
    rows = data.get("shops") or []
    return [int(row["id"]) for row in rows]
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 allShopIds() {
  const data = await apiGet("shops", { display: "full" });
  const rows = data.shops || [];
  return rows.map((row) => Number(row.id));
}
3

Snapshot each currency's rate per shop

For each shop id, call GET /api/currencies?output_format=JSON&display=full&filter[active]=1 scoped to that shop context, and read back id, iso_code, and conversion_rate. Build a snapshot keyed by (id_shop, id_currency) mapping to the rate, and persist it with a timestamp so the next run has something to compare against.

step3.py
def currencies_for_shop(id_shop):
    data = api_get("currencies", params={
        "display": "full",
        "filter[active]": "1",
        "id_shop": id_shop,
    })
    return data.get("currencies") or []

def build_snapshot(shop_ids):
    snapshot = {}
    for id_shop in shop_ids:
        for row in currencies_for_shop(id_shop):
            key = (int(id_shop), int(row["id"]))
            snapshot[key] = float(row["conversion_rate"])
    return snapshot
step3.js
async function currenciesForShop(idShop) {
  const data = await apiGet("currencies", {
    display: "full",
    "filter[active]": "1",
    id_shop: idShop,
  });
  return data.currencies || [];
}

async function buildSnapshot(shopIds) {
  const snapshot = {};
  for (const idShop of shopIds) {
    for (const row of await currenciesForShop(idShop)) {
      const key = `${Number(idShop)}:${Number(row.id)}`;
      snapshot[key] = Number(row.conversion_rate);
    }
  }
  return snapshot;
}
4

Decide, with one pure function

Keep the decision in its own function that takes only two plain snapshots and a tolerance, no I/O at all. Group entries by currency id, and for each currency, look at the shops that used to disagree on the rate. If two or more of those shops now report the identical rate, and that rate matches what exactly one shop's most recent write would have produced, it is very likely one shop's edit overwrote the others.

decide.py
def detect_rate_overwrite(previous_snapshot, current_snapshot, tolerance=1e-6):
    by_currency = {}
    for (id_shop, id_currency), rate in current_snapshot.items():
        by_currency.setdefault(id_currency, []).append((id_shop, rate))

    findings = []
    for id_currency, shop_rates in by_currency.items():
        prior_rates = {
            id_shop: previous_snapshot[(id_shop, id_currency)]
            for id_shop, _ in shop_rates
            if (id_shop, id_currency) in previous_snapshot
        }
        if not _has_disagreement(prior_rates.values(), tolerance):
            continue  # shops agreed before, nothing to collapse

        for group in _group_by_tolerance(shop_rates, tolerance):
            shops_now = [id_shop for id_shop, _ in group]
            new_rate = group[0][1]
            disagreeing_before = [
                s for s in shops_now
                if s in prior_rates and abs(prior_rates[s] - new_rate) > tolerance
            ]
            if len(disagreeing_before) >= 2:
                source_candidates = [
                    s for s in shops_now
                    if s in prior_rates and abs(prior_rates[s] - new_rate) <= tolerance
                ]
                findings.append({
                    "id_currency": id_currency,
                    "id_shops_collapsed": sorted(disagreeing_before),
                    "old_rates": {s: prior_rates[s] for s in disagreeing_before},
                    "new_rate": new_rate,
                    "likely_source_shop": source_candidates[0] if len(source_candidates) == 1 else None,
                })
    return findings


def _has_disagreement(rates, tolerance):
    rates = list(rates)
    if len(rates) < 2:
        return False
    base = rates[0]
    return any(abs(r - base) > tolerance for r in rates[1:])


def _group_by_tolerance(shop_rates, tolerance):
    # Groups (id_shop, rate) pairs into clusters whose rates are mutually
    # within tolerance of each other.
    groups = []
    for id_shop, rate in shop_rates:
        placed = False
        for group in groups:
            if abs(group[0][1] - rate) <= tolerance:
                group.append((id_shop, rate))
                placed = True
                break
        if not placed:
            groups.append([(id_shop, rate)])
    return groups
decide.js
export function detectRateOverwrite(previousSnapshot, currentSnapshot, tolerance = 1e-6) {
  const byCurrency = new Map();
  for (const [key, rate] of Object.entries(currentSnapshot)) {
    const [idShop, idCurrency] = key.split(":").map(Number);
    if (!byCurrency.has(idCurrency)) byCurrency.set(idCurrency, []);
    byCurrency.get(idCurrency).push([idShop, rate]);
  }

  const findings = [];
  for (const [idCurrency, shopRates] of byCurrency) {
    const priorRates = {};
    for (const [idShop] of shopRates) {
      const priorKey = `${idShop}:${idCurrency}`;
      if (priorKey in previousSnapshot) priorRates[idShop] = previousSnapshot[priorKey];
    }
    if (!hasDisagreement(Object.values(priorRates), tolerance)) continue;

    for (const group of groupByTolerance(shopRates, tolerance)) {
      const shopsNow = group.map(([idShop]) => idShop);
      const newRate = group[0][1];
      const disagreeingBefore = shopsNow.filter(
        (s) => s in priorRates && Math.abs(priorRates[s] - newRate) > tolerance
      );
      if (disagreeingBefore.length >= 2) {
        const sourceCandidates = shopsNow.filter(
          (s) => s in priorRates && Math.abs(priorRates[s] - newRate) <= tolerance
        );
        findings.push({
          idCurrency,
          idShopsCollapsed: disagreeingBefore.slice().sort((a, b) => a - b),
          oldRates: Object.fromEntries(disagreeingBefore.map((s) => [s, priorRates[s]])),
          newRate,
          likelySourceShop: sourceCandidates.length === 1 ? sourceCandidates[0] : null,
        });
      }
    }
  }
  return findings;
}

function hasDisagreement(rates, tolerance) {
  if (rates.length < 2) return false;
  const base = rates[0];
  return rates.slice(1).some((r) => Math.abs(r - base) > tolerance);
}

function groupByTolerance(shopRates, tolerance) {
  // Groups [idShop, rate] pairs into clusters whose rates are mutually
  // within tolerance of each other.
  const groups = [];
  for (const [idShop, rate] of shopRates) {
    const group = groups.find((g) => Math.abs(g[0][1] - rate) <= tolerance);
    if (group) group.push([idShop, rate]);
    else groups.push([[idShop, rate]]);
  }
  return groups;
}
5

Report only, never auto-repair

Because conversion_rate is shared across every shop using that currency id, there is no safe write that restores one shop's rate without simultaneously overwriting every other shop again. So the default behavior is to log the finding, the affected shop ids, the old rates, the new collapsed rate, and the suspected source shop, then stop. Any corrective PUT /api/currencies/{id} stays behind DRY_RUN=false and is something a human decides to run, not something this script does on its own.

apply.py
def api_put_restore_rate(id_currency, currency_body, restored_rate):
    # Restoring one shop's rate rewrites the single shared conversion_rate
    # column, which will simultaneously re-break every other shop sharing
    # this currency id. Only call this after a human has confirmed which
    # rate is authoritative, and never from an automatic branch.
    body = dict(currency_body)
    body["conversion_rate"] = restored_rate
    r = requests.put(
        f"{PRESTASHOP_URL}/api/currencies/{id_currency}",
        params={"output_format": "JSON"}, auth=AUTH,
        json={"currency": body}, timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function apiPutRestoreRate(idCurrency, currencyBody, restoredRate) {
  // Restoring one shop's rate rewrites the single shared conversion_rate
  // column, which will simultaneously re-break every other shop sharing
  // this currency id. Only call this after a human has confirmed which
  // rate is authoritative, and never from an automatic branch.
  const body = { ...currencyBody, conversion_rate: restoredRate };
  const url = new URL(`${PRESTASHOP_URL}/api/currencies/${idCurrency}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ currency: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT currencies/${idCurrency}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together: list shops, build the current snapshot, load the previous snapshot from disk, run detect_rate_overwrite, log every finding, then save the current snapshot as the new baseline for next time. The corrective PUT is never called from this loop automatically, it is gated behind DRY_RUN=false and left as a manual, one-currency-at-a-time decision. Run this on a schedule that matches how often your shops update rates, for example once an hour.

Run it safe

Always leave DRY_RUN=true. This is a detection tool, not an auto-fix. Restoring one shop's rate will simultaneously re-break every other shop sharing that currency id, so only write a new rate after a human has looked at the report and decided which value is authoritative.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, snapshots each shop's currency rates, compares against the last snapshot on disk, and reports every suspected overwrite. It never writes a corrective rate on its own.

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.
detect_rate_overwrite.py
"""Detect PrestaShop multistore currency rates that were overwritten across shops.

PrestaShop stores a currency's exchange rate as a single conversion_rate column
on the ps_currency row for that currency id. Shops are linked to currencies
through ps_currency_shop, but that table only controls enable and disable
state, it has no rate column. So editing a rate for one shop context, or
letting cron_currency_rates.php run, writes the one shared column and every
shop using that currency id instantly inherits the new value (PrestaShop/
PrestaShop issues #23447 and #12025, closed as expected as is).

This script snapshots each shop's view of every currency's rate, keyed by
(id_shop, id_currency), and compares the new snapshot against the last one on
disk. When shops that used to disagree on a currency's rate now report the
identical rate, it is very likely an overwrite happened, and this is reported.
There is no safe automatic repair: restoring one shop's rate rewrites the same
shared column and would re-break every other shop again, so any corrective PUT
stays behind DRY_RUN and is a human decision.

Guide: https://www.allanninal.dev/prestashop/exchange-rate-overwritten-across-shops/

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

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

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
SNAPSHOT_FILE = os.environ.get("SNAPSHOT_FILE", "rate_snapshot.json")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def detect_rate_overwrite(previous_snapshot, current_snapshot, tolerance=1e-6):
    """Pure decision function, no I/O.

    previous_snapshot, current_snapshot: dict[tuple[int, int], float] mapping
        (id_shop, id_currency) to conversion_rate.
    tolerance: float, how close two rates must be to count as identical.

    Returns a list of findings, each a dict with id_currency,
    id_shops_collapsed, old_rates, new_rate, and likely_source_shop.
    A finding is emitted when two or more shops that previously disagreed on
    a currency's rate now report the identical rate, and that rate matches
    the rate most recently written in exactly one shop (the likely source).
    """
    by_currency = {}
    for (id_shop, id_currency), rate in current_snapshot.items():
        by_currency.setdefault(id_currency, []).append((id_shop, rate))

    findings = []
    for id_currency, shop_rates in by_currency.items():
        prior_rates = {
            id_shop: previous_snapshot[(id_shop, id_currency)]
            for id_shop, _ in shop_rates
            if (id_shop, id_currency) in previous_snapshot
        }
        if not _has_disagreement(prior_rates.values(), tolerance):
            continue  # shops agreed before, nothing to collapse

        for group in _group_by_tolerance(shop_rates, tolerance):
            shops_now = [id_shop for id_shop, _ in group]
            new_rate = group[0][1]
            disagreeing_before = [
                s for s in shops_now
                if s in prior_rates and abs(prior_rates[s] - new_rate) > tolerance
            ]
            if len(disagreeing_before) >= 2:
                source_candidates = [
                    s for s in shops_now
                    if s in prior_rates and abs(prior_rates[s] - new_rate) <= tolerance
                ]
                findings.append({
                    "id_currency": id_currency,
                    "id_shops_collapsed": sorted(disagreeing_before),
                    "old_rates": {s: prior_rates[s] for s in disagreeing_before},
                    "new_rate": new_rate,
                    "likely_source_shop": source_candidates[0] if len(source_candidates) == 1 else None,
                })
    return findings


def _has_disagreement(rates, tolerance):
    """True when the given rates are not all within tolerance of each other."""
    rates = list(rates)
    if len(rates) < 2:
        return False
    base = rates[0]
    return any(abs(r - base) > tolerance for r in rates[1:])


def _group_by_tolerance(shop_rates, tolerance):
    """Group (id_shop, rate) pairs into clusters whose rates are mutually
    within tolerance of each other. Simple, order-independent clustering
    that is adequate for the small number of shops a currency has."""
    groups = []
    for id_shop, rate in shop_rates:
        placed = False
        for group in groups:
            if abs(group[0][1] - rate) <= tolerance:
                group.append((id_shop, rate))
                placed = True
                break
        if not placed:
            groups.append([(id_shop, rate)])
    return groups


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 all_shop_ids():
    data = api_get("shops", params={"display": "full"})
    rows = data.get("shops") or []
    return [int(row["id"]) for row in rows]


def currencies_for_shop(id_shop):
    data = api_get("currencies", params={
        "display": "full",
        "filter[active]": "1",
        "id_shop": id_shop,
    })
    return data.get("currencies") or []


def build_snapshot(shop_ids):
    snapshot = {}
    for id_shop in shop_ids:
        for row in currencies_for_shop(id_shop):
            key = (int(id_shop), int(row["id"]))
            snapshot[key] = float(row["conversion_rate"])
    return snapshot


def load_snapshot(path):
    if not os.path.exists(path):
        return {}
    with open(path, "r", encoding="utf-8") as f:
        raw = json.load(f)
    return {
        (int(item["id_shop"]), int(item["id_currency"])): float(item["conversion_rate"])
        for item in raw.get("entries", [])
    }


def save_snapshot(path, snapshot):
    entries = [
        {"id_shop": id_shop, "id_currency": id_currency, "conversion_rate": rate}
        for (id_shop, id_currency), rate in snapshot.items()
    ]
    with open(path, "w", encoding="utf-8") as f:
        json.dump({"entries": entries}, f, indent=2)


def api_put_restore_rate(id_currency, currency_body, restored_rate):
    # Restoring one shop's rate rewrites the single shared conversion_rate
    # column, which will simultaneously re-break every other shop sharing
    # this currency id. Only call this after a human has confirmed which
    # rate is authoritative, and never from an automatic branch.
    body = dict(currency_body)
    body["conversion_rate"] = restored_rate
    r = requests.put(
        f"{PRESTASHOP_URL}/api/currencies/{id_currency}",
        params={"output_format": "JSON"}, auth=AUTH,
        json={"currency": body}, timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    shop_ids = all_shop_ids()
    current = build_snapshot(shop_ids)
    previous = load_snapshot(SNAPSHOT_FILE)

    findings = detect_rate_overwrite(previous, current)
    for f in findings:
        log.warning(
            "Currency id=%s rate collapsed to %s across shops %s. old_rates=%s likely_source_shop=%s",
            f["id_currency"], f["new_rate"], f["id_shops_collapsed"],
            f["old_rates"], f["likely_source_shop"],
        )
        if not DRY_RUN:
            log.info(
                "DRY_RUN is false, but this script never auto-repairs currency id=%s. "
                "Restoring one shop's rate would re-break every other shop sharing it. "
                "Decide the authoritative rate by hand, then call api_put_restore_rate() explicitly.",
                f["id_currency"],
            )

    save_snapshot(SNAPSHOT_FILE, current)
    log.info("Done. %d suspected overwrite(s) found across %d shop(s).", len(findings), len(shop_ids))


if __name__ == "__main__":
    run()
detect-rate-overwrite.js
/**
 * Detect PrestaShop multistore currency rates that were overwritten across shops.
 *
 * PrestaShop stores a currency's exchange rate as a single conversion_rate column
 * on the ps_currency row for that currency id. Shops are linked to currencies
 * through ps_currency_shop, but that table only controls enable and disable
 * state, it has no rate column. So editing a rate for one shop context, or
 * letting cron_currency_rates.php run, writes the one shared column and every
 * shop using that currency id instantly inherits the new value (PrestaShop/
 * PrestaShop issues #23447 and #12025, closed as expected as is).
 *
 * This script snapshots each shop's view of every currency's rate, keyed by
 * "id_shop:id_currency", and compares the new snapshot against the last one on
 * disk. When shops that used to disagree on a currency's rate now report the
 * identical rate, it is very likely an overwrite happened, and this is reported.
 * There is no safe automatic repair: restoring one shop's rate rewrites the same
 * shared column and would re-break every other shop again, so any corrective PUT
 * stays behind DRY_RUN and is a human decision.
 *
 * Guide: https://www.allanninal.dev/prestashop/exchange-rate-overwritten-across-shops/
 *
 * Run on a schedule. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";
import { readFileSync, writeFileSync, existsSync } from "node:fs";

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

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

/**
 * Pure decision function, no I/O.
 *
 * previousSnapshot, currentSnapshot: plain objects mapping "id_shop:id_currency"
 *   to conversion_rate.
 * tolerance: number, how close two rates must be to count as identical.
 *
 * Returns a list of findings, each an object with idCurrency,
 * idShopsCollapsed, oldRates, newRate, and likelySourceShop.
 * A finding is emitted when two or more shops that previously disagreed on
 * a currency's rate now report the identical rate, and that rate matches
 * the rate most recently written in exactly one shop (the likely source).
 */
export function detectRateOverwrite(previousSnapshot, currentSnapshot, tolerance = 1e-6) {
  const byCurrency = new Map();
  for (const [key, rate] of Object.entries(currentSnapshot)) {
    const [idShop, idCurrency] = key.split(":").map(Number);
    if (!byCurrency.has(idCurrency)) byCurrency.set(idCurrency, []);
    byCurrency.get(idCurrency).push([idShop, rate]);
  }

  const findings = [];
  for (const [idCurrency, shopRates] of byCurrency) {
    const priorRates = {};
    for (const [idShop] of shopRates) {
      const priorKey = `${idShop}:${idCurrency}`;
      if (priorKey in previousSnapshot) priorRates[idShop] = previousSnapshot[priorKey];
    }
    if (!hasDisagreement(Object.values(priorRates), tolerance)) continue;

    for (const group of groupByTolerance(shopRates, tolerance)) {
      const shopsNow = group.map(([idShop]) => idShop);
      const newRate = group[0][1];
      const disagreeingBefore = shopsNow.filter(
        (s) => s in priorRates && Math.abs(priorRates[s] - newRate) > tolerance
      );
      if (disagreeingBefore.length >= 2) {
        const sourceCandidates = shopsNow.filter(
          (s) => s in priorRates && Math.abs(priorRates[s] - newRate) <= tolerance
        );
        findings.push({
          idCurrency,
          idShopsCollapsed: disagreeingBefore.slice().sort((a, b) => a - b),
          oldRates: Object.fromEntries(disagreeingBefore.map((s) => [s, priorRates[s]])),
          newRate,
          likelySourceShop: sourceCandidates.length === 1 ? sourceCandidates[0] : null,
        });
      }
    }
  }
  return findings;
}

/** True when the given rates are not all within tolerance of each other. */
function hasDisagreement(rates, tolerance) {
  if (rates.length < 2) return false;
  const base = rates[0];
  return rates.slice(1).some((r) => Math.abs(r - base) > tolerance);
}

/**
 * Group [idShop, rate] pairs into clusters whose rates are mutually within
 * tolerance of each other. Simple, order-independent clustering that is
 * adequate for the small number of shops a currency has.
 */
function groupByTolerance(shopRates, tolerance) {
  const groups = [];
  for (const [idShop, rate] of shopRates) {
    const group = groups.find((g) => Math.abs(g[0][1] - rate) <= tolerance);
    if (group) group.push([idShop, rate]);
    else groups.push([[idShop, rate]]);
  }
  return groups;
}

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 allShopIds() {
  const data = await apiGet("shops", { display: "full" });
  const rows = data.shops || [];
  return rows.map((row) => Number(row.id));
}

async function currenciesForShop(idShop) {
  const data = await apiGet("currencies", {
    display: "full",
    "filter[active]": "1",
    id_shop: idShop,
  });
  return data.currencies || [];
}

async function buildSnapshot(shopIds) {
  const snapshot = {};
  for (const idShop of shopIds) {
    for (const row of await currenciesForShop(idShop)) {
      const key = `${Number(idShop)}:${Number(row.id)}`;
      snapshot[key] = Number(row.conversion_rate);
    }
  }
  return snapshot;
}

function loadSnapshot(path) {
  if (!existsSync(path)) return {};
  const raw = JSON.parse(readFileSync(path, "utf-8"));
  const snapshot = {};
  for (const item of raw.entries || []) {
    snapshot[`${Number(item.id_shop)}:${Number(item.id_currency)}`] = Number(item.conversion_rate);
  }
  return snapshot;
}

function saveSnapshot(path, snapshot) {
  const entries = Object.entries(snapshot).map(([key, rate]) => {
    const [idShop, idCurrency] = key.split(":").map(Number);
    return { id_shop: idShop, id_currency: idCurrency, conversion_rate: rate };
  });
  writeFileSync(path, JSON.stringify({ entries }, null, 2));
}

async function apiPutRestoreRate(idCurrency, currencyBody, restoredRate) {
  // Restoring one shop's rate rewrites the single shared conversion_rate
  // column, which will simultaneously re-break every other shop sharing
  // this currency id. Only call this after a human has confirmed which
  // rate is authoritative, and never from an automatic branch.
  const body = { ...currencyBody, conversion_rate: restoredRate };
  const url = new URL(`${PRESTASHOP_URL}/api/currencies/${idCurrency}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ currency: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT currencies/${idCurrency}`);
  return res.json();
}

export async function run() {
  const shopIds = await allShopIds();
  const current = await buildSnapshot(shopIds);
  const previous = loadSnapshot(SNAPSHOT_FILE);

  const findings = detectRateOverwrite(previous, current);
  for (const f of findings) {
    console.warn(
      `Currency id=${f.idCurrency} rate collapsed to ${f.newRate} across shops ${JSON.stringify(f.idShopsCollapsed)}. old_rates=${JSON.stringify(f.oldRates)} likely_source_shop=${f.likelySourceShop}`
    );
    if (!DRY_RUN) {
      console.log(
        `DRY_RUN is false, but this script never auto-repairs currency id=${f.idCurrency}. Restoring one shop's rate would re-break every other shop sharing it. Decide the authoritative rate by hand, then call apiPutRestoreRate() explicitly.`
      );
    }
  }

  saveSnapshot(SNAPSHOT_FILE, current);
  console.log(`Done. ${findings.length} suspected overwrite(s) found across ${shopIds.length} shop(s).`);
}

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 which currency ids get reported as a suspected overwrite. Because we kept detect_rate_overwrite pure, the tests need no network and no PrestaShop store. They just feed in two plain snapshot dicts and check the answer.

test_exchange_overwrite.py
from detect_rate_overwrite import detect_rate_overwrite


def test_flags_when_two_disagreeing_shops_collapse_to_one_rate():
    previous = {(1, 3): 0.92, (2, 3): 0.95}
    current = {(1, 3): 0.90, (2, 3): 0.90}
    findings = detect_rate_overwrite(previous, current)
    assert len(findings) == 1
    assert findings[0]["id_currency"] == 3
    assert findings[0]["id_shops_collapsed"] == [1, 2]
    assert findings[0]["new_rate"] == 0.90


def test_no_flag_when_shops_already_agreed():
    previous = {(1, 3): 0.90, (2, 3): 0.90}
    current = {(1, 3): 0.90, (2, 3): 0.90}
    assert detect_rate_overwrite(previous, current) == []


def test_no_flag_when_only_one_shop_changed():
    previous = {(1, 3): 0.92, (2, 3): 0.95}
    current = {(1, 3): 0.90, (2, 3): 0.95}
    assert detect_rate_overwrite(previous, current) == []


def test_no_flag_with_no_previous_snapshot():
    current = {(1, 3): 0.90, (2, 3): 0.90}
    assert detect_rate_overwrite({}, current) == []


def test_identifies_likely_source_shop_when_unambiguous():
    previous = {(1, 3): 0.92, (2, 3): 0.95, (3, 3): 0.90}
    current = {(1, 3): 0.90, (2, 3): 0.90, (3, 3): 0.90}
    findings = detect_rate_overwrite(previous, current)
    assert findings[0]["likely_source_shop"] == 3


def test_tolerance_absorbs_tiny_float_noise():
    previous = {(1, 3): 0.92, (2, 3): 0.95}
    current = {(1, 3): 0.9000001, (2, 3): 0.9000002}
    findings = detect_rate_overwrite(previous, current, tolerance=1e-4)
    assert len(findings) == 1
    assert findings[0]["new_rate"] == 0.9000001


def test_multiple_currencies_are_evaluated_independently():
    previous = {(1, 3): 0.92, (2, 3): 0.95, (1, 4): 1.10, (2, 4): 1.10}
    current = {(1, 3): 0.90, (2, 3): 0.90, (1, 4): 1.10, (2, 4): 1.10}
    findings = detect_rate_overwrite(previous, current)
    assert len(findings) == 1
    assert findings[0]["id_currency"] == 3
overwrite.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectRateOverwrite } from "./detect-rate-overwrite.js";

test("flags when two disagreeing shops collapse to one rate", () => {
  const previous = { "1:3": 0.92, "2:3": 0.95 };
  const current = { "1:3": 0.90, "2:3": 0.90 };
  const findings = detectRateOverwrite(previous, current);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].idCurrency, 3);
  assert.deepEqual(findings[0].idShopsCollapsed, [1, 2]);
  assert.equal(findings[0].newRate, 0.90);
});

test("no flag when shops already agreed", () => {
  const previous = { "1:3": 0.90, "2:3": 0.90 };
  const current = { "1:3": 0.90, "2:3": 0.90 };
  assert.deepEqual(detectRateOverwrite(previous, current), []);
});

test("no flag when only one shop changed", () => {
  const previous = { "1:3": 0.92, "2:3": 0.95 };
  const current = { "1:3": 0.90, "2:3": 0.95 };
  assert.deepEqual(detectRateOverwrite(previous, current), []);
});

test("no flag with no previous snapshot", () => {
  const current = { "1:3": 0.90, "2:3": 0.90 };
  assert.deepEqual(detectRateOverwrite({}, current), []);
});

test("identifies likely source shop when unambiguous", () => {
  const previous = { "1:3": 0.92, "2:3": 0.95, "3:3": 0.90 };
  const current = { "1:3": 0.90, "2:3": 0.90, "3:3": 0.90 };
  const findings = detectRateOverwrite(previous, current);
  assert.equal(findings[0].likelySourceShop, 3);
});

test("tolerance absorbs tiny float noise", () => {
  const previous = { "1:3": 0.92, "2:3": 0.95 };
  const current = { "1:3": 0.9000001, "2:3": 0.9000002 };
  const findings = detectRateOverwrite(previous, current, 1e-4);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].newRate, 0.9000001);
});

test("multiple currencies are evaluated independently", () => {
  const previous = { "1:3": 0.92, "2:3": 0.95, "1:4": 1.10, "2:4": 1.10 };
  const current = { "1:3": 0.90, "2:3": 0.90, "1:4": 1.10, "2:4": 1.10 };
  const findings = detectRateOverwrite(previous, current);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].idCurrency, 3);
});

Case studies

Regional pricing

The second storefront that kept losing its own rate

A retailer ran two shops in one PrestaShop install, a domestic storefront and an export storefront that intentionally used a slightly padded EUR to USD rate to cover wire transfer fees. Every time finance updated the domestic rate for a daily refresh, the export storefront's padded rate quietly reset to the domestic value, undercutting the margin nobody noticed until a shipment came back underpriced.

Running the detection script on a daily schedule caught the pattern immediately: the two shops' recorded rates, which used to differ by design, kept collapsing onto the same number right after the domestic update. Once finance saw the report, they moved the export markup into a separate cart rule instead of relying on a shop-specific exchange rate, since the underlying rate genuinely cannot be kept independent.

Automated cron updates

The cron job that overwrote a manually corrected rate every night

A merchant had manually corrected one shop's exchange rate after a bank statement showed the automatic feed was stale for that currency. The nightly cron_currency_rates.php run refreshed the rate for all shops the next morning, silently wiping out the manual correction along with every other shop's rate.

The team started running the detection script right after the nightly cron finished. It flagged the currency every single night until they excluded that currency from the automatic feed and switched to a manual, confirmed update instead, at which point the flags stopped appearing.

What good looks like

After this runs on a schedule, an exchange rate overwrite across shops shows up as a clear report the same day it happens, with the exact currency id, the shops it affected, the rates before, and the rate it collapsed to. Nobody discovers it three days later from a wrong price on the storefront. The report also makes the underlying limitation visible, so teams stop assuming PrestaShop can hold independent per-shop rates and plan pricing decisions around what the data model actually supports.

FAQ

Why does changing the exchange rate in one shop affect another shop in PrestaShop?

PrestaShop stores a currency's conversion_rate as a single column on the ps_currency row, not one value per shop. A currency is only linked to shops through ps_currency_shop for enabling and disabling it, so when you edit the rate for All Shops or for one shop in the back office, the write updates the one shared column and every other shop using that same currency id instantly gets the new value too.

Is this a bug PrestaShop is going to fix?

No. PrestaShop's own team reviewed this behavior in GitHub issue #23447 and closed it as expected as is, because the data model intentionally stores one conversion_rate per currency id. The cron_currency_rates.php auto updater has the same effect, since it refreshes rates for all shops regardless of which shop triggered it (issue #12025).

Can I fix this with a webservice PUT to restore one shop's rate?

You can write a new conversion_rate with PUT to /api/currencies/{id}, but because the field is shared across every shop using that currency id, restoring one shop's rate will simultaneously overwrite every other shop's rate again. There is no safe automatic fix, which is why the recommended approach is to detect and report the overwrite so a human decides which rate is authoritative before any write happens.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: MultiStore With Multi Currency Exchange Rate Manual Update, issue #23447. github.com/PrestaShop/PrestaShop/issues/23447
  2. PrestaShop GitHub: Multi-store exchange rates cron scheduler, issue #12025. github.com/PrestaShop/PrestaShop/issues/12025
  3. PrestaShop Forums: Currency exchange rate not working on third multistore. prestashop.com/forums/topic/592832-currency-exchange-rate-not-working-on-third-multistore

On the solution:

  1. PrestaShop Developer Documentation: Currencies webservice resource reference. devdocs.prestashop-project.org/1.7/webservice/resources/currencies
  2. PrestaShop Developer Documentation: The PrestaShop Webservice API. devdocs.prestashop-project.org/9/webservice
  3. PrestaShop 8 documentation: Currencies user guide. docs.prestashop-project.org/v.8-documentation/user-guide/improving-shop/going-international/localization/currencies

Stuck on a tricky one?

If you have a problem in PrestaShop multistore, currencies, pricing, orders, 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 rate overwrite?

If this saved you a wrong price on a second storefront, 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