Reconciler Refunds, payouts, and reconciliation
Reconcile Shopify orders against payouts
The bank deposit lands, and it never equals the order totals for the day. Fees came out, a refund got subtracted, a few orders from yesterday rode along, and the currencies do not all match. Finance ends up trying to match a single deposit number to a page of orders by eye, and gives up halfway through. Here is why a payout never equals order totals and a small script that ties every payout to its own balance transactions to the cent.
Do not compare a payout to order totals. Compare it to the sum of its own balance transactions. Run a small Python or Node.js script that lists your recent Shopify Payments payouts and every balance transaction, adds up the net amount of the transactions tied to each payout, and tags for review any payout whose own net does not match that sum to the cent. Full code, tests, and a dry run guard are below.
The problem in plain words
An order total is what the buyer paid. A payout is what landed in your bank. They are not the same number, and they were never supposed to be.
Shopify Payments bundles many things into one payout: the captures from several orders, minus any refunds you issued, minus the processing fee, sometimes minus a reserve, sometimes across more than one day. So a $500 payout might represent $560 in captures, a $40 refund, and a $20 fee. Try to check that by adding up order totals in the admin and you will be wrong before you start. The number that should actually match the payout is the sum of the balance transactions Shopify recorded against it, not the orders themselves.
Why it happens
Shopify Payments settles money in batches, not order by order, and the numbers involved come from a few different places:
- A payout covers whatever captures and refunds settled in that payout window, which can be a day, or span a weekend, so the orders inside it are not a clean one to one list.
- The processing fee is subtracted before the money ever reaches your bank, so the payout is always smaller than the sum of what buyers paid.
- A refund issued against an old order reduces a later payout, so the payout you are looking at today can be pulling down money earned from an order shipped weeks ago.
- Multi-currency stores settle in one currency while orders were placed in another, so a naive sum of order totals will not even share the same unit as the payout.
The Admin gives you a payout report, and it does list the balance transactions behind each payout, but nobody wants to open every payout by hand and add up rows to confirm they were reported correctly. The safe pattern is to let Shopify's own numbers check themselves: sum the balance transactions tied to a payout and compare that sum to the payout total, in cents, not against the order totals directly. See the citations at the end for the exact docs.
You do not reconcile a payout against orders. You reconcile it against its own balance transactions, because those already carry the fee, the refund, and the currency Shopify actually used to build that payout. If the sum of a payout's transactions does not equal the payout's own net amount, something is missing or wrong, and that is worth a human's attention before the books get signed off.
The fix, as a flow
We do not touch money. We add a job that lists recent payouts and every balance transaction, groups the transactions by the payout they belong to, sums each group in cents, and compares that sum to the payout's own net amount. Anything within a cent of tolerance is fine. Anything past that gets a review tag so a human can look at exactly that payout, never a guess across the whole ledger.
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_shopify_payments and read_shopify_payments_accounts scopes, plus write_orders if you want the review tag written to the order later, 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 REVIEW_TAG="payout-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 REVIEW_TAG="payout-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 read payouts, to read balance transactions, and to run the tag mutation.
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 payouts and every balance transaction
Read the payouts under shopifyPaymentsAccount.payouts, sorted by issue date, newest first. Separately, page through shopifyPaymentsAccount.balanceTransactions, which returns every charge, refund, fee, and adjustment along with the payout it settled in through associatedPayout. We page with a cursor so the job handles a large ledger.
PAYOUTS_QUERY = """
query($first: Int!) {
shopifyPaymentsAccount {
payouts(first: $first, sortKey: ISSUED_AT, reverse: true) {
nodes { id status issuedAt net { amount currencyCode } }
}
}
}"""
BALANCE_TRANSACTIONS_QUERY = """
query($cursor: String) {
shopifyPaymentsAccount {
balanceTransactions(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
associatedPayout { id }
associatedOrder { id name }
net { amount currencyCode }
}
}
}
}"""
def recent_payouts(first):
data = gql(PAYOUTS_QUERY, {"first": first})["shopifyPaymentsAccount"]
return data["payouts"]["nodes"]
def all_balance_transactions():
cursor, out = None, []
while True:
data = gql(BALANCE_TRANSACTIONS_QUERY, {"cursor": cursor})["shopifyPaymentsAccount"]
page = data["balanceTransactions"]
out.extend(page["nodes"])
if not page["pageInfo"]["hasNextPage"]:
return out
cursor = page["pageInfo"]["endCursor"]
const PAYOUTS_QUERY = `
query($first: Int!) {
shopifyPaymentsAccount {
payouts(first: $first, sortKey: ISSUED_AT, reverse: true) {
nodes { id status issuedAt net { amount currencyCode } }
}
}
}`;
const BALANCE_TRANSACTIONS_QUERY = `
query($cursor: String) {
shopifyPaymentsAccount {
balanceTransactions(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
associatedPayout { id }
associatedOrder { id name }
net { amount currencyCode }
}
}
}
}`;
async function recentPayouts(first) {
const data = (await gql(PAYOUTS_QUERY, { first })).shopifyPaymentsAccount;
return data.payouts.nodes;
}
async function allBalanceTransactions() {
let cursor = null;
const out = [];
while (true) {
const data = (await gql(BALANCE_TRANSACTIONS_QUERY, { cursor })).shopifyPaymentsAccount;
const page = data.balanceTransactions;
out.push(...page.nodes);
if (!page.pageInfo.hasNextPage) return out;
cursor = page.pageInfo.endCursor;
}
}
Decide, with a pure function that works in cents
Keep the decision in its own function that takes a payout and the list of balance transactions and returns the gap between them, in minor units. Money math in floating point drifts, so every amount is converted to integer cents before it is summed or compared. A pure function like this is easy to read and easy to test, which we do later. The rule only sums transactions whose associatedPayout.id matches the payout in question, and flags a gap larger than a one cent tolerance.
TOLERANCE_CENTS = 1
def to_cents(amount):
return round(float(amount) * 100)
def net_transactions_cents(transactions, payout_id):
total = 0
for t in transactions or []:
payout = t.get("associatedPayout") or {}
if payout.get("id") != payout_id:
continue
total += to_cents(t["net"]["amount"])
return total
def payout_mismatch_cents(payout, transactions):
payout_net = to_cents(payout["net"]["amount"])
summed_net = net_transactions_cents(transactions, payout["id"])
return payout_net - summed_net
def is_mismatch(payout, transactions, tolerance_cents=TOLERANCE_CENTS):
return abs(payout_mismatch_cents(payout, transactions)) > tolerance_cents
const TOLERANCE_CENTS = 1;
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function netTransactionsCents(transactions, payoutId) {
let total = 0;
for (const t of transactions || []) {
if ((t.associatedPayout || {}).id !== payoutId) continue;
total += toCents(t.net.amount);
}
return total;
}
export function payoutMismatchCents(payout, transactions) {
const payoutNet = toCents(payout.net.amount);
const summedNet = netTransactionsCents(transactions, payout.id);
return payoutNet - summedNet;
}
export function isMismatch(payout, transactions, toleranceCents = TOLERANCE_CENTS) {
return Math.abs(payoutMismatchCents(payout, transactions)) > toleranceCents;
}
Tag the payouts that do not tie out
When a payout is a mismatch, add a review tag with the tagsAdd mutation. This never edits an order or moves money, it only marks the payout so a human can open it and see which balance transaction is missing or wrong. Always read back userErrors. If Shopify refuses, the error tells you why, and the script should stop on it rather than pretend it worked.
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def tag_for_review(payout_id, review_tag):
result = gql(TAGS_ADD, {"id": payout_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(payoutId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: payoutId, 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 payouts it would tag and by how many cents. Read the output, agree with it, then switch it off to let it write the review tag. Run it on a schedule that matches your payout cadence, for example once a day.
Always start with DRY_RUN=true. This script never edits an order and never issues a refund, its only write is a review tag on a payout, but you still want to read the flagged list yourself before trusting it in a busy month end close.
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 its only write is a review tag on the payouts that do not tie out.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Tie a Shopify Payments payout to its balance transactions to the cent.
A bank deposit is one number. The orders behind it are many. When the sum of
a payout's balance transactions (charges minus refunds minus fees, in other
words `net`) does not equal the payout's own `net` amount, either a balance
transaction is missing from the page you fetched, a currency got mixed in, or
Shopify's own numbers disagree, and finance will chase the gap by hand. This
script pages through recent payouts, sums the `net` of every balance
transaction associated with each one, and tags the payouts that do not tie
out for review with `tagsAdd`. 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("reconcile_payout_orders")
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"
PAYOUT_LOOKBACK = int(os.environ.get("PAYOUT_LOOKBACK", "10"))
REVIEW_TAG = os.environ.get("REVIEW_TAG", "payout-mismatch")
TOLERANCE_CENTS = int(os.environ.get("TOLERANCE_CENTS", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAYOUTS_QUERY = """
query($first: Int!) {
shopifyPaymentsAccount {
payouts(first: $first, sortKey: ISSUED_AT, reverse: true) {
nodes {
id
status
issuedAt
net { amount currencyCode }
}
}
}
}"""
BALANCE_TRANSACTIONS_QUERY = """
query($cursor: String) {
shopifyPaymentsAccount {
balanceTransactions(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
associatedPayout { id }
associatedOrder { id name }
net { 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 net_transactions_cents(transactions, payout_id):
"""Sum the net of every balance transaction associated with this payout, in minor units."""
total = 0
for t in transactions or []:
payout = t.get("associatedPayout") or {}
if payout.get("id") != payout_id:
continue
total += to_cents(t["net"]["amount"])
return total
def payout_mismatch_cents(payout, transactions):
"""Return the gap in cents between a payout's own net and its summed transactions.
A positive number means the payout reports more than the transactions add up to.
A negative number means the transactions add up to more than the payout reports.
"""
payout_net = to_cents(payout["net"]["amount"])
summed_net = net_transactions_cents(transactions, payout["id"])
return payout_net - summed_net
def is_mismatch(payout, transactions, tolerance_cents=TOLERANCE_CENTS):
return abs(payout_mismatch_cents(payout, transactions)) > tolerance_cents
def tag_for_review(payout_id, review_tag):
result = gql(TAGS_ADD, {"id": payout_id, "tags": [review_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
def recent_payouts():
data = gql(PAYOUTS_QUERY, {"first": PAYOUT_LOOKBACK})["shopifyPaymentsAccount"]
return data["payouts"]["nodes"]
def all_balance_transactions():
cursor = None
out = []
while True:
data = gql(BALANCE_TRANSACTIONS_QUERY, {"cursor": cursor})["shopifyPaymentsAccount"]
page = data["balanceTransactions"]
out.extend(page["nodes"])
if not page["pageInfo"]["hasNextPage"]:
return out
cursor = page["pageInfo"]["endCursor"]
def run():
payouts = recent_payouts()
transactions = all_balance_transactions()
flagged = 0
for payout in payouts:
gap = payout_mismatch_cents(payout, transactions)
if abs(gap) <= TOLERANCE_CENTS:
continue
log.warning(
"Payout %s off by %s cents. %s",
payout["id"], gap, "would tag" if DRY_RUN else "tagging",
)
if not DRY_RUN:
tag_for_review(payout["id"], REVIEW_TAG)
flagged += 1
log.info("Done. %d payout(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")
if __name__ == "__main__":
run()
/**
* Tie a Shopify Payments payout to its balance transactions to the cent.
*
* A bank deposit is one number. The orders behind it are many. When the sum of
* a payout's balance transactions (charges minus refunds minus fees, in other
* words `net`) does not equal the payout's own `net` amount, either a balance
* transaction is missing from the page you fetched, a currency got mixed in, or
* Shopify's own numbers disagree, and finance will chase the gap by hand. This
* script pages through recent payouts, sums the `net` of every balance
* transaction associated with each one, and tags the payouts that do not tie
* out for review with tagsAdd. Run on a schedule.
*/
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 PAYOUT_LOOKBACK = Number(process.env.PAYOUT_LOOKBACK || 10);
const REVIEW_TAG = process.env.REVIEW_TAG || "payout-mismatch";
const TOLERANCE_CENTS = Number(process.env.TOLERANCE_CENTS || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function netTransactionsCents(transactions, payoutId) {
let total = 0;
for (const t of transactions || []) {
if ((t.associatedPayout || {}).id !== payoutId) continue;
total += toCents(t.net.amount);
}
return total;
}
export function payoutMismatchCents(payout, transactions) {
const payoutNet = toCents(payout.net.amount);
const summedNet = netTransactionsCents(transactions, payout.id);
return payoutNet - summedNet;
}
export function isMismatch(payout, transactions, toleranceCents = TOLERANCE_CENTS) {
return Math.abs(payoutMismatchCents(payout, transactions)) > toleranceCents;
}
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 PAYOUTS_QUERY = `
query($first: Int!) {
shopifyPaymentsAccount {
payouts(first: $first, sortKey: ISSUED_AT, reverse: true) {
nodes {
id
status
issuedAt
net { amount currencyCode }
}
}
}
}`;
const BALANCE_TRANSACTIONS_QUERY = `
query($cursor: String) {
shopifyPaymentsAccount {
balanceTransactions(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
associatedPayout { id }
associatedOrder { id name }
net { amount currencyCode }
}
}
}
}`;
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function recentPayouts() {
const data = (await gql(PAYOUTS_QUERY, { first: PAYOUT_LOOKBACK })).shopifyPaymentsAccount;
return data.payouts.nodes;
}
async function allBalanceTransactions() {
let cursor = null;
const out = [];
while (true) {
const data = (await gql(BALANCE_TRANSACTIONS_QUERY, { cursor })).shopifyPaymentsAccount;
const page = data.balanceTransactions;
out.push(...page.nodes);
if (!page.pageInfo.hasNextPage) return out;
cursor = page.pageInfo.endCursor;
}
}
async function tagForReview(payoutId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: payoutId, tags: [reviewTag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
const payouts = await recentPayouts();
const transactions = await allBalanceTransactions();
let flagged = 0;
for (const payout of payouts) {
const gap = payoutMismatchCents(payout, transactions);
if (Math.abs(gap) <= TOLERANCE_CENTS) continue;
console.warn(`Payout ${payout.id} off by ${gap} cents. ${DRY_RUN ? "would tag" : "tagging"}`);
if (!DRY_RUN) await tagForReview(payout.id, REVIEW_TAG);
flagged++;
}
console.log(`Done. ${flagged} payout(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 real payouts get flagged for finance to chase. Because we kept the math in payout_mismatch_cents pure and in integer cents, the test needs no network and no Shopify account. It just feeds in plain objects and checks the gap.
from reconcile_payout_orders import (
to_cents,
net_transactions_cents,
payout_mismatch_cents,
is_mismatch,
)
def payout(net, payout_id="gid://shopify/ShopifyPaymentsPayout/1"):
return {"id": payout_id, "status": "PAID", "net": {"amount": net, "currencyCode": "USD"}}
def txn(net, payout_id="gid://shopify/ShopifyPaymentsPayout/1", order_name="#1001"):
return {
"associatedPayout": {"id": payout_id},
"associatedOrder": {"id": "gid://shopify/Order/1", "name": order_name},
"net": {"amount": net, "currencyCode": "USD"},
}
def test_to_cents_rounds():
assert to_cents("50.00") == 5000
assert to_cents("9.99") == 999
def test_net_transactions_sums_only_matching_payout():
transactions = [txn("40.00"), txn("10.00"), txn("5.00", payout_id="other")]
assert net_transactions_cents(transactions, "gid://shopify/ShopifyPaymentsPayout/1") == 5000
def test_no_mismatch_when_balanced():
p = payout("50.00")
transactions = [txn("30.00"), txn("20.00")]
assert payout_mismatch_cents(p, transactions) == 0
assert is_mismatch(p, transactions) is False
def test_mismatch_when_transaction_missing():
p = payout("50.00")
transactions = [txn("30.00")]
assert payout_mismatch_cents(p, transactions) == 2000
assert is_mismatch(p, transactions) is True
def test_mismatch_when_transactions_overshoot():
p = payout("50.00")
transactions = [txn("30.00"), txn("30.00")]
assert payout_mismatch_cents(p, transactions) == -1000
assert is_mismatch(p, transactions) is True
def test_within_tolerance_is_not_a_mismatch():
p = payout("50.00")
transactions = [txn("49.99")]
assert is_mismatch(p, transactions) is False
def test_transactions_from_other_payouts_are_ignored():
p = payout("50.00")
transactions = [txn("50.00"), txn("999.00", payout_id="gid://shopify/ShopifyPaymentsPayout/2")]
assert is_mismatch(p, transactions) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import {
toCents,
netTransactionsCents,
payoutMismatchCents,
isMismatch,
} from "./reconcile-payout-orders.js";
const payout = (net, payoutId = "gid://shopify/ShopifyPaymentsPayout/1") => ({
id: payoutId, status: "PAID", net: { amount: net, currencyCode: "USD" },
});
const txn = (net, { payoutId = "gid://shopify/ShopifyPaymentsPayout/1", orderName = "#1001" } = {}) => ({
associatedPayout: { id: payoutId },
associatedOrder: { id: "gid://shopify/Order/1", name: orderName },
net: { amount: net, currencyCode: "USD" },
});
test("toCents rounds", () => {
assert.equal(toCents("50.00"), 5000);
assert.equal(toCents("9.99"), 999);
});
test("netTransactionsCents sums only matching payout", () => {
const transactions = [txn("40.00"), txn("10.00"), txn("5.00", { payoutId: "other" })];
assert.equal(netTransactionsCents(transactions, "gid://shopify/ShopifyPaymentsPayout/1"), 5000);
});
test("no mismatch when balanced", () => {
const p = payout("50.00");
const transactions = [txn("30.00"), txn("20.00")];
assert.equal(payoutMismatchCents(p, transactions), 0);
assert.equal(isMismatch(p, transactions), false);
});
test("mismatch when transaction missing", () => {
const p = payout("50.00");
const transactions = [txn("30.00")];
assert.equal(payoutMismatchCents(p, transactions), 2000);
assert.equal(isMismatch(p, transactions), true);
});
test("mismatch when transactions overshoot", () => {
const p = payout("50.00");
const transactions = [txn("30.00"), txn("30.00")];
assert.equal(payoutMismatchCents(p, transactions), -1000);
assert.equal(isMismatch(p, transactions), true);
});
test("within tolerance is not a mismatch", () => {
const p = payout("50.00");
const transactions = [txn("49.99")];
assert.equal(isMismatch(p, transactions), false);
});
test("transactions from other payouts are ignored", () => {
const p = payout("50.00");
const transactions = [txn("50.00"), txn("999.00", { payoutId: "gid://shopify/ShopifyPaymentsPayout/2" })];
assert.equal(isMismatch(p, transactions), false);
});
Case studies
The store that thought a payout was short
A skincare brand's bookkeeper compared each weekly payout to that week's order total and kept finding a gap of a few hundred dollars. She assumed Shopify was skimming extra fees and opened a support ticket every month, which always came back closed with no change made.
Once the script summed each payout's own balance transactions instead of the week's orders, the numbers tied out every time. The gap had only ever been the processing fee and a couple of older refunds landing in that week's payout, both of which were already accounted for correctly.
The multi-currency store with one payout that would not balance
A store selling in three currencies had one payout each month that genuinely would not reconcile, off by exactly the amount of a refund that had been issued through a support tool outside the usual flow, which never generated a normal balance transaction row.
The script flagged that one payout for review the same day it settled, months before it would have surfaced in a manual month end close. Support traced the refund, confirmed it, and closed the books with an explained gap instead of an unexplained one.
After this runs on a schedule, every payout either ties out silently or gets a review tag the same day it settles, with the exact cent gap already computed. Finance stops eyeballing deposits against order lists, and the one payout a quarter that genuinely does not balance gets caught while the trail is still warm instead of at month end close.
FAQ
Why does a Shopify payout never match my order totals?
A payout is a net bank deposit. It already has fees taken out, refunds subtracted, and it can span several days of orders bundled into one transfer. Comparing a payout straight to a list of order totals will almost never match. You have to compare it to the sum of its own balance transactions instead.
Is it safe to reconcile payouts with a script?
Yes, when the script only reads payouts and balance transactions and its only write is a review tag on the payout that does not tie out. It never edits an order, never issues a refund, and never touches money. Run it in dry run first so you can see exactly which payouts it would flag.
What is a Shopify Payments balance transaction?
A balance transaction is a single line in your Shopify Payments ledger, a charge, a refund, a fee, or an adjustment, each with its own net amount and a link to the payout it settled in. Summing the net of every balance transaction tied to one payout should equal that payout's own net amount.
Related field notes
Citations
On the problem:
- Shopify Help Center: how payouts work, fees, and payout schedules for Shopify Payments. help.shopify.com/en/manual/payments/shopify-payments/payout-schedule
- Shopify Help Center: viewing your balance and understanding a payout's transactions. help.shopify.com/en/manual/reports/report-types/finances-reports
- Shopify Community: why a payout amount does not match the sum of order totals. community.shopify.com payments shipping fulfillment
On the solution:
- Shopify Admin GraphQL: the
shopifyPaymentsAccountquery, includingpayoutsandbalanceTransactions. shopify.dev/docs/api/admin-graphql/latest/queries/shopifyPaymentsAccount - Shopify Admin GraphQL: the
ShopifyPaymentsBalanceTransactionobject, includingassociatedPayoutandnet. shopify.dev/docs/api/admin-graphql/latest/objects/ShopifyPaymentsBalanceTransaction - Shopify Admin GraphQL: the
tagsAddmutation used to flag a payout for review. 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 settle your books?
If this saved you a month end spent chasing a bank deposit by hand, 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