Skip to content

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.

Python and Node.js Saleor GraphQL API Report-first, human sign-off required
A card reader device
Photo by Towfiqu barbhuiya on Unsplash
The short answer

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.

Partial refund REFUND_SUCCESS only Authorization adjust authorizedAmount changes no capture or void event Fields disagree chargedAmount stale no reconciliation check totalBalance derived from both wrong balance
A refund or an authorization adjustment that only touches part of the ledger leaves the fields that feed totalBalance disagreeing with each other.

Why it happens

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 key insight

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.

Scheduled job runs on a timer Page orders, read total, captured, refunded classifyBalanceDrift pure decision function OK or BALANCE_DRIFTED? OK, skip DRIFTED, report row Correct ledger DRY_RUN false, signed off only
Every drifted order becomes a report row first. Recording a correcting transaction event is a separate, gated step that only ever runs with DRY_RUN off and a human sign-off logged.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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"]
step3.js
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;
  }
}
4

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.

decide.py
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,
    }
decide.js
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 };
}
5

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.

apply.py
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"]
apply.js
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;
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
flag_balance_drift.py
"""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()
flag-balance-drift.js
/**
 * 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.

test_balance_classify.py
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"
classify.test.js
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

Partial refund

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.

Authorization adjustment

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.

What good looks like

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:

  1. totalBalance and totalCaptured become inconsistent after a partial refund. github.com/saleor/saleor/issues/12297
  2. Authorization amount changes are not reconciled against captured or refunded totals. github.com/saleor/saleor/issues/11445
  3. RFC: Improve relation between orderGrantedRefund and TransactionItem. github.com/saleor/saleor/discussions/15458

On the solution:

  1. Saleor Commerce Documentation: transaction events (transactionEventReport). docs.saleor.io/developer/extending/webhooks/synchronous-events/transaction
  2. Saleor API Reference: the TransactionItem object. docs.saleor.io/api-reference/payments/objects/transaction-item
  3. 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.

Contact me on LinkedIn

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

Back to all Saleor field notes