Skip to content

Diagnostic Payments & Transactions

Order can be charged more than its total

The order total says one number. The finance report says a bigger one. Nobody edited the price, nobody added a line item, and yet the order somehow collected more money than it was ever supposed to. This is not corrupted data. Saleor has a name for this exact state, and it exists because nothing in the system hard-caps what a payment app or a duplicate webhook is allowed to charge. Here is why it happens and a script that finds every order in that state before a refund conversation gets awkward.

Python and Node.js Saleor GraphQL API Flag and report, not auto-refund
Holding a card and a phone
Photo by Nathana Reboucas on Unsplash
The short answer

Saleor tracks authorizedAmount and chargedAmount independently on every TransactionItem, then aggregates them onto the order as totalCharged and totalAuthorized. Nothing at the database level stops that sum from exceeding order.total. Historically, checkoutPaymentCreate accepted a payment amount higher than the checkout total with no server-side cap (saleor/saleor#4162), and separately a store could attach more than one TransactionItem to an order where each individual capture looked fine but the combined total did not (saleor/saleor#7399). Saleor is honest about this: it surfaces the result as the OVERCHARGED value of order.chargeStatus, which only exists because this state is reachable, not prevented. Run a small Python or Node.js script that pages through orders, compares totalCharged + totalAuthorized against total.gross.amount, and reports every order that crossed the line with its transaction breakdown. Full code, tests, and a dry run guard are below.

The problem in plain words

An order total in Saleor is a fixed number computed from its lines, shipping, and taxes. It is easy to assume that whatever money moves against that order is checked against that number before it is accepted. It is not, at least not with a hard constraint that blocks the write.

Each TransactionItem attached to an order carries its own authorizedAmount and chargedAmount, set independently every time a payment app or gateway reports an event back to Saleor. The order then rolls those numbers up into totalAuthorized and totalCharged, purely as an aggregate for reporting. Nothing forces that aggregate to stay under order.total. A single overly generous capture request can slip through, as checkoutPaymentCreate once did when it accepted an amount above the checkout total with no server-side cap. Or the overage can build up gradually: two separate TransactionItems on the same order, each individually well within a plausible range, whose combined chargedAmount only becomes a problem once you add them together, which is exactly the shape of the doubled-capture case reported in saleor/saleor#7399.

TransactionItem A charged: looks valid TransactionItem B charged: looks valid Summed, no cap totalCharged on order no DB constraint Exceeds order.total chargeStatus OVERCHARGED
Each TransactionItem's amount can look reasonable in isolation. Saleor sums them onto the order without a hard cap, so the aggregate can still cross the order total.

Why it happens

Saleor does not treat this as impossible. It is a recognized state with its own value on order.chargeStatus: OVERCHARGED. That naming is itself the confirmation that this is an accepted, if unwanted, outcome rather than a bug that corrupts the schema. See the citations at the end for the exact issue threads and the RFC discussing tighter coupling between refunds and transactions.

The key insight

You cannot prevent this by validating one transaction at a time, because each individual transaction can be perfectly valid on its own. The overage only shows up when you sum every transaction on the order and compare that sum against the order total. That is a detection problem, not a single-request validation problem, so the fix is a script that walks your orders and does that comparison for you, on the numbers Saleor already reports.

The fix, as a flow

The script runs on a schedule. It pages through orders with their total and their transactions, sums chargedAmount plus authorizedAmount per order with one pure function, and compares that sum against order.total.gross.amount. Anything over gets written to a report with the exact overage and a per-transaction breakdown. Nothing gets refunded automatically. A human reviews the report and, only once they approve it, the script's guarded path creates a granted refund and, if you flip the dry run flag off, requests it against the gateway.

Scheduled job runs on a timer Page orders, read total, transactions decideOverchargeFlag pure decision function over total? yes no, within tolerance, skip Report entry then human-approved refund
The script only ever reports overcharged orders. Creating a granted refund still needs a human to approve the amount, and the gateway call itself stays behind a dry run guard.

Build it step by step

1

Get an app token with order read and refund access

Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and payments. Since the guarded repair path calls orderGrantedRefundCreate and transactionRequestRefundForGrantedRefund, it also needs permission to manage orders and handle payments. 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 OVERCHARGE_EPSILON="0.005"
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 OVERCHARGE_EPSILON="0.005"
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 with their transactions and total

Ask for orders(first, after) and read back id, number, chargeStatus, totalCharged, totalAuthorized, totalBalance, total.gross, and each order's transactions with their chargedAmount, authorizedAmount, and refundedAmount. Saleor has no filter that returns only OVERCHARGED orders directly, so the comparison happens in your own code. Page with a cursor so the job handles a large backlog.

step3.py
ORDERS_QUERY = """
query FlagOverchargedOrders($first: Int!, $after: String) {
  orders(first: $first, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        chargeStatus
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        totalBalance { amount currency }
        total { gross { amount currency } }
        transactions {
          id
          chargedAmount { amount }
          authorizedAmount { amount }
          refundedAmount { amount }
        }
      }
    }
  }
}"""

def all_orders(page_size=50):
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"first": page_size, "after": 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 FlagOverchargedOrders($first: Int!, $after: String) {
  orders(first: $first, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        chargeStatus
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        totalBalance { amount currency }
        total { gross { amount currency } }
        transactions {
          id
          chargedAmount { amount }
          authorizedAmount { amount }
          refundedAmount { amount }
        }
      }
    }
  }
}`;

async function* allOrders(pageSize = 50) {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { first: pageSize, after: 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 plain order and transaction data and returns whether it is overcharged, the summed amount, and the overage. It sums chargedAmount plus authorizedAmount across the transactions array, falling back to order.totalCharged and order.totalAuthorized if that array is missing, then compares the sum to order.totalGrossAmount with a small epsilon so ordinary rounding does not trip a false positive. A pure function like this needs no network to test, which we do later.

decide.py
def decide_overcharge_flag(order, transactions=None, epsilon=0.005):
    transactions = transactions or []
    if transactions:
        captured_plus_authorized = sum(
            (t.get("chargedAmount") or 0) + (t.get("authorizedAmount") or 0)
            for t in transactions
        )
    else:
        captured_plus_authorized = (order.get("totalCharged") or 0) + (order.get("totalAuthorized") or 0)

    total_gross = order.get("totalGrossAmount") or 0
    overage_amount = captured_plus_authorized - total_gross
    is_overcharged = overage_amount > epsilon

    return {
        "isOvercharged": is_overcharged,
        "capturedPlusAuthorized": captured_plus_authorized,
        "overageAmount": max(overage_amount, 0.0) if is_overcharged else 0.0,
    }
decide.js
export function decideOverchargeFlag(order, transactions = [], epsilon = 0.005) {
  const captured_plus_authorized = transactions.length
    ? transactions.reduce((sum, t) => sum + (t.chargedAmount || 0) + (t.authorizedAmount || 0), 0)
    : (order.totalCharged || 0) + (order.totalAuthorized || 0);

  const totalGross = order.totalGrossAmount || 0;
  const overageAmount = captured_plus_authorized - totalGross;
  const isOvercharged = overageAmount > epsilon;

  return {
    isOvercharged,
    capturedPlusAuthorized: captured_plus_authorized,
    overageAmount: isOvercharged ? Math.max(overageAmount, 0) : 0,
  };
}
5

Prepare a granted refund, do not auto-execute it

When an order is flagged, the safe next step is creating a granted refund record with orderGrantedRefundCreate for the overage amount, which just records that a refund of that size is warranted. It does not itself move money. Only once a human has reviewed and approved that record should the script's separate, explicitly opt-in path call transactionRequestRefundForGrantedRefund against the actual transaction, and even that stays behind DRY_RUN=false.

apply.py
GRANTED_REFUND_CREATE = """
mutation($orderId: ID!, $amount: PositiveDecimal!, $reason: String!) {
  orderGrantedRefundCreate(orderId: $orderId, input: { amount: $amount, reason: $reason }) {
    orderGrantedRefund { id amount { amount } }
    errors { field code message }
  }
}"""

# Opt-in only. Never called by run(). Only fire after a human approves the
# grantedRefund record created above, and only with DRY_RUN=false.
REQUEST_REFUND_FOR_GRANTED_REFUND = """
mutation($transactionId: ID!, $grantedRefundId: ID!) {
  transactionRequestRefundForGrantedRefund(id: $transactionId, grantedRefundId: $grantedRefundId) {
    transaction { id }
    errors { field code message }
  }
}"""

def create_granted_refund(order_id, overage_amount, reason):
    result = gql(GRANTED_REFUND_CREATE, {
        "orderId": order_id,
        "amount": round(overage_amount, 2),
        "reason": reason,
    })["orderGrantedRefundCreate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["orderGrantedRefund"]["id"]
apply.js
const GRANTED_REFUND_CREATE = `
mutation($orderId: ID!, $amount: PositiveDecimal!, $reason: String!) {
  orderGrantedRefundCreate(orderId: $orderId, input: { amount: $amount, reason: $reason }) {
    orderGrantedRefund { id amount { amount } }
    errors { field code message }
  }
}`;

// Opt-in only. Never called by run(). Only fire after a human approves the
// grantedRefund record created above, and only with DRY_RUN=false.
const REQUEST_REFUND_FOR_GRANTED_REFUND = `
mutation($transactionId: ID!, $grantedRefundId: ID!) {
  transactionRequestRefundForGrantedRefund(id: $transactionId, grantedRefundId: $grantedRefundId) {
    transaction { id }
    errors { field code message }
  }
}`;

async function createGrantedRefund(orderId, overageAmount, reason) {
  const result = (await gql(GRANTED_REFUND_CREATE, {
    orderId,
    amount: Math.round(overageAmount * 100) / 100,
    reason,
  })).orderGrantedRefundCreate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.orderGrantedRefund.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 proposed granted refund input for each overcharged order: the order id, number, overage amount, and the per-transaction breakdown. It never calls orderGrantedRefundCreate or transactionRequestRefundForGrantedRefund from the default path. Run it on a schedule that matches how quickly finance wants to catch a discrepancy, for example once a day, then re-query the order afterward to confirm chargeStatus moved off OVERCHARGED and totalBalance.amount returned to zero once a refund is actually processed.

Run it safe

This script's default behavior is report-only, and it should stay that way. Reversing real captured money needs a human decision about which transaction to refund, whether it is partial or full, and whether goods already shipped against that money. Only wire in the guarded refund path once a person has reviewed the flagged list, and always start with DRY_RUN=true.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through orders, flags each with the pure function, and logs a proposed granted refund for every overcharged order. The refund-execution path stays commented out by default, since flagging is the safe behavior for this issue.

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_overcharged_orders.py
"""Flag Saleor orders where totalCharged plus totalAuthorized exceeds the
order total, because Saleor tracks authorizedAmount and chargedAmount
independently per TransactionItem and aggregates them without a hard cap
against order.total (see saleor/saleor#4162, saleor/saleor#7399, and the
order.chargeStatus OVERCHARGED value in the docs).

This script never calls orderGrantedRefundCreate or
transactionRequestRefundForGrantedRefund by default. Under DRY_RUN=true (the
default) it only logs the proposed granted refund input for each overcharged
order for a human to review. The guarded repair path (create_granted_refund)
is opt-in only, meant to run after a human approves the amount, and should
only ever run with DRY_RUN=false. Run on a schedule. Safe to run again and
again, since it never writes anything on its own.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_overcharged_orders")

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
EPSILON = float(os.environ.get("OVERCHARGE_EPSILON", "0.005"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ORDERS_QUERY = """
query FlagOverchargedOrders($first: Int!, $after: String) {
  orders(first: $first, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        chargeStatus
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        totalBalance { amount currency }
        total { gross { amount currency } }
        transactions {
          id
          chargedAmount { amount }
          authorizedAmount { amount }
          refundedAmount { amount }
        }
      }
    }
  }
}"""

GRANTED_REFUND_CREATE = """
mutation($orderId: ID!, $amount: PositiveDecimal!, $reason: String!) {
  orderGrantedRefundCreate(orderId: $orderId, input: { amount: $amount, reason: $reason }) {
    orderGrantedRefund { id amount { amount } }
    errors { field code message }
  }
}"""

# Opt-in only. Never called by run(). Only fire after a human approves the
# grantedRefund record created above, and only with DRY_RUN=false.
REQUEST_REFUND_FOR_GRANTED_REFUND = """
mutation($transactionId: ID!, $grantedRefundId: ID!) {
  transactionRequestRefundForGrantedRefund(id: $transactionId, grantedRefundId: $grantedRefundId) {
    transaction { id }
    errors { field code message }
  }
}"""


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 decide_overcharge_flag(order, transactions=None, epsilon=0.005):
    transactions = transactions or []
    if transactions:
        captured_plus_authorized = sum(
            (t.get("chargedAmount") or 0) + (t.get("authorizedAmount") or 0)
            for t in transactions
        )
    else:
        captured_plus_authorized = (order.get("totalCharged") or 0) + (order.get("totalAuthorized") or 0)

    total_gross = order.get("totalGrossAmount") or 0
    overage_amount = captured_plus_authorized - total_gross
    is_overcharged = overage_amount > epsilon

    return {
        "isOvercharged": is_overcharged,
        "capturedPlusAuthorized": captured_plus_authorized,
        "overageAmount": max(overage_amount, 0.0) if is_overcharged else 0.0,
    }


def all_orders(page_size=50):
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"first": page_size, "after": cursor})["orders"]
        for edge in data["edges"]:
            yield edge["node"]
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def create_granted_refund(order_id, overage_amount, reason):
    """Opt-in only. Never called by run(). Wire in yourself after a human
    approves the flagged overage amount."""
    result = gql(GRANTED_REFUND_CREATE, {
        "orderId": order_id,
        "amount": round(overage_amount, 2),
        "reason": reason,
    })["orderGrantedRefundCreate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["orderGrantedRefund"]["id"]


def request_refund_for_granted_refund(transaction_id, granted_refund_id):
    """Opt-in only. Never called by run(). Executes the refund against the
    gateway once a granted refund has been approved."""
    result = gql(REQUEST_REFUND_FOR_GRANTED_REFUND, {
        "transactionId": transaction_id,
        "grantedRefundId": granted_refund_id,
    })["transactionRequestRefundForGrantedRefund"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["transaction"]["id"]


def to_plain(node):
    return {
        "id": node["id"],
        "number": node["number"],
        "chargeStatus": node["chargeStatus"],
        "totalCharged": (node.get("totalCharged") or {}).get("amount") or 0,
        "totalAuthorized": (node.get("totalAuthorized") or {}).get("amount") or 0,
        "totalBalance": (node.get("totalBalance") or {}).get("amount") or 0,
        "totalGrossAmount": (node.get("total") or {}).get("gross", {}).get("amount") or 0,
        "currency": (node.get("total") or {}).get("gross", {}).get("currency"),
    }


def to_plain_transactions(node):
    return [
        {
            "id": t["id"],
            "chargedAmount": (t.get("chargedAmount") or {}).get("amount") or 0,
            "authorizedAmount": (t.get("authorizedAmount") or {}).get("amount") or 0,
            "refundedAmount": (t.get("refundedAmount") or {}).get("amount") or 0,
        }
        for t in (node.get("transactions") or [])
    ]


def run():
    flagged = 0

    for node in all_orders():
        order = to_plain(node)
        transactions = to_plain_transactions(node)
        decision = decide_overcharge_flag(order, transactions, EPSILON)
        if not decision["isOvercharged"]:
            continue

        report_entry = {
            "orderId": order["id"],
            "number": order["number"],
            "chargeStatus": order["chargeStatus"],
            "capturedPlusAuthorized": decision["capturedPlusAuthorized"],
            "orderTotal": order["totalGrossAmount"],
            "overageAmount": round(decision["overageAmount"], 2),
            "totalBalance": order["totalBalance"],
            "transactions": transactions,
        }
        log.warning("Overcharged order found. %s %s", report_entry,
                    "(dry run, reporting only)" if DRY_RUN else "(reporting only, refund requires approval)")
        flagged += 1

        proposed_input = {
            "orderId": order["id"],
            "amount": round(decision["overageAmount"], 2),
            "reason": "Overcharge auto-detected: captured+authorized exceeded order total",
        }
        log.info("Proposed grantedRefund input: %s", proposed_input)

    log.info("Done. %d overcharged order(s) flagged for review.", flagged)


if __name__ == "__main__":
    run()
flag-overcharged-orders.js
/**
 * Flag Saleor orders where totalCharged plus totalAuthorized exceeds the
 * order total, because Saleor tracks authorizedAmount and chargedAmount
 * independently per TransactionItem and aggregates them without a hard cap
 * against order.total (see saleor/saleor#4162, saleor/saleor#7399, and the
 * order.chargeStatus OVERCHARGED value in the docs).
 *
 * This script never calls orderGrantedRefundCreate or
 * transactionRequestRefundForGrantedRefund by default. Under DRY_RUN=true
 * (the default) it only logs the proposed granted refund input for each
 * overcharged order for a human to review. The guarded repair path
 * (createGrantedRefund) is opt-in only, meant to run after a human approves
 * the amount, and should only ever run with DRY_RUN=false. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/saleor/order-charged-more-than-total/
 */
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 EPSILON = Number(process.env.OVERCHARGE_EPSILON || 0.005);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decideOverchargeFlag(order, transactions = [], epsilon = 0.005) {
  const capturedPlusAuthorized = transactions.length
    ? transactions.reduce((sum, t) => sum + (t.chargedAmount || 0) + (t.authorizedAmount || 0), 0)
    : (order.totalCharged || 0) + (order.totalAuthorized || 0);

  const totalGross = order.totalGrossAmount || 0;
  const overageAmount = capturedPlusAuthorized - totalGross;
  const isOvercharged = overageAmount > epsilon;

  return {
    isOvercharged,
    capturedPlusAuthorized,
    overageAmount: isOvercharged ? Math.max(overageAmount, 0) : 0,
  };
}

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 FlagOverchargedOrders($first: Int!, $after: String) {
  orders(first: $first, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        chargeStatus
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        totalBalance { amount currency }
        total { gross { amount currency } }
        transactions {
          id
          chargedAmount { amount }
          authorizedAmount { amount }
          refundedAmount { amount }
        }
      }
    }
  }
}`;

const GRANTED_REFUND_CREATE = `
mutation($orderId: ID!, $amount: PositiveDecimal!, $reason: String!) {
  orderGrantedRefundCreate(orderId: $orderId, input: { amount: $amount, reason: $reason }) {
    orderGrantedRefund { id amount { amount } }
    errors { field code message }
  }
}`;

// Opt-in only. Never called by run(). Only fire after a human approves the
// grantedRefund record created above, and only with DRY_RUN=false.
const REQUEST_REFUND_FOR_GRANTED_REFUND = `
mutation($transactionId: ID!, $grantedRefundId: ID!) {
  transactionRequestRefundForGrantedRefund(id: $transactionId, grantedRefundId: $grantedRefundId) {
    transaction { id }
    errors { field code message }
  }
}`;

async function* allOrders(pageSize = 50) {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { first: pageSize, after: cursor })).orders;
    for (const edge of data.edges) yield edge.node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

// Opt-in only. Never called by run(). Wire in yourself after a human
// approves the flagged overage amount.
async function createGrantedRefund(orderId, overageAmount, reason) {
  const result = (await gql(GRANTED_REFUND_CREATE, {
    orderId,
    amount: Math.round(overageAmount * 100) / 100,
    reason,
  })).orderGrantedRefundCreate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.orderGrantedRefund.id;
}

// Opt-in only. Never called by run(). Executes the refund against the
// gateway once a granted refund has been approved.
async function requestRefundForGrantedRefund(transactionId, grantedRefundId) {
  const result = (await gql(REQUEST_REFUND_FOR_GRANTED_REFUND, {
    transactionId,
    grantedRefundId,
  })).transactionRequestRefundForGrantedRefund;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.transaction.id;
}

function toPlain(node) {
  return {
    id: node.id,
    number: node.number,
    chargeStatus: node.chargeStatus,
    totalCharged: node.totalCharged?.amount ?? 0,
    totalAuthorized: node.totalAuthorized?.amount ?? 0,
    totalBalance: node.totalBalance?.amount ?? 0,
    totalGrossAmount: node.total?.gross?.amount ?? 0,
    currency: node.total?.gross?.currency ?? null,
  };
}

function toPlainTransactions(node) {
  return (node.transactions || []).map((t) => ({
    id: t.id,
    chargedAmount: t.chargedAmount?.amount ?? 0,
    authorizedAmount: t.authorizedAmount?.amount ?? 0,
    refundedAmount: t.refundedAmount?.amount ?? 0,
  }));
}

export async function run() {
  let flagged = 0;

  for await (const node of allOrders()) {
    const order = toPlain(node);
    const transactions = toPlainTransactions(node);
    const decision = decideOverchargeFlag(order, transactions, EPSILON);
    if (!decision.isOvercharged) continue;

    const reportEntry = {
      orderId: order.id,
      number: order.number,
      chargeStatus: order.chargeStatus,
      capturedPlusAuthorized: decision.capturedPlusAuthorized,
      orderTotal: order.totalGrossAmount,
      overageAmount: Math.round(decision.overageAmount * 100) / 100,
      totalBalance: order.totalBalance,
      transactions,
    };
    console.warn(
      "Overcharged order found.", reportEntry,
      DRY_RUN ? "(dry run, reporting only)" : "(reporting only, refund requires approval)"
    );
    flagged++;

    const proposedInput = {
      orderId: order.id,
      amount: Math.round(decision.overageAmount * 100) / 100,
      reason: "Overcharge auto-detected: captured+authorized exceeded order total",
    };
    console.log("Proposed grantedRefund input:", proposedInput);
  }

  console.log(`Done. ${flagged} overcharged order(s) flagged for review.`);
}

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 as overcharged. Because decide_overcharge_flag is pure, taking plain order and transaction data as arguments instead of querying anything itself, the test needs no network and no Saleor account. It just feeds in fixture orders and checks the answer.

test_overcharge_decision.py
from flag_overcharged_orders import decide_overcharge_flag


def order(**over):
    base = {"totalGrossAmount": 100.0, "totalCharged": 100.0, "totalAuthorized": 0.0, "currency": "USD"}
    base.update(over)
    return base


def test_exact_match_is_not_overcharged():
    result = decide_overcharge_flag(order(), [{"chargedAmount": 100.0, "authorizedAmount": 0.0}])
    assert result["isOvercharged"] is False
    assert result["capturedPlusAuthorized"] == 100.0
    assert result["overageAmount"] == 0.0


def test_one_cent_over_is_overcharged():
    result = decide_overcharge_flag(order(), [{"chargedAmount": 100.01, "authorizedAmount": 0.0}])
    assert result["isOvercharged"] is True
    assert round(result["overageAmount"], 2) == 0.01


def test_within_epsilon_is_not_overcharged():
    result = decide_overcharge_flag(order(), [{"chargedAmount": 100.003, "authorizedAmount": 0.0}], epsilon=0.005)
    assert result["isOvercharged"] is False


def test_double_capture_is_overcharged():
    transactions = [
        {"chargedAmount": 100.0, "authorizedAmount": 0.0},
        {"chargedAmount": 100.0, "authorizedAmount": 0.0},
    ]
    result = decide_overcharge_flag(order(), transactions)
    assert result["isOvercharged"] is True
    assert result["capturedPlusAuthorized"] == 200.0
    assert round(result["overageAmount"], 2) == 100.0


def test_zero_total_with_any_charge_is_overcharged():
    result = decide_overcharge_flag(order(totalGrossAmount=0.0), [{"chargedAmount": 5.0, "authorizedAmount": 0.0}])
    assert result["isOvercharged"] is True


def test_falls_back_to_order_totals_when_no_transactions_array():
    result = decide_overcharge_flag(order(totalCharged=150.0, totalAuthorized=0.0), transactions=None)
    assert result["isOvercharged"] is True
    assert result["capturedPlusAuthorized"] == 150.0


def test_authorized_plus_charged_together_can_overcharge():
    transactions = [{"chargedAmount": 80.0, "authorizedAmount": 25.0}]
    result = decide_overcharge_flag(order(), transactions)
    assert result["isOvercharged"] is True
    assert result["capturedPlusAuthorized"] == 105.0
decide-overcharge.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOverchargeFlag } from "./flag-overcharged-orders.js";

const order = (over = {}) => ({
  totalGrossAmount: 100.0,
  totalCharged: 100.0,
  totalAuthorized: 0.0,
  currency: "USD",
  ...over,
});

test("exact match is not overcharged", () => {
  const result = decideOverchargeFlag(order(), [{ chargedAmount: 100.0, authorizedAmount: 0.0 }]);
  assert.equal(result.isOvercharged, false);
  assert.equal(result.capturedPlusAuthorized, 100.0);
  assert.equal(result.overageAmount, 0);
});

test("one cent over is overcharged", () => {
  const result = decideOverchargeFlag(order(), [{ chargedAmount: 100.01, authorizedAmount: 0.0 }]);
  assert.equal(result.isOvercharged, true);
  assert.equal(Math.round(result.overageAmount * 100) / 100, 0.01);
});

test("within epsilon is not overcharged", () => {
  const result = decideOverchargeFlag(order(), [{ chargedAmount: 100.003, authorizedAmount: 0.0 }], 0.005);
  assert.equal(result.isOvercharged, false);
});

test("double capture is overcharged", () => {
  const transactions = [
    { chargedAmount: 100.0, authorizedAmount: 0.0 },
    { chargedAmount: 100.0, authorizedAmount: 0.0 },
  ];
  const result = decideOverchargeFlag(order(), transactions);
  assert.equal(result.isOvercharged, true);
  assert.equal(result.capturedPlusAuthorized, 200.0);
  assert.equal(Math.round(result.overageAmount * 100) / 100, 100.0);
});

test("zero total with any charge is overcharged", () => {
  const result = decideOverchargeFlag(order({ totalGrossAmount: 0.0 }), [{ chargedAmount: 5.0, authorizedAmount: 0.0 }]);
  assert.equal(result.isOvercharged, true);
});

test("falls back to order totals when no transactions array", () => {
  const result = decideOverchargeFlag(order({ totalCharged: 150.0, totalAuthorized: 0.0 }), []);
  assert.equal(result.isOvercharged, true);
  assert.equal(result.capturedPlusAuthorized, 150.0);
});

test("authorized plus charged together can overcharge", () => {
  const transactions = [{ chargedAmount: 80.0, authorizedAmount: 25.0 }];
  const result = decideOverchargeFlag(order(), transactions);
  assert.equal(result.isOvercharged, true);
  assert.equal(result.capturedPlusAuthorized, 105.0);
});

Case studies

Duplicate webhook

A retried webhook charged the same order twice

A payment app's webhook endpoint timed out on the gateway's side, so the gateway retried the delivery a few seconds later. The app processed both deliveries as separate capture events, each one landing on its own TransactionItem with a perfectly reasonable chargedAmount. Individually neither transaction looked wrong. Together they doubled the money taken from the customer, and nobody noticed until a customer emailed asking why their card was charged twice.

Running the flag script against the store's recent orders surfaced the exact order within the first pass, with both transaction ids and amounts laid out side by side. Finance approved a granted refund for the difference the same day, and the team added idempotency keys to the webhook handler so a retried delivery updates the existing transaction instead of creating a second one.

Manual capture mistake

A support agent captured an already-captured authorization

An order had an authorization placed, then a support agent, working from an old ticket, manually triggered a capture against it not realizing another agent had already captured the same authorization the day before through a different tool. The order's totalCharged quietly became larger than total.gross.amount, and Saleor's own chargeStatus flipped to OVERCHARGED, but nothing paged anyone about it.

The daily flag run caught it that night, reporting the order id, the overage amount, and both transactions involved. A manager reviewed the report the next morning, approved a granted refund for the exact overage, and the team updated their support runbook so agents check chargeStatus before ever triggering a manual capture.

What good looks like

After this runs on a schedule, an overcharge does not sit hidden in a finance report until a customer complains. It shows up within one run, with the exact overage amount and the transaction breakdown that explains it. Nothing gets refunded automatically, since deciding which transaction to reverse and whether it is a partial or full refund needs a person who knows whether goods already shipped, but that person now starts from a clear, pre-computed number instead of hand-auditing every order.

FAQ

Can a Saleor order really be charged more than its total?

Yes. Saleor tracks authorizedAmount and chargedAmount independently on each TransactionItem and aggregates them onto the order rather than hard-capping the sum with a database constraint. A buggy payment app, a double-delivered webhook, or more than one TransactionItem attached to the same order can each look valid on their own while together pushing totalCharged plus totalAuthorized above order.total. Saleor even has a name for it: order.chargeStatus becomes OVERCHARGED.

How do I find orders that were overcharged?

Query orders with their total, totalCharged, totalAuthorized, totalBalance, chargeStatus, and the per-transaction chargedAmount and authorizedAmount, then compare the sum against the order total in your own code, since Saleor has no orders filter that returns only OVERCHARGED orders directly. Flag any order where totalCharged plus totalAuthorized exceeds total.gross.amount by more than a small rounding tolerance, and treat chargeStatus equal to OVERCHARGED and totalBalance greater than zero as corroborating signals.

Is it safe to auto-refund an overcharged order?

No, not automatically. Reversing real captured money requires a human decision about which transaction to refund, whether it should be a partial or full refund, and whether the merchant already shipped against that money. The safe pattern is to flag and report only, then let a human approve an orderGrantedRefundCreate for the overage amount, and only call transactionRequestRefundForGrantedRefund once that approval exists, always behind a dry run guard.

Related field notes

Citations

On the problem:

  1. Saleor will authorize and charge more than the order's total if allowed. github.com/saleor/saleor/issues/4162
  2. Captured amount is twice the total amount resulting in an outstanding balance. github.com/saleor/saleor/issues/7399
  3. RFC: Improve relation between orderGrantedRefund and TransactionItem. github.com/saleor/saleor/discussions/15458

On the solution:

  1. Saleor Commerce Documentation: the TransactionItem object. docs.saleor.io/api-reference/payments/objects/transaction-item
  2. Saleor Commerce Documentation: refunds. docs.saleor.io/developer/payments/refunds
  3. Saleor Commerce Documentation: payments lifecycle. docs.saleor.io/developer/payments/lifecycle

Stuck on a tricky one?

If you have a problem in Saleor checkout, payments, transactions, 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 an overcharge before finance did?

If this saved you an awkward refund conversation or a mismatched revenue report, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Saleor field notes