Diagnostic Payments & Transactions
totalBalance drifts after refund or authorization adjustment
Finance opens an order and the numbers do not add up. The order shows a partial refund, the amount captured looks right on its own, but totalBalance, the figure everyone reads as "what is still owed or owing," does not match what total, totalCaptured, and totalRefunded would suggest if you did the arithmetic by hand. Nothing errored. No mutation failed. A partial refund or an authorization adjustment simply left the transaction ledger telling two different stories at once, and Saleor reported the derived balance from whichever story it had on hand at read time. Here is why that gap opens up and a script that finds every order where it has, without correcting a single value until a human says so.
totalBalance is not stored as its own fact. It is derived from the TransactionItem events that exist on the order at the moment you read it: total.gross.amount against the sum of chargedAmount, refundedAmount, and authorizedAmount across every transaction. A partial refund that reports a REFUND_SUCCESS event without a matching correction to chargedAmount, or an authorization adjustment that changes authorizedAmount without a follow-up capture or void event, leaves those underlying fields disagreeing with each other. Saleor does not detect that disagreement on its own, it just reports whatever the fields say. Run a small Python or Node.js script that pages through recently modified orders, recomputes the expected balance from total, totalCaptured, and totalRefunded, compares it against the reported totalBalance, and reports every order where the two disagree for finance to review. Full code, tests, and a dry run guard are below.
The problem in plain words
An order's balance is supposed to answer one question: after every charge and every refund, how much money is still outstanding, or how much is still owed back to the customer. Saleor does not keep a single running number for that. It computes totalBalance on the fly from total.gross.amount, totalCaptured.amount, and totalRefunded.amount, which are themselves rolled up from every TransactionItem attached to the order.
That works cleanly as long as every event on every transaction keeps the rollup fields consistent with each other. It stops working the moment one event updates part of the picture and not the rest. A partial refund is a common way in. The payment app reports a REFUND_SUCCESS event, Saleor adds it to totalRefunded, but if the app never sends a follow-up event correcting the transaction's own chargedAmount down to match, totalCaptured keeps reporting the pre-refund figure. An authorization adjustment does something similar from the other side: the gateway raises or lowers authorizedAmount on an existing authorization, but no capture or void event ever arrives to reconcile it against what was actually charged. Either way, the fields that feed totalBalance stop agreeing with each other, and the number everyone reads as "what is still owed" quietly becomes wrong.
Why it happens
totalBalanceis not a stored fact. It is computed at read time fromtotal.gross.amount,totalCaptured.amount, andtotalRefunded.amount, which are themselves rollups across everyTransactionItemon the order.- A partial refund processed by a payment app can report a
REFUND_SUCCESSevent that increasestotalRefunded, but if the app never sends a follow-up event correcting that transaction's ownchargedAmountdownward,totalCapturedkeeps reporting the pre-refund figure. - An authorization adjustment from the gateway can raise or lower
authorizedAmounton an existing authorization without any accompanying capture or void event, so the amount Saleor believes is authorized no longer lines up with what was actually charged or released. - A webhook that updates one
TransactionItemfield while a related field on the same or a sibling transaction stays stale leaves the aggregate inconsistent, even though every individual event looked valid when it arrived. - There is no reconciliation check anywhere in order processing that verifies
totalCaptured,totalRefunded, andauthorizedAmountstay consistent with each other after a partial event. Saleor reports the derived balance from whatever the fields say at that moment.
None of this throws an error or fails a mutation. The order simply reports a balance that does not reconcile with the rest of its own numbers, and finance has no way to tell from the admin alone which field is the one that drifted. See the citations at the end for the exact GitHub issues and the discussion on this gap.
The signature that matters is not "the balance looks off." It is a specific arithmetic mismatch: total.gross.amount minus totalCaptured.amount plus totalRefunded.amount does not equal the reported totalBalance.amount, outside a small rounding epsilon. That is different from an order that is simply mid-refund or mid-capture and genuinely has a nonzero balance for a legitimate reason. So the fix is not "any nonzero balance is a bug." It is "recompute the expected balance from the same three numbers Saleor itself derives it from, and only flag the orders where the arithmetic does not close."
The fix, as a flow
The script runs on a schedule. It pages through orders, reads each order's total, totalCaptured, totalRefunded, and the reported totalBalance, and hands the numbers to a single pure function that decides whether the order is OK or BALANCE_DRIFTED. Because correcting a ledger is never something a script should do blindly, the default action for every drifted order is a report row for finance, not a write. A correcting transaction event is only ever recorded behind a DRY_RUN guard, and only with a human sign-off logged first.
Build it step by step
Get an app token with order and payment read access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and payments. If you also intend to run the guarded correction step, it needs permission to manage payments too, since it calls transactionEventReport. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" # start safe, this script never writes without it off
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" // start safe, this script never writes without it off
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
import os, requests
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {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 API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
Page through orders and read the fields the decision needs
Ask for orders(first, after) and read back id, number, total.gross.amount, totalCaptured.amount, totalRefunded.amount, and the reported totalBalance.amount. Page with a cursor so the job handles a large order history without loading it all at once.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
total { gross { amount } }
totalCaptured { amount }
totalRefunded { amount }
totalBalance { amount }
}
}
}
}"""
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
total { gross { amount } }
totalCaptured { amount }
totalRefunded { amount }
totalBalance { amount }
}
}
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes total, totalCaptured, totalRefunded, and the reported totalBalance, and returns a status plus the expected balance and how far off the reported one is. A pure function like this is easy to read and test, which we do later. It recomputes the expected balance as total - totalCaptured + totalRefunded. If that matches the reported totalBalance within a small epsilon for floating point rounding, the order is OK. Otherwise it is BALANCE_DRIFTED, with the exact driftedBy amount so finance knows how far off the ledger is before they go looking for the missing event.
EPSILON = 0.01
def classify_balance_drift(total, total_captured, total_refunded, reported_balance):
expected_balance = round(total - total_captured + total_refunded, 2)
drifted_by = round(reported_balance - expected_balance, 2)
if abs(drifted_by) <= EPSILON:
return {
"status": "OK",
"expectedBalance": expected_balance,
"reportedBalance": reported_balance,
"driftedBy": 0,
}
return {
"status": "BALANCE_DRIFTED",
"expectedBalance": expected_balance,
"reportedBalance": reported_balance,
"driftedBy": drifted_by,
}
const EPSILON = 0.01;
export function classifyBalanceDrift(total, totalCaptured, totalRefunded, reportedBalance) {
const expectedBalance = Math.round((total - totalCaptured + totalRefunded) * 100) / 100;
const driftedBy = Math.round((reportedBalance - expectedBalance) * 100) / 100;
if (Math.abs(driftedBy) <= EPSILON) {
return { status: "OK", expectedBalance, reportedBalance, driftedBy: 0 };
}
return { status: "BALANCE_DRIFTED", expectedBalance, reportedBalance, driftedBy };
}
Report first, correct only with a human sign-off
When the decision is BALANCE_DRIFTED, the default action is a report row: {orderId, orderNumber, expectedBalance, reportedBalance, driftedBy}, for finance to review, since the missing piece could be a refund event that never arrived, an authorization adjustment that never got a follow-up capture, or something else entirely that only a human looking at the raw transaction events can identify. If a corrective event is authorized, it is recorded with transactionEventReport against the specific transaction a human identified, gated behind DRY_RUN, and it is never issued without a sign-off logged first.
REPORT_EVENT = """
mutation($id: ID!, $type: TransactionEventTypeEnum!, $amount: Decimal!) {
transactionEventReport(id: $id, type: $type, amount: $amount) {
transaction { id }
errors { field message code }
}
}"""
def record_correcting_event(transaction_id, event_type, amount):
result = gql(REPORT_EVENT, {"id": transaction_id, "type": event_type, "amount": amount})["transactionEventReport"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["transaction"]["id"]
const REPORT_EVENT = `
mutation($id: ID!, $type: TransactionEventTypeEnum!, $amount: Decimal!) {
transactionEventReport(id: $id, type: $type, amount: $amount) {
transaction { id }
errors { field message code }
}
}`;
async function recordCorrectingEvent(transactionId, eventType, amount) {
const result = (await gql(REPORT_EVENT, { id: transactionId, type: eventType, amount })).transactionEventReport;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.transaction.id;
}
Wire it together with a dry run guard
The loop ties every piece together. Under DRY_RUN=true, the default, the script only logs the report row for every drifted order. It never calls transactionEventReport in this mode. Recording a correcting event is a separate, explicitly authorized path that takes the transaction id, the event type, and the amount a human decided on after reading the raw events, gated behind DRY_RUN=false, and even then the script re-queries the order afterward to confirm the balance now reconciles before treating it as resolved.
Always start with DRY_RUN=true and read the report before authorizing anything. Recording a transaction event changes what the ledger says happened, and guessing at the correction without reading the actual events on that specific order can turn one drift into two. Never call transactionEventReport without a human sign-off logged for that specific order.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through orders, classifies each with the pure function, always logs a report row for anything drifted, and only ever calls the correction mutation under an explicit DRY_RUN=false for a signed-off order.
"""Find Saleor orders where totalBalance no longer reconciles with
total, totalCaptured, and totalRefunded, because totalBalance is derived
at read time from whatever TransactionItem events happen to exist, and a
partial refund or an authorization adjustment can update one field
without a matching correction to the others (see saleor/saleor#12297,
saleor/saleor#11445, discussion #15458).
Under DRY_RUN=true (the default) this script only reports drifted
orders, it never writes a correcting event. A correction is only ever
recorded when DRY_RUN=false, and only after a human has signed off on
the specific transaction, event type, and amount, since the right fix
depends on reading the actual raw events. 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("flag_balance_drift")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EPSILON = 0.01
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
total { gross { amount } }
totalCaptured { amount }
totalRefunded { amount }
totalBalance { amount }
}
}
}
}"""
REPORT_EVENT = """
mutation($id: ID!, $type: TransactionEventTypeEnum!, $amount: Decimal!) {
transactionEventReport(id: $id, type: $type, amount: $amount) {
transaction { id }
errors { field message code }
}
}"""
ORDER_BALANCE_CHECK = """
query($id: ID!) {
order(id: $id) {
id
total { gross { amount } }
totalCaptured { amount }
totalRefunded { amount }
totalBalance { amount }
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {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 classify_balance_drift(total, total_captured, total_refunded, reported_balance):
"""Pure decision function. No I/O.
All amounts are plain numbers, the same units Saleor reports them in.
Returns {"status": "OK"|"BALANCE_DRIFTED", "expectedBalance", "reportedBalance", "driftedBy"}.
"""
expected_balance = round(total - total_captured + total_refunded, 2)
drifted_by = round(reported_balance - expected_balance, 2)
if abs(drifted_by) <= EPSILON:
return {
"status": "OK",
"expectedBalance": expected_balance,
"reportedBalance": reported_balance,
"driftedBy": 0,
}
return {
"status": "BALANCE_DRIFTED",
"expectedBalance": expected_balance,
"reportedBalance": reported_balance,
"driftedBy": drifted_by,
}
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def record_correcting_event(transaction_id, event_type, amount):
result = gql(REPORT_EVENT, {"id": transaction_id, "type": event_type, "amount": amount})["transactionEventReport"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["transaction"]["id"]
def confirm_reconciled(order_id):
order = gql(ORDER_BALANCE_CHECK, {"id": order_id})["order"]
result = classify_balance_drift(
order["total"]["gross"]["amount"],
order["totalCaptured"]["amount"],
order["totalRefunded"]["amount"],
order["totalBalance"]["amount"],
)
return result["status"] == "OK"
def to_plain(node):
return {
"id": node["id"],
"number": node["number"],
"total": node["total"]["gross"]["amount"],
"totalCaptured": node["totalCaptured"]["amount"],
"totalRefunded": node["totalRefunded"]["amount"],
"totalBalance": node["totalBalance"]["amount"],
}
def run(pending_corrections=None):
"""pending_corrections maps orderId -> (transactionId, eventType, amount),
populated only from a signed-off review, never guessed by the script."""
pending_corrections = pending_corrections or {}
reported = 0
corrected = 0
for node in all_orders():
order = to_plain(node)
result = classify_balance_drift(
order["total"], order["totalCaptured"], order["totalRefunded"], order["totalBalance"]
)
if result["status"] == "OK":
continue
report_row = {
"orderId": order["id"],
"orderNumber": order["number"],
"expectedBalance": result["expectedBalance"],
"reportedBalance": result["reportedBalance"],
"driftedBy": result["driftedBy"],
}
log.warning("Balance-drifted order found for finance review: %s", report_row)
reported += 1
# Correcting the ledger is never automatic. This branch only ever runs
# for an order a human has signed off on, with the exact transaction,
# event type, and amount they decided on after reading the raw events.
correction = pending_corrections.get(order["id"])
if not DRY_RUN and correction:
transaction_id, event_type, amount = correction
log.info("Recording correcting event on transaction %s (signed off).", transaction_id)
record_correcting_event(transaction_id, event_type, amount)
if confirm_reconciled(order["id"]):
log.info("Order %s reconciled. totalBalance now matches.", order["number"])
else:
log.error("Order %s still drifted after correction. Needs manual review.", order["number"])
corrected += 1
log.info("Done. %d order(s) reported drifted, %d correction(s) recorded.", reported, corrected)
if __name__ == "__main__":
run()
/**
* Find Saleor orders where totalBalance no longer reconciles with total,
* totalCaptured, and totalRefunded, because totalBalance is derived at
* read time from whatever TransactionItem events happen to exist, and a
* partial refund or an authorization adjustment can update one field
* without a matching correction to the others (see saleor/saleor#12297,
* saleor/saleor#11445, discussion #15458).
*
* Under DRY_RUN=true (the default) this script only reports drifted
* orders, it never writes a correcting event. A correction is only ever
* recorded when DRY_RUN=false, and only after a human has signed off on
* the specific transaction, event type, and amount, since the right fix
* depends on reading the actual raw events. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/total-balance-drift-after-refund/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EPSILON = 0.01;
/**
* Pure decision function. No I/O.
* All amounts are plain numbers, the same units Saleor reports them in.
* Returns { status: "OK"|"BALANCE_DRIFTED", expectedBalance, reportedBalance, driftedBy }.
*/
export function classifyBalanceDrift(total, totalCaptured, totalRefunded, reportedBalance) {
const expectedBalance = Math.round((total - totalCaptured + totalRefunded) * 100) / 100;
const driftedBy = Math.round((reportedBalance - expectedBalance) * 100) / 100;
if (Math.abs(driftedBy) <= EPSILON) {
return { status: "OK", expectedBalance, reportedBalance, driftedBy: 0 };
}
return { status: "BALANCE_DRIFTED", expectedBalance, reportedBalance, driftedBy };
}
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${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) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
total { gross { amount } }
totalCaptured { amount }
totalRefunded { amount }
totalBalance { amount }
}
}
}
}`;
const REPORT_EVENT = `
mutation($id: ID!, $type: TransactionEventTypeEnum!, $amount: Decimal!) {
transactionEventReport(id: $id, type: $type, amount: $amount) {
transaction { id }
errors { field message code }
}
}`;
const ORDER_BALANCE_CHECK = `
query($id: ID!) {
order(id: $id) {
id
total { gross { amount } }
totalCaptured { amount }
totalRefunded { amount }
totalBalance { amount }
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function recordCorrectingEvent(transactionId, eventType, amount) {
const result = (await gql(REPORT_EVENT, { id: transactionId, type: eventType, amount })).transactionEventReport;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.transaction.id;
}
async function confirmReconciled(orderId) {
const data = await gql(ORDER_BALANCE_CHECK, { id: orderId });
const order = data.order;
const result = classifyBalanceDrift(
order.total.gross.amount,
order.totalCaptured.amount,
order.totalRefunded.amount,
order.totalBalance.amount
);
return result.status === "OK";
}
function toPlain(node) {
return {
id: node.id,
number: node.number,
total: node.total.gross.amount,
totalCaptured: node.totalCaptured.amount,
totalRefunded: node.totalRefunded.amount,
totalBalance: node.totalBalance.amount,
};
}
/**
* pendingCorrections maps orderId -> { transactionId, eventType, amount },
* populated only from a signed-off review, never guessed by the script.
*/
export async function run(pendingCorrections = {}) {
let reported = 0;
let corrected = 0;
for await (const node of allOrders()) {
const order = toPlain(node);
const result = classifyBalanceDrift(order.total, order.totalCaptured, order.totalRefunded, order.totalBalance);
if (result.status === "OK") continue;
const reportRow = {
orderId: order.id,
orderNumber: order.number,
expectedBalance: result.expectedBalance,
reportedBalance: result.reportedBalance,
driftedBy: result.driftedBy,
};
console.warn("Balance-drifted order found for finance review:", reportRow);
reported++;
// Correcting the ledger is never automatic. This branch only ever runs
// for an order a human has signed off on, with the exact transaction,
// event type, and amount they decided on after reading the raw events.
const correction = pendingCorrections[order.id];
if (!DRY_RUN && correction) {
console.log(`Recording correcting event on transaction ${correction.transactionId} (signed off).`);
await recordCorrectingEvent(correction.transactionId, correction.eventType, correction.amount);
const reconciled = await confirmReconciled(order.id);
if (reconciled) {
console.log(`Order ${order.number} reconciled. totalBalance now matches.`);
} else {
console.error(`Order ${order.number} still drifted after correction. Needs manual review.`);
}
corrected++;
}
}
console.log(`Done. ${reported} order(s) reported drifted, ${corrected} correction(s) recorded.`);
}
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 reported, and how far off finance is told the ledger is. Because classify_balance_drift is pure, taking four plain numbers, the test needs no network and no Saleor account. It just feeds in fixture values and checks the answer.
from flag_balance_drift import classify_balance_drift
def test_ok_when_balance_matches_expected():
result = classify_balance_drift(100.0, 100.0, 0.0, 0.0)
assert result["status"] == "OK"
assert result["driftedBy"] == 0
def test_ok_with_legitimate_partial_balance():
# partial capture, nothing refunded, balance is genuinely nonzero
result = classify_balance_drift(100.0, 60.0, 0.0, 40.0)
assert result["status"] == "OK"
def test_drifted_when_partial_refund_leaves_charged_amount_stale():
# refunded 30 but totalCaptured never stepped down, so reported balance is wrong
result = classify_balance_drift(100.0, 100.0, 30.0, 0.0)
assert result["status"] == "BALANCE_DRIFTED"
assert result["expectedBalance"] == 30.0
assert result["driftedBy"] == -30.0
def test_drifted_when_authorization_adjustment_has_no_capture():
# reported balance overstates what is actually owed after an adjustment
result = classify_balance_drift(200.0, 150.0, 0.0, 75.0)
assert result["status"] == "BALANCE_DRIFTED"
assert result["expectedBalance"] == 50.0
assert result["driftedBy"] == 25.0
def test_floating_point_rounding_within_epsilon_is_ok():
result = classify_balance_drift(99.995, 100.0, 0.0, 0.0)
assert result["status"] == "OK"
def test_drifted_by_is_signed_and_reports_direction():
over_reported = classify_balance_drift(100.0, 100.0, 0.0, 10.0)
under_reported = classify_balance_drift(100.0, 100.0, 0.0, -10.0)
assert over_reported["driftedBy"] == 10.0
assert under_reported["driftedBy"] == -10.0
def test_ok_when_fully_refunded_and_balance_equals_total():
result = classify_balance_drift(100.0, 100.0, 100.0, 100.0)
assert result["status"] == "OK"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyBalanceDrift } from "./flag-balance-drift.js";
test("OK when balance matches expected", () => {
const result = classifyBalanceDrift(100.0, 100.0, 0.0, 0.0);
assert.equal(result.status, "OK");
assert.equal(result.driftedBy, 0);
});
test("OK with legitimate partial balance", () => {
const result = classifyBalanceDrift(100.0, 60.0, 0.0, 40.0);
assert.equal(result.status, "OK");
});
test("drifted when partial refund leaves charged amount stale", () => {
const result = classifyBalanceDrift(100.0, 100.0, 30.0, 0.0);
assert.equal(result.status, "BALANCE_DRIFTED");
assert.equal(result.expectedBalance, 30.0);
assert.equal(result.driftedBy, -30.0);
});
test("drifted when authorization adjustment has no capture", () => {
const result = classifyBalanceDrift(200.0, 150.0, 0.0, 75.0);
assert.equal(result.status, "BALANCE_DRIFTED");
assert.equal(result.expectedBalance, 50.0);
assert.equal(result.driftedBy, 25.0);
});
test("floating point rounding within epsilon is OK", () => {
const result = classifyBalanceDrift(99.995, 100.0, 0.0, 0.0);
assert.equal(result.status, "OK");
});
test("driftedBy is signed and reports direction", () => {
const overReported = classifyBalanceDrift(100.0, 100.0, 0.0, 10.0);
const underReported = classifyBalanceDrift(100.0, 100.0, 0.0, -10.0);
assert.equal(overReported.driftedBy, 10.0);
assert.equal(underReported.driftedBy, -10.0);
});
test("OK when fully refunded and balance equals total", () => {
const result = classifyBalanceDrift(100.0, 100.0, 100.0, 100.0);
assert.equal(result.status, "OK");
});
Case studies
A support-issued partial refund left the balance overstated
A support team issued partial refunds directly through their payment provider's dashboard for a batch of orders with a shipping shortfall, then recorded the refund back into Saleor with a single REFUND_SUCCESS event. The provider never sent a corresponding update to the transaction's own chargedAmount, so Saleor kept reporting the pre-refund captured figure alongside the new refunded figure, and every affected order's totalBalance quietly understated how much had actually been returned to the customer.
Running the report script in dry run surfaced every one of those orders with the exact drift amount, matching the shortfall refund to the dollar. Finance confirmed the pattern against the payment provider's own ledger, then authorized a correcting event on each transaction, and reconciliation stopped flagging orders that were, in truth, already settled correctly with the customer.
A gateway-side authorization bump nobody captured against
A subscription billing integration authorized a slightly higher amount than the original order total to cover a rounding difference in currency conversion, then never followed up with a capture or a void for the adjusted authorization. The order's totalBalance stopped matching what total, totalCaptured, and totalRefunded implied on their own, and finance could not tell from the admin whether the customer had been charged the higher amount or not.
Because the script only ever reports and never auto-corrects, the order sat in the report queue with its exact drift amount instead of anyone guessing at a fix. The billing team traced the raw transaction events, confirmed the authorization had in fact expired uncaptured, and authorized a correcting event that matched the ledger back to what had genuinely happened, exactly the outcome the report-first design was built to protect.
After this runs on a schedule, a drifted balance stops being an invisible reconciliation problem and becomes a report row with the exact expected balance, the reported one, and how far apart they are. Finance decides what actually happened to every case, and the only automated ledger change is a correcting event on a transaction a human has already identified, gated behind DRY_RUN and a signed-off decision, never a guess about which field is the one that drifted.
FAQ
Why does an order's totalBalance stop matching total minus totalCaptured plus totalRefunded in Saleor?
totalBalance is derived from whatever TransactionItem events exist at read time. A partial refund that only reports a REFUND_SUCCESS event without an updated CHARGE_SUCCESS reconciliation, or an authorization adjustment that changes authorizedAmount without a matching capture or void event, leaves the underlying amounts inconsistent with each other, so the derived balance drifts from what total, totalCaptured, and totalRefunded would suggest on their own.
What actually triggers the totalBalance drift?
A partial refund processed through a payment app that reports REFUND_SUCCESS but never emits a follow-up event correcting chargedAmount, an authorizedAmount adjustment from the payment gateway that arrives without a corresponding capture or void, or a webhook that updates one TransactionItem field while leaving a related one stale, can each leave an order's aggregate amounts inconsistent with each other.
Is it safe to auto-correct totalBalance drift with a script?
No, not automatically. A drifted balance is a signal that the transaction ledger disagrees with itself, and the right fix depends on which event was missing or wrong, something only a human reviewing the specific transaction events can decide safely. The safe pattern is to detect and report the drifted orders first. Any correction should be gated behind DRY_RUN and only run after a human has signed off on the specific transaction event to add or correct.
Related field notes
Citations
On the problem:
- totalBalance and totalCaptured become inconsistent after a partial refund. github.com/saleor/saleor/issues/12297
- Authorization amount changes are not reconciled against captured or refunded totals. github.com/saleor/saleor/issues/11445
- RFC: Improve relation between orderGrantedRefund and TransactionItem. github.com/saleor/saleor/discussions/15458
On the solution:
- Saleor Commerce Documentation: transaction events (transactionEventReport). docs.saleor.io/developer/extending/webhooks/synchronous-events/transaction
- Saleor API Reference: the TransactionItem object. docs.saleor.io/api-reference/payments/objects/transaction-item
- Saleor API Reference: the Order object, including totalBalance. docs.saleor.io/api-reference/orders/objects/order
Stuck on a tricky one?
If you have a problem in Saleor checkout, payments, stock, 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 catch a drifted balance for you?
If this saved your finance team from an unreconciled ledger or a wrong assumption about what a customer was owed, 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