Skip to content

Diagnostic Payments & Transactions

Captured amount doubles the order total

Finance flags an order where the captured amount is exactly twice what the customer should have paid. Nothing in the Saleor admin threw an error. No mutation failed. But a retried checkout, a redelivered webhook, or a second manual capture call each reported a full charge for the order, and Saleor added both up without ever checking whether the total made sense. Here is why totalCaptured can run past order.total and a script that finds every order where it has, without touching a single dollar until a human says so.

Python and Node.js Saleor GraphQL API Report-first, human sign-off required
Hands holding phones
Photo by David Dvoracek on Unsplash
The short answer

Saleor computes an order's totalCaptured by summing chargedAmount across every TransactionItem linked to the order. It deduplicates CHARGE_SUCCESS events only within a single TransactionItem, keyed by (type, pspReference). There is no order-level guard that caps the aggregate at order.total. If a checkout retry, a webhook redelivery with a new pspReference, or a second manual transactionCreate or legacy orderCapture call produces a second TransactionItem, or a second event with a different pspReference, reporting a full CHARGE_SUCCESS, Saleor accepts and sums both, and totalCaptured becomes double the order total. Run a small Python or Node.js script that pages through orders, compares totalCaptured against total.gross, and reports every over-captured order with its culprit transaction ids for finance to review. Full code, tests, and a dry run guard are below.

The problem in plain words

An order's total captured amount is supposed to be a simple fact: how much money has actually landed for this order. Saleor derives that fact by walking every TransactionItem attached to the order and adding up each one's chargedAmount. Each TransactionItem is careful about its own bookkeeping. If the same payment gateway redelivers the exact same CHARGE_SUCCESS event with the exact same pspReference, Saleor recognizes the duplicate and does not double count it inside that one transaction.

What that guard does not cover is a second, distinct TransactionItem, or a second event on the same item carrying a different pspReference, that independently reports a full charge for the order. That can happen when a checkout is retried after a slow response and both attempts eventually succeed, when a payment app's webhook gets redelivered with a freshly generated reference, or when someone calls transactionCreate or the legacy orderCapture a second time by hand. Saleor has no rule anywhere that says the sum of every transaction's charged amount must not exceed order.total. It just adds what it is told, so the order ends up reporting twice the money it should.

TransactionItem #1 CHARGE_SUCCESS, full total TransactionItem #2 retry / webhook redelivery new pspReference, full total Sum chargedAmount no cap at order.total no order-level guard totalCaptured = 2 x total 2x order total
Each TransactionItem dedupes its own CHARGE_SUCCESS events by pspReference, but nothing checks the sum across TransactionItems against order.total.

Why it happens

None of this throws an error or shows up as a failed mutation. The order just quietly reports an outstanding balance that does not match reality, or a captured amount finance cannot reconcile against the bank. 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 two distinct TransactionItem ids, or two distinct pspReference values, each independently carrying a CHARGE_SUCCESS for the full total. That is different from a same-pspReference duplicate, which Saleor already dedupes on its own and never doubles. So the fix is not "any capture over total is wrong." It is "sum every transaction's chargedAmount, compare to order.total, and only act once you can point at the specific transactions that each claim the full amount."

The fix, as a flow

The script runs on a schedule. It pages through orders, reads each order's total and its transactions with their charged amounts, and hands the numbers to a single pure function that decides whether the order is OK or OVER_CAPTURED. Because refunding money is never something a script should do blindly, the default action for every over-captured order is a report row for finance, not a refund. A refund of the exact excess is only ever issued behind a DRY_RUN guard, and only with a human sign-off logged first.

Scheduled job runs on a timer Page orders, sum chargedAmount per order classifyOrderCapture pure decision function OK or OVER_CAPTURED? OK, skip OVER_CAPTURED, report row Refund overBy DRY_RUN false, signed off only
Every over-captured order becomes a report row first. Refunding the excess 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 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 refund step, it needs permission to manage payments too, since it calls transactionRequestAction. 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 refunds 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 refunds 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 transaction fields the decision needs

Ask for orders(first, after) and read back id, number, total.gross.amount, totalCaptured.amount, and every transactions[] entry's id, pspReference, and chargedAmount.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 }
        transactions { id pspReference chargedAmount { 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 }
        transactions { id pspReference chargedAmount { 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 the order total and the list of transactions and returns a status, the total captured, how much it is over by, and which transaction ids are the culprits. A pure function like this is easy to read and test, which we do later. It sums chargedAmount across all transactions. If that sum is at or below the order total, within a small epsilon for floating point rounding, the order is OK. Otherwise it is OVER_CAPTURED, and the culprits are the transactions whose own chargedAmount already reaches the full order total on its own, the signature of a duplicate full-amount capture rather than a same-reference duplicate Saleor already dedupes.

decide.py
EPSILON = 0.01

def classify_order_capture(order_total, transactions):
    total_captured = sum(t["chargedAmount"] for t in transactions)

    if total_captured <= order_total + EPSILON:
        return {
            "status": "OK",
            "totalCaptured": total_captured,
            "overBy": 0,
            "culprits": [],
        }

    over_by = round(total_captured - order_total, 2)
    culprits = sorted(
        (t for t in transactions if t["chargedAmount"] >= order_total - EPSILON),
        key=lambda t: t["chargedAmount"],
        reverse=True,
    )
    return {
        "status": "OVER_CAPTURED",
        "totalCaptured": total_captured,
        "overBy": over_by,
        "culprits": [t["id"] for t in culprits],
    }
decide.js
const EPSILON = 0.01;

export function classifyOrderCapture(orderTotal, transactions) {
  const totalCaptured = transactions.reduce((sum, t) => sum + t.chargedAmount, 0);

  if (totalCaptured <= orderTotal + EPSILON) {
    return { status: "OK", totalCaptured, overBy: 0, culprits: [] };
  }

  const overBy = Math.round((totalCaptured - orderTotal) * 100) / 100;
  const culprits = transactions
    .filter((t) => t.chargedAmount >= orderTotal - EPSILON)
    .sort((a, b) => b.chargedAmount - a.chargedAmount)
    .map((t) => t.id);

  return { status: "OVER_CAPTURED", totalCaptured, overBy, culprits };
}
5

Report first, refund only with a human sign-off

When the decision is OVER_CAPTURED, the default action is a report row: {orderId, orderNumber, total, totalCaptured, overBy, transactionIds}, for finance to review, since the extra capture may be a real second charge the customer actually paid rather than a bug. If a corrective refund is authorized, it only ever targets the exact overBy amount on the culprit transaction with the later createdAt success event, gated behind DRY_RUN, and it is never issued without a human sign-off logged first.

apply.py
REFUND_EXCESS = """
mutation($id: ID!, $amount: Decimal!) {
  transactionRequestAction(id: $id, actionType: REFUND, amount: $amount) {
    transaction { id }
    errors { field message code }
  }
}"""

def refund_excess(transaction_id, amount):
    result = gql(REFUND_EXCESS, {"id": transaction_id, "amount": amount})["transactionRequestAction"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["transaction"]["id"]
apply.js
const REFUND_EXCESS = `
mutation($id: ID!, $amount: Decimal!) {
  transactionRequestAction(id: $id, actionType: REFUND, amount: $amount) {
    transaction { id }
    errors { field message code }
  }
}`;

async function refundExcess(transactionId, amount) {
  const result = (await gql(REFUND_EXCESS, { id: transactionId, amount })).transactionRequestAction;
  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 over-captured order. It never calls transactionRequestAction in this mode. Refunding is a separate, explicitly authorized path, gated behind DRY_RUN=false and a signed-off overage, and even then the script re-queries the order afterward to confirm totalCaptured now matches total.gross before treating the order as reconciled.

Run it safe

Always start with DRY_RUN=true and read the report before authorizing anything. Refunding the excess moves real money, and the "extra" capture might be a legitimate second charge the customer actually paid, not a bug. Never call transactionRequestAction 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 over-captured, and only ever calls the refund mutation under an explicit DRY_RUN=false for a signed-off overage.

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_over_captured.py
"""Find Saleor orders where totalCaptured has run past order.total, because
Saleor sums chargedAmount across every TransactionItem on the order and only
dedupes CHARGE_SUCCESS events within a single TransactionItem, keyed by
(type, pspReference). There is no order-level cap at order.total (see
saleor/saleor#7399, saleor/saleor#4162, discussion #15458).

Under DRY_RUN=true (the default) this script only reports over-captured
orders, it never refunds anything. A corrective refund of the exact overBy
amount is only ever issued when DRY_RUN=false, and only after a human has
signed off on that specific order, since the extra capture may reflect a
real second charge the customer actually paid. 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_over_captured")

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 }
        transactions { id pspReference chargedAmount { amount } }
      }
    }
  }
}"""

REFUND_EXCESS = """
mutation($id: ID!, $amount: Decimal!) {
  transactionRequestAction(id: $id, actionType: REFUND, amount: $amount) {
    transaction { id }
    errors { field message code }
  }
}"""

ORDER_CAPTURE_CHECK = """
query($id: ID!) {
  order(id: $id) {
    id
    total { gross { amount } }
    totalCaptured { 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_order_capture(order_total, transactions):
    total_captured = sum(t["chargedAmount"] for t in transactions)

    if total_captured <= order_total + EPSILON:
        return {
            "status": "OK",
            "totalCaptured": total_captured,
            "overBy": 0,
            "culprits": [],
        }

    over_by = round(total_captured - order_total, 2)
    culprits = sorted(
        (t for t in transactions if t["chargedAmount"] >= order_total - EPSILON),
        key=lambda t: t["chargedAmount"],
        reverse=True,
    )
    return {
        "status": "OVER_CAPTURED",
        "totalCaptured": total_captured,
        "overBy": over_by,
        "culprits": [t["id"] for t in culprits],
    }


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 refund_excess(transaction_id, amount):
    result = gql(REFUND_EXCESS, {"id": transaction_id, "amount": amount})["transactionRequestAction"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["transaction"]["id"]


def confirm_reconciled(order_id, order_total):
    order = gql(ORDER_CAPTURE_CHECK, {"id": order_id})["order"]
    captured = order["totalCaptured"]["amount"]
    return abs(captured - order_total) <= EPSILON


def to_plain(node):
    return {
        "id": node["id"],
        "number": node["number"],
        "total": node["total"]["gross"]["amount"],
        "transactions": [
            {"id": t["id"], "pspReference": t["pspReference"], "chargedAmount": t["chargedAmount"]["amount"]}
            for t in (node.get("transactions") or [])
        ],
    }


def run():
    reported = 0
    refunded = 0

    for node in all_orders():
        order = to_plain(node)
        result = classify_order_capture(order["total"], order["transactions"])
        if result["status"] == "OK":
            continue

        report_row = {
            "orderId": order["id"],
            "orderNumber": order["number"],
            "total": order["total"],
            "totalCaptured": result["totalCaptured"],
            "overBy": result["overBy"],
            "transactionIds": result["culprits"],
        }
        log.warning("Over-captured order found for finance review: %s", report_row)
        reported += 1

        # Refunding is never automatic. This branch only ever runs when a human
        # has signed off on this specific order and DRY_RUN has been turned off.
        if not DRY_RUN and result["culprits"]:
            culprit_id = result["culprits"][0]
            log.info("Refunding excess %.2f on transaction %s (signed off).", result["overBy"], culprit_id)
            refund_excess(culprit_id, result["overBy"])
            if confirm_reconciled(order["id"], order["total"]):
                log.info("Order %s reconciled. totalCaptured now matches total.", order["number"])
            else:
                log.error("Order %s still not reconciled after refund. Needs manual review.", order["number"])
            refunded += 1

    log.info("Done. %d order(s) reported over-captured, %d refund(s) issued.", reported, refunded)


if __name__ == "__main__":
    run()
flag-over-captured.js
/**
 * Find Saleor orders where totalCaptured has run past order.total, because
 * Saleor sums chargedAmount across every TransactionItem on the order and
 * only dedupes CHARGE_SUCCESS events within a single TransactionItem, keyed
 * by (type, pspReference). There is no order-level cap at order.total (see
 * saleor/saleor#7399, saleor/saleor#4162, discussion #15458).
 *
 * Under DRY_RUN=true (the default) this script only reports over-captured
 * orders, it never refunds anything. A corrective refund of the exact
 * overBy amount is only ever issued when DRY_RUN=false, and only after a
 * human has signed off on that specific order, since the extra capture may
 * reflect a real second charge the customer actually paid. Run on a
 * schedule.
 *
 * Guide: https://www.allanninal.dev/saleor/captured-amount-doubles-order-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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const EPSILON = 0.01;

export function classifyOrderCapture(orderTotal, transactions) {
  const totalCaptured = transactions.reduce((sum, t) => sum + t.chargedAmount, 0);

  if (totalCaptured <= orderTotal + EPSILON) {
    return { status: "OK", totalCaptured, overBy: 0, culprits: [] };
  }

  const overBy = Math.round((totalCaptured - orderTotal) * 100) / 100;
  const culprits = transactions
    .filter((t) => t.chargedAmount >= orderTotal - EPSILON)
    .sort((a, b) => b.chargedAmount - a.chargedAmount)
    .map((t) => t.id);

  return { status: "OVER_CAPTURED", totalCaptured, overBy, culprits };
}

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 }
        transactions { id pspReference chargedAmount { amount } }
      }
    }
  }
}`;

const REFUND_EXCESS = `
mutation($id: ID!, $amount: Decimal!) {
  transactionRequestAction(id: $id, actionType: REFUND, amount: $amount) {
    transaction { id }
    errors { field message code }
  }
}`;

const ORDER_CAPTURE_CHECK = `
query($id: ID!) {
  order(id: $id) {
    id
    total { gross { amount } }
    totalCaptured { 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 refundExcess(transactionId, amount) {
  const result = (await gql(REFUND_EXCESS, { id: transactionId, amount })).transactionRequestAction;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.transaction.id;
}

async function confirmReconciled(orderId, orderTotal) {
  const data = await gql(ORDER_CAPTURE_CHECK, { id: orderId });
  const captured = data.order.totalCaptured.amount;
  return Math.abs(captured - orderTotal) <= EPSILON;
}

function toPlain(node) {
  return {
    id: node.id,
    number: node.number,
    total: node.total.gross.amount,
    transactions: (node.transactions || []).map((t) => ({
      id: t.id,
      pspReference: t.pspReference,
      chargedAmount: t.chargedAmount.amount,
    })),
  };
}

export async function run() {
  let reported = 0;
  let refunded = 0;

  for await (const node of allOrders()) {
    const order = toPlain(node);
    const result = classifyOrderCapture(order.total, order.transactions);
    if (result.status === "OK") continue;

    const reportRow = {
      orderId: order.id,
      orderNumber: order.number,
      total: order.total,
      totalCaptured: result.totalCaptured,
      overBy: result.overBy,
      transactionIds: result.culprits,
    };
    console.warn("Over-captured order found for finance review:", reportRow);
    reported++;

    // Refunding is never automatic. This branch only ever runs when a human
    // has signed off on this specific order and DRY_RUN has been turned off.
    if (!DRY_RUN && result.culprits.length) {
      const culpritId = result.culprits[0];
      console.log(`Refunding excess ${result.overBy.toFixed(2)} on transaction ${culpritId} (signed off).`);
      await refundExcess(culpritId, result.overBy);
      const reconciled = await confirmReconciled(order.id, order.total);
      if (reconciled) {
        console.log(`Order ${order.number} reconciled. totalCaptured now matches total.`);
      } else {
        console.error(`Order ${order.number} still not reconciled after refund. Needs manual review.`);
      }
      refunded++;
    }
  }

  console.log(`Done. ${reported} order(s) reported over-captured, ${refunded} refund(s) issued.`);
}

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 which transaction is treated as the culprit if a refund is ever authorized. Because classify_order_capture is pure, taking the order total and a plain list of transactions, the test needs no network and no Saleor account. It just feeds in fixture arrays and checks the answer.

test_captured_classify.py
from flag_over_captured import classify_order_capture


def tx(id_, amount, psp="psp_1"):
    return {"id": id_, "pspReference": psp, "chargedAmount": amount}


def test_ok_when_single_correct_capture():
    result = classify_order_capture(100.0, [tx("t1", 100.0)])
    assert result["status"] == "OK"
    assert result["overBy"] == 0
    assert result["culprits"] == []


def test_over_captured_when_two_full_amount_transactions():
    result = classify_order_capture(100.0, [tx("t1", 100.0, "psp_1"), tx("t2", 100.0, "psp_2")])
    assert result["status"] == "OVER_CAPTURED"
    assert result["totalCaptured"] == 200.0
    assert result["overBy"] == 100.0
    assert result["culprits"] == ["t1", "t2"]


def test_ok_with_partial_capture_and_refund_netting_to_total():
    # a partial capture plus a top-up that together equal the total, no doubling
    result = classify_order_capture(100.0, [tx("t1", 60.0), tx("t2", 40.0)])
    assert result["status"] == "OK"


def test_over_captured_with_partial_plus_full_duplicate():
    result = classify_order_capture(50.0, [tx("t1", 50.0, "psp_1"), tx("t2", 50.0, "psp_2")])
    assert result["status"] == "OVER_CAPTURED"
    assert result["overBy"] == 50.0
    assert result["culprits"] == ["t1", "t2"]


def test_floating_point_rounding_within_epsilon_is_ok():
    result = classify_order_capture(99.99, [tx("t1", 100.0)])
    assert result["status"] == "OK"


def test_culprits_sorted_by_charged_amount_descending():
    result = classify_order_capture(
        100.0,
        [tx("small", 5.0), tx("big1", 100.0, "psp_1"), tx("big2", 150.0, "psp_2")],
    )
    assert result["status"] == "OVER_CAPTURED"
    assert result["culprits"] == ["big2", "big1"]


def test_no_culprits_when_over_captured_from_many_small_transactions():
    # over total, but no single transaction reaches the full order amount on its own
    result = classify_order_capture(100.0, [tx("t1", 60.0), tx("t2", 60.0)])
    assert result["status"] == "OVER_CAPTURED"
    assert result["overBy"] == 20.0
    assert result["culprits"] == []
classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrderCapture } from "./flag-over-captured.js";

const tx = (id, chargedAmount, pspReference = "psp_1") => ({ id, pspReference, chargedAmount });

test("OK when single correct capture", () => {
  const result = classifyOrderCapture(100.0, [tx("t1", 100.0)]);
  assert.equal(result.status, "OK");
  assert.equal(result.overBy, 0);
  assert.deepEqual(result.culprits, []);
});

test("OVER_CAPTURED when two full amount transactions", () => {
  const result = classifyOrderCapture(100.0, [tx("t1", 100.0, "psp_1"), tx("t2", 100.0, "psp_2")]);
  assert.equal(result.status, "OVER_CAPTURED");
  assert.equal(result.totalCaptured, 200.0);
  assert.equal(result.overBy, 100.0);
  assert.deepEqual(result.culprits, ["t1", "t2"]);
});

test("OK with partial capture and refund netting to total", () => {
  const result = classifyOrderCapture(100.0, [tx("t1", 60.0), tx("t2", 40.0)]);
  assert.equal(result.status, "OK");
});

test("OVER_CAPTURED with partial plus full duplicate", () => {
  const result = classifyOrderCapture(50.0, [tx("t1", 50.0, "psp_1"), tx("t2", 50.0, "psp_2")]);
  assert.equal(result.status, "OVER_CAPTURED");
  assert.equal(result.overBy, 50.0);
  assert.deepEqual(result.culprits, ["t1", "t2"]);
});

test("floating point rounding within epsilon is OK", () => {
  const result = classifyOrderCapture(99.99, [tx("t1", 100.0)]);
  assert.equal(result.status, "OK");
});

test("culprits sorted by charged amount descending", () => {
  const result = classifyOrderCapture(100.0, [
    tx("small", 5.0),
    tx("big1", 100.0, "psp_1"),
    tx("big2", 150.0, "psp_2"),
  ]);
  assert.equal(result.status, "OVER_CAPTURED");
  assert.deepEqual(result.culprits, ["big2", "big1"]);
});

test("no culprits when over-captured from many small transactions", () => {
  const result = classifyOrderCapture(100.0, [tx("t1", 60.0), tx("t2", 60.0)]);
  assert.equal(result.status, "OVER_CAPTURED");
  assert.equal(result.overBy, 20.0);
  assert.deepEqual(result.culprits, []);
});

Case studies

Webhook redelivery

A payment app's retried webhook doubled a week of orders

A store's payment app briefly lost its connection to Saleor during a deploy. Its retry queue redelivered a batch of CHARGE_SUCCESS notifications hours later, each with a freshly generated pspReference because the app's own retry logic treated the redelivery as a new event. Saleor created a second TransactionItem for every one of them, and a week's worth of orders quietly reported double their real captured amount.

Running the report script in dry run surfaced every affected order in one pass, each with two transaction ids that both independently claimed the full total. Finance reviewed the list, confirmed the customers had only ever been charged once by the actual processor, and authorized the refund step to correct the ledger without touching a single order that was genuinely fine.

Two cards, one order

The over-capture that was not a bug

One flagged order showed two transactions, each for the full order amount, exactly the pattern the classifier looks for. But this time the customer had genuinely split the payment across two cards after the first one was declined mid-checkout, and a support agent had manually recorded both captures because the storefront had shown an error on the first attempt.

Because the script only ever reports and never auto-refunds, this order sat in the report queue instead of getting money pulled back automatically. Finance called the customer, confirmed the double charge was real and unwanted, and only then authorized a refund of the excess, exactly the outcome the report-first design was built to protect.

What good looks like

After this runs on a schedule, an over-captured order stops being an invisible ledger problem and becomes a report row with the exact transactions responsible. Finance decides what to do with every case, and the only automated money movement is a refund of the precise excess, gated behind DRY_RUN and a signed-off decision, never a guess about what Saleor's aggregate totals imply on their own.

FAQ

Why does Saleor let totalCaptured exceed the order total?

Saleor computes totalCaptured by summing chargedAmount across every TransactionItem linked to the order. It only deduplicates CHARGE_SUCCESS events within a single TransactionItem, keyed by type and pspReference. There is no order-level guard that caps the aggregate at order.total, so a second TransactionItem or a second event with a new pspReference reporting a full CHARGE_SUCCESS gets summed right alongside the first one.

What actually causes a duplicate full-amount capture?

A checkout or payment retry, a webhook redelivery that arrives with a new pspReference, or a manual transactionCreate or legacy orderCapture call can each produce a second TransactionItem, or a second event on the same TransactionItem with a different pspReference, that reports CHARGE_SUCCESS for the full order amount. Saleor accepts and sums both because its deduplication only ever looks within one TransactionItem's own events.

Is it safe to auto-refund the excess when totalCaptured is doubled?

No, not automatically. The extra capture might really be a second charge the customer paid on purpose, such as two separate cards, rather than a bug. The safe pattern is to detect and report the overage for finance review first. Any refund should be gated behind DRY_RUN, limited to the exact overBy amount, and only run after a human has signed off on it.

Related field notes

Citations

On the problem:

  1. Captured amount is Twice the Total amount resulting in Outstanding balance. github.com/saleor/saleor/issues/7399
  2. Saleor will authorize and charge more than the order's total if allowed. github.com/saleor/saleor/issues/4162
  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. 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 doubled capture for you?

If this saved your finance team from an unreconciled ledger or a wrong refund, 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