Reconciler Tax, currency, and reporting
Presentment vs settlement currency
A shopper in Berlin checks out in EUR. The store's books run in USD. The bank deposit that shows up two days later is a third number, in a currency and at a rate the payment gateway chose on its own schedule. All three of these, the presentment amount the shopper saw, the order total BigCommerce stored, and the settlement amount that landed in the bank, can legitimately disagree. Naive reconciliation that just matches order total to bank deposit breaks quietly and misreports revenue or FX gain and loss. Here is why it happens and a small script that flags the variance without touching a single money field on the order.
When Multi-Currency is enabled on a BigCommerce store, a shopper can pay in an active transactional currency, such as EUR, that differs from the store's configured base currency, such as USD. The order carries default_currency_code for what the buyer paid, alongside store_default_currency_code and store_default_to_transactional_exchange_rate for converting back to the base currency. Run a small Python or Node.js script that pulls financially final orders, flags any where default_currency_code differs from store_default_currency_code, computes the expected base-currency amount as total_inc_tax * store_default_to_transactional_exchange_rate, and compares it against your ledger's recorded amount. When the variance exceeds a small tolerance, it writes the presentment, settlement, and variance figures to staff_notes for a human to review. It never edits total_inc_tax or default_currency_code. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce's Multi-Currency feature lets a shopper browse and pay in a currency that is comfortable for them, even though the store's accounting runs in one base currency underneath. The Orders API exposes both sides of that: the transactional amounts the buyer actually paid, through fields like default_currency_code and total_inc_tax, and the store's own base currency plus the rate between the two, through store_default_currency_code and store_default_to_transactional_exchange_rate.
The trouble is that plenty of integrations and finance exports only read the order's face-value total. They never apply that exchange rate, so a EUR order gets treated as if its number were already in USD. On top of that, the payment gateway itself often settles to the merchant's bank account in yet a third currency, at its own rate and on its own schedule, days after the order was placed. So you end up with three numbers that can each be correct on their own terms and still not agree with each other: the presentment amount shown to the shopper, the order total BigCommerce stored, and the settlement amount that lands in the bank.
Why it happens
Multi-Currency gives shoppers a currency they understand at checkout, but nothing forces every downstream system to convert consistently back to the store's base currency. A few common ways stores end up with a mismatch:
- A finance export or accounting integration reads
total_inc_taxanddefault_currency_codeat face value without ever applyingstore_default_to_transactional_exchange_rateto convert it intostore_default_currency_code. - The payment gateway settles to the merchant's bank account in a currency of its own choosing, using its own daily rate, days after the order was placed and at a rate that differs from the one BigCommerce recorded on the order.
- A reconciliation job matches order total to bank deposit directly, assuming both are already in the same currency, when the order was presented and paid in a non-base currency entirely.
- Rounding accumulates across many small orders in a foreign presentment currency, so a tolerance-free comparison flags noise instead of a real variance.
This is a common source of confusion for merchants who expand into new markets with Multi-Currency turned on. The order looks correct in the BigCommerce admin, the bank statement looks correct on its own, and yet the two never quite line up because nobody applied the exchange rate that ties them together. See the citations at the end for the exact references.
A variance here does not mean the order is wrong. BigCommerce's stored total and exchange rate are the authoritative record of what the shopper was actually charged at that moment. So the safe pattern is not "correct the order to match the bank" or the other way around. It is "compute the expected base-currency amount, compare it to what the ledger recorded, and flag the difference as a financial annotation for a human to reconcile." We do that with a note or an external ledger row, never a direct edit to total_inc_tax or default_currency_code.
The fix, as a flow
We do not touch the order's currency fields. We add a job that walks financially final orders, reads the transactional and base currency fields plus the gateway transaction record, computes the expected base-currency equivalent, and compares it against the ledger's recorded amount for that order. When the two disagree by more than a small tolerance, we write the presentment, settlement, and variance figures to the order and leave the totals alone for a human to resolve.
Build it step by step
Get a BigCommerce API access token
Create an API account in your BigCommerce control panel under Settings, API, and generate a token with the Orders and Transactions read scopes, plus Orders write if you want the script to write the reconciliation note. Keep the store hash and the token in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export FX_TOLERANCE_RATIO="0.005"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export FX_TOLERANCE_RATIO="0.005"
export DRY_RUN="true" // start safe, change to false to write
Talk to the BigCommerce REST API
Every call carries your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper sends the request, raises on a non-2xx response, and returns the parsed body. We reuse this helper to read orders, read transactions, and later write the note.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
List financially final orders and pull the gateway transaction
Read orders whose status_id is 10 (Completed), 11 (Awaiting Fulfillment), 2 (Shipped), 3 (Partially Shipped), or 14 (Partially Refunded), since those are financially final states where the currency and amounts should already be settled. For each order, call GET /v2/orders/{id}/transactions to read the gateway transaction record representing what the processor actually captured.
FINAL_STATUS_IDS = {10, 11, 2, 3, 14}
def orders_to_check(min_date_created):
page = 1
while True:
rows = bc("GET", f"/v2/orders?min_date_created={min_date_created}&page={page}&limit=50")
if not rows:
return
for row in rows:
if int(row["status_id"]) in FINAL_STATUS_IDS:
yield row
page += 1
def order_transactions(order_id):
return bc("GET", f"/v2/orders/{order_id}/transactions") or []
const FINAL_STATUS_IDS = new Set([10, 11, 2, 3, 14]);
async function* ordersToCheck(minDateCreated) {
let page = 1;
while (true) {
const rows = await bc("GET", `/v2/orders?min_date_created=${minDateCreated}&page=${page}&limit=50`);
if (!rows || rows.length === 0) return;
for (const row of rows) {
if (FINAL_STATUS_IDS.has(Number(row.status_id))) yield row;
}
page++;
}
}
async function orderTransactions(orderId) {
return (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
}
Decide, with one pure function
Keep the comparison in its own function that takes the order's currency fields plus a ledger's recorded base amount and returns whether they tie out. It flags the order as a presentment case whenever default_currency_code differs from store_default_currency_code, computes the expected base amount as total_inc_tax * store_default_to_transactional_exchange_rate, and compares that against the ledger figure with a ratio-based tolerance so it scales correctly for both small and large orders. A pure function like this needs no network to test, which we do later.
def classify_currency_variance(order, tolerance_ratio=0.005):
default_ccy = order["defaultCurrencyCode"]
store_ccy = order["storeDefaultCurrencyCode"]
rate = order["storeDefaultToTransactionalExchangeRate"]
total = float(order["totalIncTax"])
ledger_base_amount = float(order["ledgerBaseAmount"])
is_mismatch = default_ccy != store_ccy
expected_base_amount = total * rate if is_mismatch else total
variance = abs(expected_base_amount - ledger_base_amount)
variance_ratio = (variance / expected_base_amount) if expected_base_amount else 0.0
return {
"isMismatch": is_mismatch and variance_ratio > tolerance_ratio,
"presentmentCurrency": default_ccy,
"settlementCurrency": store_ccy,
"expectedBaseAmount": expected_base_amount,
"variance": variance,
"varianceRatio": variance_ratio,
}
export function classifyCurrencyVariance(order, toleranceRatio = 0.005) {
const { defaultCurrencyCode, storeDefaultCurrencyCode, totalIncTax,
storeDefaultToTransactionalExchangeRate, ledgerBaseAmount } = order;
const isMismatch = defaultCurrencyCode !== storeDefaultCurrencyCode;
const total = Number(totalIncTax);
const rate = Number(storeDefaultToTransactionalExchangeRate);
const expectedBaseAmount = isMismatch ? total * rate : total;
const variance = Math.abs(expectedBaseAmount - Number(ledgerBaseAmount));
const varianceRatio = expectedBaseAmount ? variance / expectedBaseAmount : 0;
return {
isMismatch: isMismatch && varianceRatio > toleranceRatio,
presentmentCurrency: defaultCurrencyCode,
settlementCurrency: storeDefaultCurrencyCode,
expectedBaseAmount,
variance,
varianceRatio,
};
}
Flag the order, never patch its totals
When an order is mismatched, write a machine-readable note to staff_notes through PUT /v2/orders/{id}, something like FX_VARIANCE: presentment=EUR settlement=USD expected=<n> variance=<n> ratio=<n>. Leave total_inc_tax, default_currency_code, and every other money field untouched. This is a tag for a human to review against the payout report, not a correction, because the true settlement figure lives with the gateway or your bank, not on the order itself.
def flag_order(order_id, result):
note = (
f"FX_VARIANCE: presentment={result['presentmentCurrency']} "
f"settlement={result['settlementCurrency']} "
f"expected={result['expectedBaseAmount']:.2f} "
f"variance={result['variance']:.2f} "
f"ratio={result['varianceRatio']:.4f}"
)
return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
async function flagOrder(orderId, result) {
const note = `FX_VARIANCE: presentment=${result.presentmentCurrency} ` +
`settlement=${result.settlementCurrency} ` +
`expected=${result.expectedBaseAmount.toFixed(2)} ` +
`variance=${result.variance.toFixed(2)} ` +
`ratio=${result.varianceRatio.toFixed(4)}`;
return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}
Wire it together with a dry run guard
The loop pulls each candidate order, fetches its ledger's recorded base amount for that order_id, runs the pure decision function, and only writes the note when DRY_RUN is off. Leave DRY_RUN=true on the first runs so the script only logs which orders it would flag. Read the log, confirm it looks right against your payout report, then switch it off. Run it on a schedule that matches your settlement cycle, for example once a day.
Always start with DRY_RUN=true. Never let this script write total_inc_tax, default_currency_code, or store_default_to_transactional_exchange_rate. The order's stored totals and rate are the authoritative record of what was charged. Only a human reconciling against the actual bank deposit or gateway payout report should decide what, if anything, needs correcting on the accounting side.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never edits an order's currency or total fields, only a note that flags it for a human.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Flag BigCommerce orders where presentment and settlement currency diverge.
When Multi-Currency is enabled, a shopper can pay in a transactional currency
(default_currency_code) that differs from the store's base currency
(store_default_currency_code). BigCommerce records the rate between the two as
store_default_to_transactional_exchange_rate, but a finance export that reads
only the face-value total, or a gateway that settles to the bank in a third
currency at its own rate, can leave the presentment amount, the order total,
and the settlement amount all disagreeing. This reads each financially final
order's currency fields, computes the expected base-currency amount, compares
it against your ledger's recorded amount for that order, and writes an
FX_VARIANCE note to staff_notes when they disagree by more than a tolerance.
It never edits total_inc_tax, default_currency_code, or the exchange rate.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_currency_variance")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
MIN_DATE_CREATED = os.environ.get("MIN_DATE_CREATED", "")
TOLERANCE_RATIO = float(os.environ.get("FX_TOLERANCE_RATIO", "0.005"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
FINAL_STATUS_IDS = {10, 11, 2, 3, 14}
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
def classify_currency_variance(order, tolerance_ratio=0.005):
"""Pure decision function. No network calls.
order: {
"defaultCurrencyCode": str, "storeDefaultCurrencyCode": str,
"totalIncTax": number|str, "storeDefaultToTransactionalExchangeRate": number|str,
"ledgerBaseAmount": number|str,
}
"""
default_ccy = order["defaultCurrencyCode"]
store_ccy = order["storeDefaultCurrencyCode"]
rate = float(order["storeDefaultToTransactionalExchangeRate"])
total = float(order["totalIncTax"])
ledger_base_amount = float(order["ledgerBaseAmount"])
is_mismatch = default_ccy != store_ccy
expected_base_amount = total * rate if is_mismatch else total
variance = abs(expected_base_amount - ledger_base_amount)
variance_ratio = (variance / expected_base_amount) if expected_base_amount else 0.0
return {
"isMismatch": is_mismatch and variance_ratio > tolerance_ratio,
"presentmentCurrency": default_ccy,
"settlementCurrency": store_ccy,
"expectedBaseAmount": expected_base_amount,
"variance": variance,
"varianceRatio": variance_ratio,
}
def orders_to_check():
page = 1
while True:
params = f"page={page}&limit=50"
if MIN_DATE_CREATED:
params += f"&min_date_created={MIN_DATE_CREATED}"
rows = bc("GET", f"/v2/orders?{params}")
if not rows:
return
for row in rows:
if int(row["status_id"]) in FINAL_STATUS_IDS:
yield row
page += 1
def order_transactions(order_id):
return bc("GET", f"/v2/orders/{order_id}/transactions") or []
def ledger_base_amount_for(order_id, transactions):
"""Placeholder for your own ledger lookup.
Wire this to your accounting export or payout report keyed by order_id.
Falls back to summing settled gateway transaction amounts when no
external ledger is configured.
"""
return sum(float(t["amount"]) for t in transactions if t.get("success"))
def flag_order(order_id, result):
note = (
f"FX_VARIANCE: presentment={result['presentmentCurrency']} "
f"settlement={result['settlementCurrency']} "
f"expected={result['expectedBaseAmount']:.2f} "
f"variance={result['variance']:.2f} "
f"ratio={result['varianceRatio']:.4f}"
)
return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
def run():
flagged = 0
for row in orders_to_check():
transactions = order_transactions(row["id"])
order = {
"defaultCurrencyCode": row.get("default_currency_code") or row.get("currency_code"),
"storeDefaultCurrencyCode": row.get("store_default_currency_code") or row.get("currency_code"),
"totalIncTax": row["total_inc_tax"],
"storeDefaultToTransactionalExchangeRate": row.get("store_default_to_transactional_exchange_rate", 1),
"ledgerBaseAmount": ledger_base_amount_for(row["id"], transactions),
}
result = classify_currency_variance(order, TOLERANCE_RATIO)
if not result["isMismatch"]:
continue
log.warning(
"Order #%s currency variance. presentment=%s settlement=%s expected=%.2f variance=%.2f ratio=%.4f. %s",
row["id"], result["presentmentCurrency"], result["settlementCurrency"],
result["expectedBaseAmount"], result["variance"], result["varianceRatio"],
"would flag" if DRY_RUN else "flagging",
)
if not DRY_RUN:
flag_order(row["id"], result)
flagged += 1
log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Flag BigCommerce orders where presentment and settlement currency diverge.
*
* When Multi-Currency is enabled, a shopper can pay in a transactional currency
* (default_currency_code) that differs from the store's base currency
* (store_default_currency_code). BigCommerce records the rate between the two as
* store_default_to_transactional_exchange_rate, but a finance export that reads
* only the face-value total, or a gateway that settles to the bank in a third
* currency at its own rate, can leave the presentment amount, the order total,
* and the settlement amount all disagreeing. This reads each financially final
* order's currency fields, computes the expected base-currency amount, compares
* it against your ledger's recorded amount for that order, and writes an
* FX_VARIANCE note to staff_notes when they disagree by more than a tolerance.
* It never edits total_inc_tax, default_currency_code, or the exchange rate.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/presentment-vs-settlement-currency/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const MIN_DATE_CREATED = process.env.MIN_DATE_CREATED || "";
const TOLERANCE_RATIO = Number(process.env.FX_TOLERANCE_RATIO || 0.005);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const FINAL_STATUS_IDS = new Set([10, 11, 2, 3, 14]);
/**
* Pure decision function. No network calls.
* order: {
* defaultCurrencyCode: string, storeDefaultCurrencyCode: string,
* totalIncTax: number|string, storeDefaultToTransactionalExchangeRate: number|string,
* ledgerBaseAmount: number|string,
* }
*/
export function classifyCurrencyVariance(order, toleranceRatio = 0.005) {
const { defaultCurrencyCode, storeDefaultCurrencyCode, totalIncTax,
storeDefaultToTransactionalExchangeRate, ledgerBaseAmount } = order;
const isMismatch = defaultCurrencyCode !== storeDefaultCurrencyCode;
const total = Number(totalIncTax);
const rate = Number(storeDefaultToTransactionalExchangeRate);
const expectedBaseAmount = isMismatch ? total * rate : total;
const variance = Math.abs(expectedBaseAmount - Number(ledgerBaseAmount));
const varianceRatio = expectedBaseAmount ? variance / expectedBaseAmount : 0;
return {
isMismatch: isMismatch && varianceRatio > toleranceRatio,
presentmentCurrency: defaultCurrencyCode,
settlementCurrency: storeDefaultCurrencyCode,
expectedBaseAmount,
variance,
varianceRatio,
};
}
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function* ordersToCheck() {
let page = 1;
while (true) {
let params = `page=${page}&limit=50`;
if (MIN_DATE_CREATED) params += `&min_date_created=${MIN_DATE_CREATED}`;
const rows = await bc("GET", `/v2/orders?${params}`);
if (!rows || rows.length === 0) return;
for (const row of rows) {
if (FINAL_STATUS_IDS.has(Number(row.status_id))) yield row;
}
page++;
}
}
async function orderTransactions(orderId) {
return (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
}
/**
* Placeholder for your own ledger lookup. Wire this to your accounting
* export or payout report keyed by order_id. Falls back to summing settled
* gateway transaction amounts when no external ledger is configured.
*/
function ledgerBaseAmountFor(orderId, transactions) {
return transactions
.filter((t) => t.success)
.reduce((sum, t) => sum + Number(t.amount), 0);
}
async function flagOrder(orderId, result) {
const note = `FX_VARIANCE: presentment=${result.presentmentCurrency} ` +
`settlement=${result.settlementCurrency} ` +
`expected=${result.expectedBaseAmount.toFixed(2)} ` +
`variance=${result.variance.toFixed(2)} ` +
`ratio=${result.varianceRatio.toFixed(4)}`;
return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}
export async function run() {
let flagged = 0;
for await (const row of ordersToCheck()) {
const transactions = await orderTransactions(row.id);
const order = {
defaultCurrencyCode: row.default_currency_code || row.currency_code,
storeDefaultCurrencyCode: row.store_default_currency_code || row.currency_code,
totalIncTax: row.total_inc_tax,
storeDefaultToTransactionalExchangeRate: row.store_default_to_transactional_exchange_rate || 1,
ledgerBaseAmount: ledgerBaseAmountFor(row.id, transactions),
};
const result = classifyCurrencyVariance(order, TOLERANCE_RATIO);
if (!result.isMismatch) continue;
console.warn(
`Order #${row.id} currency variance. presentment=${result.presentmentCurrency} settlement=${result.settlementCurrency} expected=${result.expectedBaseAmount.toFixed(2)} variance=${result.variance.toFixed(2)} ratio=${result.varianceRatio.toFixed(4)}. ${DRY_RUN ? "would flag" : "flagging"}`
);
if (!DRY_RUN) await flagOrder(row.id, result);
flagged++;
}
console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The variance rule is the part most worth testing, because it decides which orders get flagged for a human to chase against a payout report. Because we kept classify_currency_variance pure, arithmetic and string comparison only, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from find_currency_variance import classify_currency_variance
def order(**over):
base = {
"defaultCurrencyCode": "EUR",
"storeDefaultCurrencyCode": "USD",
"totalIncTax": "100.00",
"storeDefaultToTransactionalExchangeRate": "0.90",
"ledgerBaseAmount": "90.00",
}
base.update(over)
return base
def test_same_currency_is_never_a_mismatch():
result = classify_currency_variance(
order(defaultCurrencyCode="USD", storeDefaultCurrencyCode="USD", ledgerBaseAmount="100.00")
)
assert result["isMismatch"] is False
def test_matching_conversion_within_tolerance():
# 100.00 EUR-presented order at rate 0.90 -> 90.00 USD expected, ledger says 90.00
result = classify_currency_variance(order())
assert result["isMismatch"] is False
assert result["expectedBaseAmount"] == 90.0
assert result["variance"] == 0.0
def test_flags_variance_beyond_tolerance():
# ledger only shows 85.00 against an expected 90.00, a 5.6% gap
result = classify_currency_variance(order(ledgerBaseAmount="85.00"))
assert result["isMismatch"] is True
assert round(result["varianceRatio"], 4) == round(5.0 / 90.0, 4)
def test_within_tolerance_ratio_not_flagged():
# ledger shows 89.70, a 0.33% gap, under the default 0.5% tolerance
result = classify_currency_variance(order(ledgerBaseAmount="89.70"))
assert result["isMismatch"] is False
def test_custom_tolerance_ratio():
result = classify_currency_variance(order(ledgerBaseAmount="89.00"), tolerance_ratio=0.001)
assert result["isMismatch"] is True
def test_presentment_and_settlement_currency_reported():
result = classify_currency_variance(order(ledgerBaseAmount="85.00"))
assert result["presentmentCurrency"] == "EUR"
assert result["settlementCurrency"] == "USD"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCurrencyVariance } from "./find-currency-variance.js";
const order = (over = {}) => ({
defaultCurrencyCode: "EUR",
storeDefaultCurrencyCode: "USD",
totalIncTax: "100.00",
storeDefaultToTransactionalExchangeRate: "0.90",
ledgerBaseAmount: "90.00",
...over,
});
test("same currency is never a mismatch", () => {
const result = classifyCurrencyVariance(
order({ defaultCurrencyCode: "USD", storeDefaultCurrencyCode: "USD", ledgerBaseAmount: "100.00" })
);
assert.equal(result.isMismatch, false);
});
test("matching conversion within tolerance", () => {
const result = classifyCurrencyVariance(order());
assert.equal(result.isMismatch, false);
assert.equal(result.expectedBaseAmount, 90);
assert.equal(result.variance, 0);
});
test("flags variance beyond tolerance", () => {
const result = classifyCurrencyVariance(order({ ledgerBaseAmount: "85.00" }));
assert.equal(result.isMismatch, true);
assert.equal(Number(result.varianceRatio.toFixed(4)), Number((5 / 90).toFixed(4)));
});
test("within tolerance ratio is not flagged", () => {
const result = classifyCurrencyVariance(order({ ledgerBaseAmount: "89.70" }));
assert.equal(result.isMismatch, false);
});
test("custom tolerance ratio", () => {
const result = classifyCurrencyVariance(order({ ledgerBaseAmount: "89.00" }), 0.001);
assert.equal(result.isMismatch, true);
});
test("presentment and settlement currency reported", () => {
const result = classifyCurrencyVariance(order({ ledgerBaseAmount: "85.00" }));
assert.equal(result.presentmentCurrency, "EUR");
assert.equal(result.settlementCurrency, "USD");
});
Case studies
The revenue report that quietly ran in the wrong currency
A home goods brand expanded into the eurozone with Multi-Currency turned on. Their nightly revenue export pulled total_inc_tax straight from every order and summed it, never noticing that a chunk of orders carried default_currency_code of EUR while the rest were USD. Revenue looked inflated some days and understated on others, and nobody could explain the swing until someone compared it against the bank.
Running the reconciliation job once a day flagged every EUR-presented order with its expected USD equivalent computed from store_default_to_transactional_exchange_rate. Finance now reconciles against a clean list instead of chasing a mystery in the topline number.
The payout that never matched the order total
A merchant's payment gateway settled cross-border card payments to the merchant's bank in USD, several days after the order, using its own daily rate rather than the one BigCommerce recorded at checkout. A naive script tried to match each bank deposit line to an order total directly and flagged nearly every foreign-currency order as an error.
Switching to a ratio-based tolerance instead of an exact match, and comparing against the expected base amount rather than the raw order total, cut the false positives to nearly zero. The handful of orders that still triggered a genuine FX_VARIANCE note turned out to be real gateway-side rate drift worth a closer look.
After this runs on a schedule, a gap between what the shopper paid, what the order recorded, and what landed in the bank gets caught within one reconciliation cycle instead of during a confusing month-end close. Every flagged order carries the presentment currency, the settlement currency, the expected base amount, and the variance ratio right in staff_notes, so whoever reviews it starts with the numbers already computed. The order's totals and exchange rate are never touched by the script, so there is no risk of it masking a real FX gain or loss.
FAQ
Why does a BigCommerce order's total not match the amount that lands in my bank?
When Multi-Currency is enabled, a shopper can check out in a transactional currency that differs from the store's base currency, so the order carries default_currency_code alongside store_default_currency_code and store_default_to_transactional_exchange_rate. If an integration reads only the face-value total without applying that exchange rate, or the gateway settles to the bank in a third currency at its own rate and timing, the presentment amount, the order total, and the settlement amount can all disagree.
Should I correct the order total when I find a currency mismatch?
No. BigCommerce's stored total and exchange rate are authoritative for what the shopper was actually charged, so the fix is financial annotation, not data mutation. Write the computed base-currency equivalent and the FX metadata to an external ledger or to the order's staff_notes, and never modify total_inc_tax, default_currency_code, or other financial fields on the order itself.
How do I detect a presentment vs settlement currency mismatch on a BigCommerce order?
Pull orders with GET /v2/orders filtered to financially final status_id values, then compare default_currency_code against store_default_currency_code. When they differ, the order was presented and paid in a non-base currency. Multiply total_inc_tax by store_default_to_transactional_exchange_rate to get the expected base-currency amount, then compare that against the settlement or ledger record for the same order_id, flagging any variance beyond a small tolerance.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Currencies Overview. developer.bigcommerce.com/docs/store-operations/currencies
- BigCommerce Developer Center: How Currencies Work. developer.bigcommerce.com/docs/store-operations/currencies/guide
- BigCommerce Help Center: Managing Currencies. support.bigcommerce.com/s/article/Managing-Currencies
On the solution:
- BigCommerce API Reference: Orders V2 (REST Management). docs.bigcommerce.com/developer/api-reference/rest/admin/management/orders
- BigCommerce Developer Center: Orders (REST Management API reference). developer.bigcommerce.com/api-reference/store-management/orders
- BigCommerce API Reference: Order Transactions. developer.bigcommerce.com/docs/rest-management/transactions
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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.
Did this untangle a reconciliation headache?
If this saved you a confusing audit or a wrong revenue 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