Reconciler Refunds, payouts, and reconciliation
Presentment vs settlement currency
The buyer paid in pounds, or euros, or pesos. Your payout lands in dollars. Both numbers are correct, they just live in different currencies, and the order carries both. The trouble starts when a report, a spreadsheet, or a support reply reads only one side of that pair and treats it as the whole story. Here is why the two amounts differ, and a small script that makes the exchange explicit and flags the orders where it does not add up.
Every Shopify order stores its money twice: presentmentMoney, the amount and currency the buyer saw at checkout, and shopMoney, the amount and currency your store actually settles in. When your store sells internationally with Shopify Payments multi-currency, these two can be different currencies connected by an exchange rate captured at the moment of sale. Run a small Python or Node.js script that reads totalReceivedSet on both sides of recent orders, works out the implied rate in cents, and tags for review any order whose currency pair looks unexpected or whose rate falls outside a sane band. Full code, tests, and a dry run guard are below.
The problem in plain words
When a store sells in more than one currency, Shopify shows the buyer prices in their own currency and lets them pay in it. That is the presentment side, the currency they saw and typed a card number against. But your store still has one home currency, the one your bank account and your payouts are in. That is the settlement side. Shopify records both amounts on the order, along with the exchange rate that connected them at the moment of the sale.
Nothing here is broken by design. The order really was paid in, say, GBP, and it really did settle as USD in your account. The problem shows up later, when someone builds a report, an export, or a support script that only reads one of the two fields and assumes it is the full picture. A total that sums presentmentMoney across orders in different currencies is not a number, it is a mix of currencies pretending to be one. A total that sums shopMoney and calls it "what the customer paid" is technically wrong too, since the customer never saw that number. Either mistake makes your reports quietly disagree with the bank.
Why it happens
Shopify captures both currencies faithfully. The confusion is almost always downstream, in how people or tools read the order afterward. A few common ways stores end up here:
- A finance export sums
presentmentMoneyacross orders placed in several different currencies and treats the result as a single meaningful total. - A support reply or a refund calculation quotes the amount the buyer saw, in their currency, but the refund actually processes in the settlement currency at a different rate than the original sale.
- A custom script or app was written before the store turned on multi-currency, and it still assumes
presentmentMoneyandshopMoneyare always the same number in the same currency. - A currency conversion app misfires or is uninstalled mid-cycle, leaving some orders with a presentment currency that no longer matches what the storefront actually charged.
This is a common source of confusion for stores that expand into new markets. The Admin shows the buyer's currency by default, which is the right thing for a support agent to see, but the wrong thing for a bookkeeper reconciling the payout. Nobody miscounted the money, they just read the currency that answers a different question. See the citations at the end for the exact docs.
The fix is not to prefer one currency field over the other. It is to always read both, and make the exchange between them explicit rather than assumed. If shopMoney is not the currency your store expects to settle in, or the implied rate between the two sides falls outside anything plausible, that order needs a human to look at it, not a script that quietly guesses.
The fix, as a flow
We do not touch how Shopify records the sale. We add a job that reads recent orders, pulls totalReceivedSet on both shopMoney and presentmentMoney, and works out whether the pair looks right. When the settlement currency is not what the store expects, or the implied exchange rate is nowhere close to a real rate, we tag the order for review. Everything that ties out is left alone.
Build it step by step
Get an Admin API access token
Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_orders scope and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.
pip install requests
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SETTLEMENT_CURRENCY="USD"
export REVIEW_TAG="currency-mismatch"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SETTLEMENT_CURRENCY="USD"
export REVIEW_TAG="currency-mismatch"
export DRY_RUN="true" // start safe, change to false to write
Talk to the Admin GraphQL API
Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to page through orders.
import os, requests
SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
def gql(query, variables=None):
r = requests.post(
ENDPOINT,
json={"query": query, "variables": variables or {}},
headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
async function gql(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Shopify ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
List recent orders with both currencies
Ask for orders created within a lookback window, and read back totalReceivedSet with both shopMoney and presentmentMoney expanded, plus the tags. We page through with a cursor so the job handles a busy store.
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id name tags
totalReceivedSet {
shopMoney { amount currencyCode }
presentmentMoney { amount currencyCode }
}
}
}
}"""
def recent_orders(lookback_days):
q = f"created_at:>-{lookback_days}d"
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id name tags
totalReceivedSet {
shopMoney { amount currencyCode }
presentmentMoney { amount currencyCode }
}
}
}
}`;
async function* recentOrders(lookbackDays) {
const q = `created_at:>-${lookbackDays}d`;
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function, in cents
Keep the decision in its own function that takes plain values and returns true or false. Convert both amounts to minor units first, since comparing floating point currency amounts directly invites rounding bugs. The rule flags an order when the settlement currency is not what the store expects, when both sides claim the same currency but the amounts differ, which means a broken conversion, or when the implied rate between presentment and settlement falls outside a band no real exchange rate would sit in.
def to_cents(amount):
return round(float(amount) * 100)
def implied_rate(shop_cents, presentment_cents):
if shop_cents == 0 or presentment_cents == 0:
return None
return presentment_cents / shop_cents
def needs_review(order, settlement_currency, min_rate, max_rate):
totals = order.get("totalReceivedSet") or {}
shop = totals.get("shopMoney") or {}
presentment = totals.get("presentmentMoney") or {}
shop_currency = shop.get("currencyCode")
presentment_currency = presentment.get("currencyCode")
if shop_currency is None or presentment_currency is None:
return False
if shop_currency != settlement_currency:
return True
shop_cents = to_cents(shop.get("amount", "0"))
presentment_cents = to_cents(presentment.get("amount", "0"))
if shop_currency == presentment_currency:
return shop_cents != presentment_cents
rate = implied_rate(shop_cents, presentment_cents)
if rate is None:
return True
return rate < min_rate or rate > max_rate
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function impliedRate(shopCents, presentmentCents) {
if (shopCents === 0 || presentmentCents === 0) return null;
return presentmentCents / shopCents;
}
export function needsReview(order, settlementCurrency, minRate, maxRate) {
const totals = order.totalReceivedSet || {};
const shop = totals.shopMoney || {};
const presentment = totals.presentmentMoney || {};
const shopCurrency = shop.currencyCode;
const presentmentCurrency = presentment.currencyCode;
if (shopCurrency == null || presentmentCurrency == null) return false;
if (shopCurrency !== settlementCurrency) return true;
const shopCents = toCents(shop.amount ?? "0");
const presentmentCents = toCents(presentment.amount ?? "0");
if (shopCurrency === presentmentCurrency) {
return shopCents !== presentmentCents;
}
const rate = impliedRate(shopCents, presentmentCents);
if (rate === null) return true;
return rate < minRate || rate > maxRate;
}
Tag the order for a human, never edit the amount
When an order needs review, the only write we make is tagsAdd. We never touch shopMoney, presentmentMoney, or any total. The tag is a pointer for a human to open the order and look at the two currencies side by side, not an automatic fix.
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def tag_for_review(order_id, review_tag):
result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function tagForReview(orderId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which orders it would flag. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how quickly you want new mismatches surfaced, for example once a day.
Always start with DRY_RUN=true, and remember the only write this script makes is a tag. It never edits an amount, a currency code, or a refund. If the numbers genuinely need correcting, that is a job for a human with the order open, not an automated write.
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 the only thing it ever writes is a review tag.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Flag Shopify orders where the presentment currency and the settlement currency
do not agree in the way your reports expect.
A buyer in the UK sees GBP at checkout (presentmentMoney), but if your store settles
in USD, the money that actually lands in your payout is in USD (shopMoney). Reports
that read the wrong side of that pair, or that mix the two without converting, end up
quietly wrong. This job reads each recent order's totalReceivedSet on both sides,
computes the implied exchange rate, and tags for review any order where the currency
pair looks unexpected or the implied rate falls outside a sane band. Read only apart
from the tag. 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_mismatch")
SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
SETTLEMENT_CURRENCY = os.environ.get("SETTLEMENT_CURRENCY", "USD")
REVIEW_TAG = os.environ.get("REVIEW_TAG", "currency-mismatch")
# An implied rate outside this band usually means a bad read, not a real conversion.
MIN_RATE = float(os.environ.get("MIN_RATE", "0.01"))
MAX_RATE = float(os.environ.get("MAX_RATE", "100"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id name tags
totalReceivedSet {
shopMoney { amount currencyCode }
presentmentMoney { amount currencyCode }
}
}
}
}"""
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def gql(query, variables=None):
r = requests.post(
ENDPOINT,
json={"query": query, "variables": variables or {}},
headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def to_cents(amount):
return round(float(amount) * 100)
def implied_rate(shop_cents, presentment_cents):
"""Presentment currency amount per one unit of settlement currency.
Returns None when either side is zero or missing, since no rate can be implied.
"""
if shop_cents == 0 or presentment_cents == 0:
return None
return presentment_cents / shop_cents
def needs_review(order, settlement_currency, min_rate, max_rate):
"""Pure decision function: does this order need a currency review tag?
Flags an order when:
- the settlement side (shopMoney) is not in the currency the shop expects, or
- both sides share the same currency code but the amounts differ (a broken
conversion, since same currency should mean same amount), or
- the implied rate between presentment and settlement falls outside a sane band.
Takes only plain values, does no I/O, so it is easy to unit test.
"""
totals = order.get("totalReceivedSet") or {}
shop = totals.get("shopMoney") or {}
presentment = totals.get("presentmentMoney") or {}
shop_currency = shop.get("currencyCode")
presentment_currency = presentment.get("currencyCode")
if shop_currency is None or presentment_currency is None:
return False
if shop_currency != settlement_currency:
return True
shop_cents = to_cents(shop.get("amount", "0"))
presentment_cents = to_cents(presentment.get("amount", "0"))
if shop_currency == presentment_currency:
return shop_cents != presentment_cents
rate = implied_rate(shop_cents, presentment_cents)
if rate is None:
return True
return rate < min_rate or rate > max_rate
def recent_orders():
q = f"created_at:>-{LOOKBACK_DAYS}d"
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def tag_for_review(order_id, review_tag):
result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
def run():
flagged = 0
for order in recent_orders():
if not needs_review(order, SETTLEMENT_CURRENCY, MIN_RATE, MAX_RATE):
continue
if REVIEW_TAG in (order.get("tags") or []):
continue
log.warning("Order %s currency pair looks off. %s",
order["name"], "would tag" if DRY_RUN else "tagging")
if not DRY_RUN:
tag_for_review(order["id"], REVIEW_TAG)
flagged += 1
log.info("Done. %d order(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")
if __name__ == "__main__":
run()
/**
* Flag Shopify orders where the presentment currency and the settlement currency
* do not agree in the way your reports expect.
*
* A buyer in the UK sees GBP at checkout (presentmentMoney), but if your store settles
* in USD, the money that actually lands in your payout is in USD (shopMoney). Reports
* that read the wrong side of that pair, or that mix the two without converting, end up
* quietly wrong. This job reads each recent order's totalReceivedSet on both sides,
* computes the implied exchange rate, and tags for review any order where the currency
* pair looks unexpected or the implied rate falls outside a sane band. Run on a schedule.
*
* Guide: https://www.allanninal.dev/shopify/presentment-vs-settlement-currency-shopify/
*/
import { pathToFileURL } from "node:url";
const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const SETTLEMENT_CURRENCY = process.env.SETTLEMENT_CURRENCY || "USD";
const REVIEW_TAG = process.env.REVIEW_TAG || "currency-mismatch";
const MIN_RATE = Number(process.env.MIN_RATE || 0.01);
const MAX_RATE = Number(process.env.MAX_RATE || 100);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function impliedRate(shopCents, presentmentCents) {
if (shopCents === 0 || presentmentCents === 0) return null;
return presentmentCents / shopCents;
}
export function needsReview(order, settlementCurrency, minRate, maxRate) {
const totals = order.totalReceivedSet || {};
const shop = totals.shopMoney || {};
const presentment = totals.presentmentMoney || {};
const shopCurrency = shop.currencyCode;
const presentmentCurrency = presentment.currencyCode;
if (shopCurrency == null || presentmentCurrency == null) return false;
if (shopCurrency !== settlementCurrency) return true;
const shopCents = toCents(shop.amount ?? "0");
const presentmentCents = toCents(presentment.amount ?? "0");
if (shopCurrency === presentmentCurrency) {
return shopCents !== presentmentCents;
}
const rate = impliedRate(shopCents, presentmentCents);
if (rate === null) return true;
return rate < minRate || rate > maxRate;
}
async function gql(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Shopify ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id name tags
totalReceivedSet {
shopMoney { amount currencyCode }
presentmentMoney { amount currencyCode }
}
}
}
}`;
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function* recentOrders() {
const q = `created_at:>-${LOOKBACK_DAYS}d`;
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function tagForReview(orderId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
let flagged = 0;
for await (const order of recentOrders()) {
if (!needsReview(order, SETTLEMENT_CURRENCY, MIN_RATE, MAX_RATE)) continue;
if ((order.tags || []).includes(REVIEW_TAG)) continue;
console.warn(`Order ${order.name} currency pair looks off. ${DRY_RUN ? "would tag" : "tagging"}`);
if (!DRY_RUN) await tagForReview(order.id, REVIEW_TAG);
flagged++;
}
console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to tag" : "tagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders get a human's attention. Because we kept needs_review pure and did all the money math in cents, the test needs no network and no floating point guesswork. It just feeds in plain objects and checks the answer.
from find_currency_mismatch import needs_review, implied_rate, to_cents
def totals(shop_amount, shop_ccy, presentment_amount, presentment_ccy):
return {
"shopMoney": {"amount": shop_amount, "currencyCode": shop_ccy},
"presentmentMoney": {"amount": presentment_amount, "currencyCode": presentment_ccy},
}
def order(total_received, tags=None):
return {"totalReceivedSet": total_received, "tags": tags or []}
def test_to_cents_rounds():
assert to_cents("50.00") == 5000
assert to_cents("9.99") == 999
def test_implied_rate_none_when_shop_side_zero():
assert implied_rate(0, 5000) is None
def test_implied_rate_computes_ratio():
# 100 GBP presentment for 128 USD settlement -> ~0.78 GBP per USD
assert round(implied_rate(12800, 10000), 2) == 0.78
def test_no_review_when_same_currency_and_amounts_match():
o = order(totals("50.00", "USD", "50.00", "USD"))
assert needs_review(o, "USD", 0.01, 100) is False
def test_review_when_settlement_currency_is_not_the_expected_one():
# shop settles in EUR but the store expects USD
o = order(totals("45.00", "EUR", "50.00", "USD"))
assert needs_review(o, "USD", 0.01, 100) is True
def test_review_when_same_currency_but_amounts_differ():
# both say USD but the numbers do not match, a broken conversion
o = order(totals("50.00", "USD", "48.00", "USD"))
assert needs_review(o, "USD", 0.01, 100) is True
def test_no_review_when_rate_is_sane():
# 100 USD settled for 92 EUR presented, plausible FX rate
o = order(totals("100.00", "USD", "92.00", "EUR"))
assert needs_review(o, "USD", 0.01, 100) is False
def test_review_when_rate_is_absurd():
# 1000 USD settled for 1 EUR presented, an obviously broken read
o = order(totals("1000.00", "USD", "1.00", "EUR"))
assert needs_review(o, "USD", 0.01, 100) is True
def test_review_when_presentment_amount_missing():
o = order(totals("100.00", "USD", "0", "EUR"))
assert needs_review(o, "USD", 0.01, 100) is True
def test_no_review_when_currency_fields_missing():
o = order({"shopMoney": {}, "presentmentMoney": {}})
assert needs_review(o, "USD", 0.01, 100) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { needsReview, impliedRate, toCents } from "./find-currency-mismatch.js";
const totals = (shopAmount, shopCcy, presentmentAmount, presentmentCcy) => ({
shopMoney: { amount: shopAmount, currencyCode: shopCcy },
presentmentMoney: { amount: presentmentAmount, currencyCode: presentmentCcy },
});
const order = (totalReceived, tags = []) => ({ totalReceivedSet: totalReceived, tags });
test("toCents rounds", () => {
assert.equal(toCents("50.00"), 5000);
assert.equal(toCents("9.99"), 999);
});
test("impliedRate is null when shop side is zero", () => {
assert.equal(impliedRate(0, 5000), null);
});
test("impliedRate computes ratio", () => {
// 100 GBP presentment for 128 USD settlement -> ~0.78 GBP per USD
assert.equal(Math.round(impliedRate(12800, 10000) * 100) / 100, 0.78);
});
test("no review when same currency and amounts match", () => {
const o = order(totals("50.00", "USD", "50.00", "USD"));
assert.equal(needsReview(o, "USD", 0.01, 100), false);
});
test("review when settlement currency is not the expected one", () => {
const o = order(totals("45.00", "EUR", "50.00", "USD"));
assert.equal(needsReview(o, "USD", 0.01, 100), true);
});
test("review when same currency but amounts differ", () => {
const o = order(totals("50.00", "USD", "48.00", "USD"));
assert.equal(needsReview(o, "USD", 0.01, 100), true);
});
test("no review when rate is sane", () => {
const o = order(totals("100.00", "USD", "92.00", "EUR"));
assert.equal(needsReview(o, "USD", 0.01, 100), false);
});
test("review when rate is absurd", () => {
const o = order(totals("1000.00", "USD", "1.00", "EUR"));
assert.equal(needsReview(o, "USD", 0.01, 100), true);
});
test("review when presentment amount missing", () => {
const o = order(totals("100.00", "USD", "0", "EUR"));
assert.equal(needsReview(o, "USD", 0.01, 100), true);
});
test("no review when currency fields missing", () => {
const o = order({ shopMoney: {}, presentmentMoney: {} });
assert.equal(needsReview(o, "USD", 0.01, 100), false);
});
Case studies
The revenue report that never matched the bank
A skincare brand sold in USD, GBP, and EUR through Shopify Payments multi-currency. Their finance export summed presentmentMoney across every order and called it monthly revenue. The number always missed the actual deposit, because it was adding pounds and euros and dollars together as if they were the same unit.
Switching the export to always read shopMoney, the settlement side, made the report match the bank on the first try. The script here now runs weekly and tags any order where shopMoney is not USD at all, which catches the rare case of a new currency being enabled without finance knowing.
A conversion app hiccup left the rate absurd
A homeware store used a third-party currency conversion app. During a deploy, the app briefly served a broken rate table, and a small batch of EUR orders settled with a shopMoney amount that implied a rate nowhere close to reality.
The nightly job flagged those orders the same day, since the implied rate fell far outside the sane band. Support caught it before a customer complained, and the store fixed the app config rather than discovering the drift weeks later during reconciliation.
After this runs on a schedule, presentment and settlement currency stop being a trap. Every report states which side it reads, the rare order with a genuinely wrong currency pair gets a tag the same day, and reconciling the payout against the bank stops requiring anyone to remember which field means what.
FAQ
What is the difference between presentment currency and settlement currency on Shopify?
Presentment currency is what the buyer saw and paid in at checkout, stored on presentmentMoney. Settlement currency is what actually lands in your payout, stored on shopMoney. With Shopify Payments multi-currency, these can be different currencies on the same order, connected by an exchange rate at the moment of the sale.
Why do my Shopify revenue reports not match my bank deposits?
It usually happens when a report reads presentmentMoney, the currency the buyer saw, while your bank and payouts are in shopMoney, your settlement currency. Reading the wrong side of the pair, or mixing both without converting, makes the totals drift even though every order is correctly recorded.
Is it safe to automate a check for currency mismatches?
Yes, when the check only reads totalReceivedSet on both shopMoney and presentmentMoney, computes the implied exchange rate in minor units, and tags orders for a human to review rather than editing any amount. Run it in dry run first so you can see the exact list before it writes a single tag.
Related field notes
Citations
On the problem:
- Shopify Help Center: sell in multiple currencies with Shopify Payments. help.shopify.com/en/manual/payments/shopify-payments/multi-currency
- Shopify Help Center: how currency conversion and settlement work for international sales. help.shopify.com/en/manual/international/pricing/international-pricing/currency-settlement
- Shopify Community: reports do not match payouts due to presentment currency. community.shopify.com shopify discussions
On the solution:
- Shopify Admin GraphQL: the Order object, including totalReceivedSet. shopify.dev/docs/api/admin-graphql/latest/objects/Order
- Shopify Admin GraphQL: the MoneyBag object, shopMoney and presentmentMoney. shopify.dev/docs/api/admin-graphql/latest/objects/MoneyBag
- Shopify Admin GraphQL: the tagsAdd mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd
Stuck on a tricky one?
If you have a problem in Shopify orders, payments, subscriptions, 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.
Did this clear up your currency reports?
If this saved you a reconciliation headache or a wrong revenue number, 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