Diagnostic Checkout / Carts

Cart stays locked to its original currency after a currency switch

A shopper adds a shirt while browsing in USD, then switches the storefront currency selector to EUR. The page redraws every price in euros, but the cart underneath never got the memo. BigCommerce fixes a cart's transactional currency at the moment it is created, and there is no API call that can change it afterward. The switch only updates a display preference, so the cart, and checkout behind it, keeps charging in the original currency. Here is why that gap exists and a script that finds the carts it left behind.

Python and Node.js BigCommerce V3 Carts API Safe by default (dry run)
A calculator sitting on top of a pile of money
Photo by Jakub Żerdzicki on Unsplash
The short answer

A BigCommerce cart's transactional currency is fixed at creation time and stored on the cart object itself as cart.currency.code. The REST Cart API has no endpoint to mutate the currency of an existing cart. When a shopper switches the storefront currency selector after items are already in the cart, the storefront only updates the display currency, a cookie or session preference, while the underlying cart and checkout keep transacting in whatever currency the cart was created with. BigCommerce's own documentation states the only supported remedy is for the shopper to empty the cart and re-add items so a new cart is created under the newly selected currency. Detect this by comparing each cart's cart.currency.code against the shopper's currently selected currency; report every mismatch, and treat migration to a new cart as a guarded, opt-in action, never a silent auto-fix. Full code, tests, and a dry run guard are below.

The problem in plain words

When a shopper's first item lands in a BigCommerce cart, the cart is created with a currency baked in, taken from whatever currency was active on the storefront at that moment. From then on, that cart transacts in that currency and nothing else. There is no field you can PATCH and no endpoint you can call to change it once the cart exists.

The currency selector most storefronts show in the header does not touch the cart at all. It changes a display preference, typically stored in a cookie such as currency_code, that tells the storefront which currency to render prices in on category and product pages. That preference and the cart's actual transactional currency are two entirely separate things, and BigCommerce never reconciles them for you.

So a shopper who adds an item in USD, keeps browsing, and switches to EUR sees every product page redraw in euros. But when they get to the cart or checkout, the line items, subtotal, and eventual charge are still in USD, because that is the currency the cart itself was born with. Nothing in the UI necessarily tells them that happened, and it gets worse if the cart already holds a manual discount or is a draft-order cart, since BigCommerce blocks currency changes entirely on those, and any promotion or gift certificate that is not valid in a new currency gets silently stripped if a new cart is ever rebuilt.

Item added in USD cart.currency.code = USD Shopper switches selector to EUR Pages redraw in EUR display preference only Cart currency unchanged Cart still USD no mutate endpoint Checkout charges USD
The storefront currency selector only changes a display preference. The cart's actual transactional currency was fixed the moment it was created, and stays that way straight through to checkout.

Why it happens

BigCommerce ties a cart's currency to the moment of creation on purpose, since a transactional currency has to stay stable for tax, payment, and reporting reasons once money is about to move. A few concrete ways stores end up with a shopper stuck on the wrong currency:

This is a well known point of friction for BigCommerce merchants running multi-currency stores, since nothing in the checkout UI clearly explains why the currency shown does not match the currency charged. See the citations at the end for BigCommerce's own documentation and the support threads where merchants ran into exactly this.

The key insight

A cart's currency is not something you can fix in place, it is something you can only detect and, carefully, replace. The safe pattern is to compare cart.currency.code against the shopper's actually selected storefront currency for every open cart that has line items, flag any mismatch, and only ever migrate a flagged cart to a brand new one under the correct currency as a deliberate, opt-in action, never an automatic background rewrite, since carts with a manual discount or a draft-order cart cannot safely go through that path at all.

The fix, as a flow

We do not touch the live cart or checkout flow, and we never try to mutate a currency BigCommerce does not let us mutate. We add a job that lists open carts, reads each one's currency alongside the shopper's currently selected currency, and decides per cart whether it is mismatched, and if so, whether it is even eligible for a guarded migration.

Scheduled job runs on a timer List open carts with line items Compare currency cart vs selected Currency mismatched? yes no, all good Flag, then guarded migration
Only carts with a genuine currency mismatch and no blocking manual discount or draft-order status are proposed for migration, and only ever behind a dry run flag.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Carts (modify) scope so it can read cart state and, when explicitly authorized, create and delete carts. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header, alongside Accept: application/json. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   # start safe, change to false to allow migration
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to allow migration
2

Talk to the V3 Carts REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET, POST, and DELETE and raises on a non-2xx response. We reuse it to read cart state and the store's currencies, and, only when explicitly authorized, to create a replacement cart and delete the stale one.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}

def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}

def bc_delete(path):
    r = requests.delete(f"{API_BASE}{path}", headers=HEADERS, timeout=30)
    r.raise_for_status()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function bcDelete(path) {
  const res = await fetch(`${API_BASE}${path}`, { method: "DELETE", headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
}
3

Read cart currency, selected currency, and the store default

Call GET /v3/carts/{cartId} for each open cart to read cart.currency.code, cart.line_items, and cart.base_amount. Cross-check against the shopper's currently selected storefront currency, either the active currency_code cookie value captured at request time, or a per-customer preference your app tracks, since BigCommerce does not persist that server-side. Fall back to the store's active default currency from GET /v3/currencies for guest carts with no tracked selection.

step3.py
def get_cart(cart_id):
    return bc_get(f"/carts/{cart_id}")

def get_store_default_currency():
    currencies = (bc_get("/currencies").get("data") or [])
    for currency in currencies:
        if currency.get("is_default"):
            return currency.get("currency_code")
    return None
step3.js
async function getCart(cartId) {
  return bcGet(`/carts/${cartId}`);
}

async function getStoreDefaultCurrency() {
  const currencies = (await bcGet("/currencies")).data || [];
  const defaultCurrency = currencies.find((c) => c.is_default);
  return defaultCurrency ? defaultCurrency.currency_code : null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the list of carts, the map of the shopper's selected currency, and the store default, and returns only the carts that are genuinely mismatched. It skips empty carts, since there is nothing at risk there, resolves the expected currency per cart from the tracked selection or the store default, and augments each flagged cart with expected_currency and has_blocking_discount so the caller knows immediately whether auto-migration is even a candidate.

decide.py
def _cart_has_line_items(cart):
    line_items = cart.get("line_items") or {}
    for key in ("physical_items", "digital_items", "gift_certificates", "custom_items"):
        if line_items.get(key):
            return True
    return False

def _cart_has_blocking_discount(cart):
    if cart.get("is_draft"):
        return True
    line_items = cart.get("line_items") or {}
    for key in ("physical_items", "digital_items", "custom_items"):
        for item in line_items.get(key) or []:
            if item.get("discounts"):
                return True
    return False

def find_currency_mismatched_carts(carts, selected_currency_by_customer, store_default_currency):
    flagged = []
    for cart in carts:
        if not _cart_has_line_items(cart):
            continue

        customer_id = cart.get("customer_id")
        key = str(customer_id) if customer_id else cart.get("id")
        expected_currency = selected_currency_by_customer.get(key, store_default_currency)
        if not expected_currency:
            continue

        cart_currency = (cart.get("currency") or {}).get("code")
        if cart_currency == expected_currency:
            continue

        flagged.append({
            **cart,
            "expected_currency": expected_currency,
            "has_blocking_discount": _cart_has_blocking_discount(cart),
        })
    return flagged
decide.js
function cartHasLineItems(cart) {
  const lineItems = cart.line_items || {};
  return ["physical_items", "digital_items", "gift_certificates", "custom_items"].some(
    (key) => (lineItems[key] || []).length > 0
  );
}

function cartHasBlockingDiscount(cart) {
  if (cart.is_draft) return true;
  const lineItems = cart.line_items || {};
  return ["physical_items", "digital_items", "custom_items"].some((key) =>
    (lineItems[key] || []).some((item) => (item.discounts || []).length > 0)
  );
}

export function findCurrencyMismatchedCarts(carts, selectedCurrencyByCustomer, storeDefaultCurrency) {
  const flagged = [];
  for (const cart of carts) {
    if (!cartHasLineItems(cart)) continue;

    const key = cart.customer_id ? String(cart.customer_id) : cart.id;
    const expectedCurrency = selectedCurrencyByCustomer[key] || storeDefaultCurrency;
    if (!expectedCurrency) continue;

    const cartCurrency = (cart.currency || {}).code;
    if (cartCurrency === expectedCurrency) continue;

    flagged.push({
      ...cart,
      expected_currency: expectedCurrency,
      has_blocking_discount: cartHasBlockingDiscount(cart),
    });
  }
  return flagged;
}
5

Propose, never force, the migration

For a flagged cart with has_blocking_discount false, the only supported repair is to read its line items, POST a brand new cart at /v3/carts with the same line items and channel id but {"currency": {"code": expected_currency}}, and only once a human has switched DRY_RUN to false, DELETE the stale cart and hand the new cart id back to the storefront session. A cart with has_blocking_discount true is excluded from this path entirely and only ever reported.

migrate.py
def build_migration_line_items(cart):
    line_items = cart.get("line_items") or {}
    return {
        "line_items": [
            {"product_id": item["product_id"], "variant_id": item.get("variant_id"), "quantity": item["quantity"]}
            for item in line_items.get("physical_items") or []
        ]
    }

def migrate_cart(cart, channel_id):
    body = {
        "channel_id": channel_id,
        "currency": {"code": cart["expected_currency"]},
        **build_migration_line_items(cart),
    }
    return bc_post("/carts", body)

def delete_cart(cart_id):
    bc_delete(f"/carts/{cart_id}")
migrate.js
function buildMigrationLineItems(cart) {
  const lineItems = cart.line_items || {};
  return {
    line_items: (lineItems.physical_items || []).map((item) => ({
      product_id: item.product_id,
      variant_id: item.variant_id,
      quantity: item.quantity,
    })),
  };
}

async function migrateCart(cart, channelId) {
  const body = {
    channel_id: channelId,
    currency: { code: cart.expected_currency },
    ...buildMigrationLineItems(cart),
  };
  return bcPost("/carts", body);
}

async function deleteCart(cartId) {
  return bcDelete(`/carts/${cartId}`);
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. With DRY_RUN on, the script only logs the {cart_id, customer_id, cart_currency, expected_currency, has_blocking_discount} tuple for every mismatched cart, and never creates or deletes anything. Once you have reviewed the log and are ready, switch it off so eligible carts get migrated to a fresh cart in the correct currency and the stale cart is removed. Run it on a schedule, for example every 15 minutes, so mismatches are caught before a shopper reaches checkout.

Run it safe

Always start with DRY_RUN=true, and never migrate a cart flagged with has_blocking_discount true. BigCommerce blocks or alters currency changes on carts with a manual discount or draft-order status, and any promotion or gift certificate invalid in the new currency is silently dropped when a new cart is rebuilt, so those carts must only ever be reported to a human.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only proposes migrating carts with a genuine currency mismatch and no blocking discount, and only ever flags, never touches, anything with a manual discount or draft-order status.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

find_currency_mismatched_carts.py
"""Find and, when authorized, migrate BigCommerce carts locked to the wrong currency.

A BigCommerce cart's transactional currency is fixed at creation time and stored
on the cart object as cart.currency.code. The REST Cart API has no endpoint to
mutate the currency of an existing cart. When a shopper switches the storefront
currency selector after items are already in the cart, the storefront only
updates the display currency, a cookie or session preference, while the
underlying cart and checkout keep transacting in the original currency. This
job lists open carts, compares each cart's currency against the shopper's
selected currency (falling back to the store's default for untracked guest
carts), and flags every mismatch. Carts with a manual discount or draft-order
status are excluded from auto-migration and only ever reported, since
BigCommerce blocks or alters currency changes on those, and any promotion or
gift certificate invalid in the new currency is silently dropped when a new
cart is rebuilt. Eligible carts are proposed for a guarded migration to a new
cart in the correct currency, gated by DRY_RUN. Safe to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/cart-locked-to-original-currency/
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "1"))

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}


def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}


def bc_delete(path):
    r = requests.delete(f"{API_BASE}{path}", headers=HEADERS, timeout=30)
    r.raise_for_status()


def _cart_has_line_items(cart: dict) -> bool:
    line_items = cart.get("line_items") or {}
    for key in ("physical_items", "digital_items", "gift_certificates", "custom_items"):
        if line_items.get(key):
            return True
    return False


def _cart_has_blocking_discount(cart: dict) -> bool:
    if cart.get("is_draft"):
        return True
    line_items = cart.get("line_items") or {}
    for key in ("physical_items", "digital_items", "custom_items"):
        for item in line_items.get(key) or []:
            if item.get("discounts"):
                return True
    return False


def find_currency_mismatched_carts(
    carts: list, selected_currency_by_customer: dict, store_default_currency: str
) -> list:
    """Pure decision logic. No network calls.

    carts: list of cart dicts as returned by GET /v3/carts/{cartId}, each with
    {"id": str, "customer_id": int|None, "currency": {"code": str},
    "line_items": {...}, "base_amount": float}.

    selected_currency_by_customer: map of customer_id (or session/guest id) to
    the shopper's currently selected storefront currency_code.

    store_default_currency: the store's active default currency_code, used as
    a fallback for guest carts with no tracked selection.

    Returns the subset of cart dicts (augmented with 'expected_currency' and
    'has_blocking_discount') whose cart.currency.code differs from the
    shopper's selected currency and that have at least one line item. Empty
    carts are never flagged.
    """
    flagged = []
    for cart in carts:
        if not _cart_has_line_items(cart):
            continue

        customer_id = cart.get("customer_id")
        key = str(customer_id) if customer_id else cart.get("id")
        expected_currency = selected_currency_by_customer.get(key, store_default_currency)
        if not expected_currency:
            continue

        cart_currency = (cart.get("currency") or {}).get("code")
        if cart_currency == expected_currency:
            continue

        flagged.append(
            {
                **cart,
                "expected_currency": expected_currency,
                "has_blocking_discount": _cart_has_blocking_discount(cart),
            }
        )
    return flagged


def get_store_default_currency():
    currencies = bc_get("/currencies").get("data") or []
    for currency in currencies:
        if currency.get("is_default"):
            return currency.get("currency_code")
    return None


def list_open_carts():
    resp = bc_get("/carts")
    return resp.get("data") or []


def build_migration_line_items(cart):
    line_items = cart.get("line_items") or {}
    return {
        "line_items": [
            {
                "product_id": item["product_id"],
                "variant_id": item.get("variant_id"),
                "quantity": item["quantity"],
            }
            for item in line_items.get("physical_items") or []
        ]
    }


def migrate_cart(cart, channel_id):
    body = {
        "channel_id": channel_id,
        "currency": {"code": cart["expected_currency"]},
        **build_migration_line_items(cart),
    }
    return bc_post("/carts", body)


def delete_cart(cart_id):
    bc_delete(f"/carts/{cart_id}")


def run(selected_currency_by_customer=None):
    selected_currency_by_customer = selected_currency_by_customer or {}
    store_default_currency = get_store_default_currency()
    carts = list_open_carts()

    mismatched = find_currency_mismatched_carts(carts, selected_currency_by_customer, store_default_currency)

    migrated = 0
    reported_only = 0

    for cart in mismatched:
        cart_id = cart["id"]
        log.info(
            "cart_id=%s customer_id=%s cart_currency=%s expected_currency=%s has_blocking_discount=%s",
            cart_id, cart.get("customer_id"), (cart.get("currency") or {}).get("code"),
            cart["expected_currency"], cart["has_blocking_discount"],
        )

        if cart["has_blocking_discount"]:
            log.warning("cart_id=%s excluded from auto-migration, reporting only.", cart_id)
            reported_only += 1
            continue

        if DRY_RUN:
            log.info("DRY_RUN: would create a replacement cart for cart_id=%s and delete the stale cart.", cart_id)
            migrated += 1
            continue

        new_cart = migrate_cart(cart, CHANNEL_ID)
        delete_cart(cart_id)
        new_cart_id = (new_cart.get("data") or {}).get("id")
        log.info("cart_id=%s migrated to new_cart_id=%s", cart_id, new_cart_id)
        migrated += 1

    log.info(
        "Done. %d cart(s) %s, %d cart(s) reported only (blocking discount).",
        migrated, "to migrate" if DRY_RUN else "migrated", reported_only,
    )


if __name__ == "__main__":
    run()
find-currency-mismatched-carts.js
/**
 * Find and, when authorized, migrate BigCommerce carts locked to the wrong currency.
 *
 * A BigCommerce cart's transactional currency is fixed at creation time and stored
 * on the cart object as cart.currency.code. The REST Cart API has no endpoint to
 * mutate the currency of an existing cart. When a shopper switches the storefront
 * currency selector after items are already in the cart, the storefront only
 * updates the display currency, a cookie or session preference, while the
 * underlying cart and checkout keep transacting in the original currency. This
 * job lists open carts, compares each cart's currency against the shopper's
 * selected currency (falling back to the store's default for untracked guest
 * carts), and flags every mismatch. Carts with a manual discount or draft-order
 * status are excluded from auto-migration and only ever reported, since
 * BigCommerce blocks or alters currency changes on those, and any promotion or
 * gift certificate invalid in the new currency is silently dropped when a new
 * cart is rebuilt. Eligible carts are proposed for a guarded migration to a new
 * cart in the correct currency, gated by DRY_RUN. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/cart-locked-to-original-currency/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CHANNEL_ID = Number(process.env.CHANNEL_ID || 1);

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

function cartHasLineItems(cart) {
  const lineItems = cart.line_items || {};
  return ["physical_items", "digital_items", "gift_certificates", "custom_items"].some(
    (key) => (lineItems[key] || []).length > 0
  );
}

function cartHasBlockingDiscount(cart) {
  if (cart.is_draft) return true;
  const lineItems = cart.line_items || {};
  return ["physical_items", "digital_items", "custom_items"].some((key) =>
    (lineItems[key] || []).some((item) => (item.discounts || []).length > 0)
  );
}

/**
 * Pure decision logic. No network calls.
 *
 * carts: list of cart dicts as returned by GET /v3/carts/{cartId}, each with
 * {id, customer_id, currency: {code}, line_items, base_amount}.
 *
 * selectedCurrencyByCustomer: map of customer_id (or session/guest id) to the
 * shopper's currently selected storefront currency_code.
 *
 * storeDefaultCurrency: the store's active default currency_code, used as a
 * fallback for guest carts with no tracked selection.
 *
 * Returns the subset of carts (augmented with expected_currency and
 * has_blocking_discount) whose cart.currency.code differs from the shopper's
 * selected currency and that have at least one line item. Empty carts are
 * never flagged.
 */
export function findCurrencyMismatchedCarts(carts, selectedCurrencyByCustomer, storeDefaultCurrency) {
  const flagged = [];
  for (const cart of carts) {
    if (!cartHasLineItems(cart)) continue;

    const key = cart.customer_id ? String(cart.customer_id) : cart.id;
    const expectedCurrency = selectedCurrencyByCustomer[key] || storeDefaultCurrency;
    if (!expectedCurrency) continue;

    const cartCurrency = (cart.currency || {}).code;
    if (cartCurrency === expectedCurrency) continue;

    flagged.push({
      ...cart,
      expected_currency: expectedCurrency,
      has_blocking_discount: cartHasBlockingDiscount(cart),
    });
  }
  return flagged;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function bcDelete(path) {
  const res = await fetch(`${API_BASE}${path}`, { method: "DELETE", headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
}

async function getStoreDefaultCurrency() {
  const currencies = (await bcGet("/currencies")).data || [];
  const defaultCurrency = currencies.find((c) => c.is_default);
  return defaultCurrency ? defaultCurrency.currency_code : null;
}

async function listOpenCarts() {
  const resp = await bcGet("/carts");
  return resp.data || [];
}

function buildMigrationLineItems(cart) {
  const lineItems = cart.line_items || {};
  return {
    line_items: (lineItems.physical_items || []).map((item) => ({
      product_id: item.product_id,
      variant_id: item.variant_id,
      quantity: item.quantity,
    })),
  };
}

async function migrateCart(cart, channelId) {
  const body = {
    channel_id: channelId,
    currency: { code: cart.expected_currency },
    ...buildMigrationLineItems(cart),
  };
  return bcPost("/carts", body);
}

async function deleteCart(cartId) {
  return bcDelete(`/carts/${cartId}`);
}

export async function run(selectedCurrencyByCustomer = {}) {
  const storeDefaultCurrency = await getStoreDefaultCurrency();
  const carts = await listOpenCarts();

  const mismatched = findCurrencyMismatchedCarts(carts, selectedCurrencyByCustomer, storeDefaultCurrency);

  let migrated = 0;
  let reportedOnly = 0;

  for (const cart of mismatched) {
    const cartId = cart.id;
    console.log(
      `cart_id=${cartId} customer_id=${cart.customer_id} cart_currency=${(cart.currency || {}).code} ` +
      `expected_currency=${cart.expected_currency} has_blocking_discount=${cart.has_blocking_discount}`
    );

    if (cart.has_blocking_discount) {
      console.warn(`cart_id=${cartId} excluded from auto-migration, reporting only.`);
      reportedOnly += 1;
      continue;
    }

    if (DRY_RUN) {
      console.log(`DRY_RUN: would create a replacement cart for cart_id=${cartId} and delete the stale cart.`);
      migrated += 1;
      continue;
    }

    const newCart = await migrateCart(cart, CHANNEL_ID);
    await deleteCart(cartId);
    const newCartId = (newCart.data || {}).id;
    console.log(`cart_id=${cartId} migrated to new_cart_id=${newCartId}`);
    migrated += 1;
  }

  console.log(
    `Done. ${migrated} cart(s) ${DRY_RUN ? "to migrate" : "migrated"}, ${reportedOnly} cart(s) reported only (blocking discount).`
  );
}

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 carts get flagged and which ones are even candidates for migration. Because find_currency_mismatched_carts takes only plain lists and dicts and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain cart objects and checks the answer.

test_cart_currency_mismatch.py
from find_currency_mismatched_carts import find_currency_mismatched_carts


def cart(id_="cart_1", customer_id=None, currency="USD", physical_items=None, is_draft=False):
    return {
        "id": id_,
        "customer_id": customer_id,
        "currency": {"code": currency},
        "line_items": {"physical_items": physical_items or []},
        "base_amount": 50.0,
        "is_draft": is_draft,
    }


def item(discounts=None):
    return {"product_id": 1, "variant_id": None, "quantity": 1, "discounts": discounts or []}


def test_empty_cart_is_never_flagged():
    carts = [cart(currency="USD", physical_items=[])]
    result = find_currency_mismatched_carts(carts, {}, "EUR")
    assert result == []


def test_matching_currency_is_not_flagged():
    carts = [cart(currency="EUR", physical_items=[item()])]
    result = find_currency_mismatched_carts(carts, {}, "EUR")
    assert result == []


def test_mismatched_currency_is_flagged_with_expected_currency():
    carts = [cart(id_="cart_9", customer_id=42, currency="USD", physical_items=[item()])]
    result = find_currency_mismatched_carts(carts, {"42": "EUR"}, "USD")
    assert len(result) == 1
    assert result[0]["id"] == "cart_9"
    assert result[0]["expected_currency"] == "EUR"
    assert result[0]["has_blocking_discount"] is False


def test_guest_cart_falls_back_to_store_default_currency():
    carts = [cart(id_="cart_guest", customer_id=None, currency="USD", physical_items=[item()])]
    result = find_currency_mismatched_carts(carts, {}, "GBP")
    assert len(result) == 1
    assert result[0]["expected_currency"] == "GBP"


def test_draft_cart_is_flagged_as_blocking():
    carts = [cart(id_="cart_draft", customer_id=7, currency="USD", physical_items=[item()], is_draft=True)]
    result = find_currency_mismatched_carts(carts, {"7": "EUR"}, "USD")
    assert len(result) == 1
    assert result[0]["has_blocking_discount"] is True


def test_cart_with_line_item_discount_is_flagged_as_blocking():
    carts = [cart(id_="cart_disc", customer_id=3, currency="USD", physical_items=[item(discounts=[{"id": 1}])])]
    result = find_currency_mismatched_carts(carts, {"3": "EUR"}, "USD")
    assert len(result) == 1
    assert result[0]["has_blocking_discount"] is True
find-currency-mismatched-carts.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findCurrencyMismatchedCarts } from "./find-currency-mismatched-carts.js";

const cart = ({ id = "cart_1", customerId = null, currency = "USD", physicalItems = [], isDraft = false } = {}) => ({
  id,
  customer_id: customerId,
  currency: { code: currency },
  line_items: { physical_items: physicalItems },
  base_amount: 50.0,
  is_draft: isDraft,
});

const item = (discounts = []) => ({ product_id: 1, variant_id: null, quantity: 1, discounts });

test("empty cart is never flagged", () => {
  const carts = [cart({ currency: "USD", physicalItems: [] })];
  const result = findCurrencyMismatchedCarts(carts, {}, "EUR");
  assert.deepEqual(result, []);
});

test("matching currency is not flagged", () => {
  const carts = [cart({ currency: "EUR", physicalItems: [item()] })];
  const result = findCurrencyMismatchedCarts(carts, {}, "EUR");
  assert.deepEqual(result, []);
});

test("mismatched currency is flagged with expected currency", () => {
  const carts = [cart({ id: "cart_9", customerId: 42, currency: "USD", physicalItems: [item()] })];
  const result = findCurrencyMismatchedCarts(carts, { "42": "EUR" }, "USD");
  assert.equal(result.length, 1);
  assert.equal(result[0].id, "cart_9");
  assert.equal(result[0].expected_currency, "EUR");
  assert.equal(result[0].has_blocking_discount, false);
});

test("guest cart falls back to store default currency", () => {
  const carts = [cart({ id: "cart_guest", customerId: null, currency: "USD", physicalItems: [item()] })];
  const result = findCurrencyMismatchedCarts(carts, {}, "GBP");
  assert.equal(result.length, 1);
  assert.equal(result[0].expected_currency, "GBP");
});

test("draft cart is flagged as blocking", () => {
  const carts = [cart({ id: "cart_draft", customerId: 7, currency: "USD", physicalItems: [item()], isDraft: true })];
  const result = findCurrencyMismatchedCarts(carts, { "7": "EUR" }, "USD");
  assert.equal(result.length, 1);
  assert.equal(result[0].has_blocking_discount, true);
});

test("cart with line item discount is flagged as blocking", () => {
  const carts = [cart({ id: "cart_disc", customerId: 3, currency: "USD", physicalItems: [item([{ id: 1 }])] })];
  const result = findCurrencyMismatchedCarts(carts, { "3": "EUR" }, "USD");
  assert.equal(result.length, 1);
  assert.equal(result[0].has_blocking_discount, true);
});

Case studies

Returning shopper, stale cart

The store where carts silently persisted across visits

A mid-size storefront kept carts alive across sessions so shoppers could pick up where they left off. Support started getting complaints from EU shoppers who swore they set their currency to EUR, only to see a USD charge at checkout. Every affected shopper had added an item on an earlier visit, then switched currencies on a later one, long after the cart's currency was already locked in.

Now the job runs every 15 minutes, compares each open cart's currency against the shopper's currently selected currency, and reports every mismatch before the shopper ever reaches checkout, instead of support discovering it after a confused chargeback dispute.

Geolocation default overridden

The storefront that auto-selected currency by IP

A store auto-selected a shopper's currency from their IP address on first visit, then let them override it manually. A traveling customer added an item while the geolocation default was still active, then corrected the currency selector by hand a few minutes later. The correction only ever touched the display layer.

The job caught this the same way as the returning-shopper case, because it never assumes why the mismatch exists, it just compares the cart's actual currency against what the shopper has selected right now, and flags anything that does not line up.

What good looks like

After this runs on a schedule, no shopper reaches checkout without someone first knowing their cart's currency does not match what they last selected. Eligible carts are proposed for a clean migration to a fresh cart in the right currency, and anything carrying a manual discount or draft-order status is reported to a human instead, so no promotion or gift certificate gets silently dropped by an automated rewrite.

FAQ

Why does my BigCommerce cart keep the old currency after I switch the currency selector?

A cart's transactional currency is set once, at creation time, and stored on the cart object as cart.currency.code. The storefront currency selector only changes a display preference, usually a cookie, and there is no REST endpoint that mutates the currency of an existing cart. So the cart and checkout keep transacting in whatever currency was active when the first item was added, even though the page now shows prices in the newly selected currency.

Can I just PATCH the cart to change its currency?

No. The BigCommerce REST Cart API has no PUT or PATCH operation that changes cart.currency on an existing cart. The only supported remedy, per BigCommerce's own documentation, is for the shopper to empty the cart and re-add the items, which creates a brand new cart under the newly selected currency. Carts holding a manual discount or draft-order carts block currency changes entirely.

Is it safe to auto-migrate every mismatched cart to a new currency?

Not fully automatically. A cart with a manual discount or a draft-order cart should only be reported, never auto-migrated, because BigCommerce blocks or alters currency changes on those, and any promotion or gift certificate that is invalid in the new currency is silently dropped when a new cart is rebuilt. Treat migration as a guarded, opt-in action behind a dry run flag, not a background auto-fix.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Cart Currency, including the creation-time behavior. developer.bigcommerce.com cart currency
  2. BigCommerce Support Community: broken currency is driving me nuts. support.bigcommerce.com broken currency is driving me nuts
  3. BigCommerce Support Community: can Multi Currency update at cart page. bigcommerce.my.site.com can multi currency update at cart page

On the solution:

  1. BigCommerce Developer Center: Cart Currency reference. developer.bigcommerce.com cart currency
  2. BigCommerce Developer Center: Carts (REST Management). developer.bigcommerce.com carts
  3. BigCommerce Developer Center: Currencies Overview. developer.bigcommerce.com currencies overview

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment 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 currency mismatch before checkout?

If this saved a customer from a confusing charge, or saved your support team from a pile of currency complaints, 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 BigCommerce field notes