Skip to content

Reconciler Payments & Transactions

Charged amount stays stale after manual capture

A merchant or an app captures a payment straight at the gateway, or fires off a transactionRequestAction, and the money moves. But the gateway's confirmation only comes back asynchronously, so Saleor never gets a chance to update its own books. Order.totalCharged and the transaction's chargedAmount keep sitting at the pre-capture number, chargePendingAmount stays open, and the order looks unpaid even though the customer's card was already charged. Here is why Saleor's ledger goes stale and a script that finds the mismatches against the gateway and reports the true state back, safely.

Python and Node.js Saleor GraphQL API Report-first, human sign-off required
A card at a payment terminal
Photo by Clay Banks on Unsplash
The short answer

Saleor's payments model is not a state machine that watches the gateway. Order.totalCharged and TransactionItem.chargedAmount are derived fields, recalculated only from the ledger of TransactionEvent records attached to a TransactionItem, never read live from the payment provider. When a manual capture happens directly at the gateway, or through transactionRequestAction, but the result only comes back asynchronously, Saleor has nothing to recalculate from until an app explicitly calls transactionEventReport (or transactionUpdate) with a CHARGE_SUCCESS event and the gateway's pspReference. Until that call lands, chargePendingAmount stays open and the order's totalCharged and chargeStatus keep reflecting the pre-capture state. Run a small Python or Node.js script that pages through orders and transactions, cross-checks each one against the gateway by pspReference, and reports every confirmed capture Saleor does not know about yet, safely and idempotently. Full code, tests, and a dry run guard are below.

The problem in plain words

Saleor never asks the payment gateway "did this actually go through." It only knows what it has been told. Every number that matters on an order, totalCharged, totalAuthorized, chargeStatus, is computed by summing up the TransactionEvent records that an app or webhook has reported against a TransactionItem. If no event says the charge succeeded, Saleor has no reason to believe it did, no matter what actually happened at the gateway.

A manual capture breaks that assumption in a very ordinary way. Someone, a merchant, a support agent, or an app, triggers a capture directly against the gateway, or fires a transactionRequestAction mutation that asks the app to go capture it. The gateway accepts the request and starts processing, but its confirmation comes back later, asynchronously, on its own webhook or polling schedule. Until the app receives that confirmation and turns around and reports it to Saleor with transactionEventReport, nothing changes on the Saleor side. The money has moved. Saleor's books have not.

Manual capture sent to the gateway Gateway succeeds confirms later, async no CHARGE_SUCCESS yet chargePendingAmount stays open totalCharged stays stale Fix: an app must call transactionEventReport(CHARGE_SUCCESS) before Saleor's ledger will move
The gateway already moved the money, but Saleor's totalCharged only recalculates from reported TransactionEvent records, so it stays stuck at the pre-capture state until an app reports back.

Why it happens

Saleor's transactions model was built around apps reporting events, not around Saleor watching gateways. That design is deliberate, since Saleor supports many payment providers with very different APIs, but it creates a real gap in a few common situations:

In every case, Saleor is behaving exactly as designed. It has no event to recalculate from, so it does not change the numbers. The mismatch only becomes visible when someone cross-checks the order against the gateway directly and finds money that Saleor's ledger does not know about yet. See the citations at the end for the exact docs this behavior comes from.

The key insight

Saleor will never poll a gateway on its own. If a capture happened outside the normal event flow, the only way to fix the ledger is to explicitly tell Saleor about it with transactionEventReport. That call is idempotent by pspReference, type, and amount, so reporting a real success is always safe to retry. What is not safe is guessing. If the gateway and Saleor disagree in a way that is not a clean "gateway succeeded, Saleor missed it," the right move is to flag the order for a human, not to write a number that might be wrong.

The fix, as a flow

We do not touch checkout or the capture flow itself. We add a job that lists candidate orders and their transactions, looks up each transaction's authoritative state at the gateway by pspReference, and only when the gateway confirms a success Saleor has not recorded reports it back with transactionEventReport. Anything ambiguous gets flagged for finance instead of guessed at.

Scheduled job runs on a timer List orders and their transactions Look up pspReference at the gateway Gateway confirms, no event yet? yes ambiguous, flag it transactionEventReport ledger catches up
The script only reports a success when the gateway is unambiguous and Saleor has no matching event. Anything else, including a mismatch in the wrong direction, is flagged for a human instead.

Build it step by step

1

Get an app token and set the environment

Create an app in Saleor Dashboard with permission to manage orders and payments, or use a staff JWT from tokenCreate. Keep the API URL and token in environment variables, and keep DRY_RUN on until you have reviewed the report.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export GATEWAY_API_URL="https://gateway.example.com/v1"
export GATEWAY_API_KEY="..."
export STALE_MINUTES="15"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export GATEWAY_API_URL="https://gateway.example.com/v1"
export GATEWAY_API_KEY="..."
export STALE_MINUTES="15"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Saleor GraphQL API

Everything is one endpoint, one header. A small helper sends a query or mutation and returns the data, and raises if Saleor reports an error. We reuse this for reading orders and for the report mutation.

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

List candidate orders and their transactions

Pull orders with their totals, charge status, and every TransactionItem underneath, including the pending and charged amounts and the events already recorded. We page through with a cursor so a large store is not a problem.

step3.py
ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number chargeStatus isPaid
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        transactions {
          id pspReference
          chargedAmount { amount currency }
          chargePendingAmount { amount currency }
          authorizedAmount { amount currency }
          events { type pspReference createdAt }
        }
      }
    }
  }
}"""

def candidate_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: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number chargeStatus isPaid
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        transactions {
          id pspReference
          chargedAmount { amount currency }
          chargePendingAmount { amount currency }
          authorizedAmount { amount currency }
          events { type pspReference createdAt }
        }
      }
    }
  }
}`;

async function* candidateOrders() {
  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

Look up the authoritative state at the gateway

For each transaction with a nonzero chargePendingAmount, call the gateway's own capture lookup API by pspReference. This is the one call that actually knows whether the money moved, since Saleor never asks this question on its own.

gateway.py
GATEWAY_API_URL = os.environ.get("GATEWAY_API_URL", "")
GATEWAY_API_KEY = os.environ.get("GATEWAY_API_KEY", "")

def fetch_gateway_capture(psp_reference):
    r = requests.get(
        f"{GATEWAY_API_URL}/charges/{psp_reference}",
        headers={"Authorization": f"Bearer {GATEWAY_API_KEY}"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    return {
        "pspReference": psp_reference,
        "capturedAmount": body["capturedAmount"],
        "status": body["status"],  # "succeeded" | "failed" | "pending"
    }
gateway.js
const GATEWAY_API_URL = process.env.GATEWAY_API_URL || "";
const GATEWAY_API_KEY = process.env.GATEWAY_API_KEY || "";

async function fetchGatewayCapture(pspReference) {
  const res = await fetch(`${GATEWAY_API_URL}/charges/${pspReference}`, {
    headers: { Authorization: `Bearer ${GATEWAY_API_KEY}` },
  });
  if (!res.ok) throw new Error(`Gateway ${res.status}`);
  const body = await res.json();
  return {
    pspReference,
    capturedAmount: body.capturedAmount,
    status: body.status, // "succeeded" | "failed" | "pending"
  };
}
5

Decide, with one pure function

Keep the reconciliation decision in its own function that takes the Saleor transaction and the gateway's capture and returns one of four outcomes. It never calls the network itself, which makes it trivial to test with plain objects. If the gateway is still pending there is nothing to reconcile yet. If it succeeded and Saleor has no matching event, we either need to report a success or, if Saleor already shows more charged than the gateway confirms, flag it instead of trusting a report. If the gateway failed and Saleor still shows an open pending amount, we need to report the failure so the ledger stops waiting on money that is never coming.

classify.py
def classify_charge_reconciliation(saleor_txn, gateway_capture):
    status = gateway_capture["status"]

    if status == "pending":
        return "IN_SYNC"

    if status == "succeeded":
        has_matching_success = any(
            e.get("type") == "CHARGE_SUCCESS" and e.get("pspReference") == gateway_capture["pspReference"]
            for e in saleor_txn.get("events", [])
        )
        if not has_matching_success:
            if saleor_txn["chargedAmount"] < gateway_capture["capturedAmount"]:
                return "NEEDS_REPORT_SUCCESS"
            if saleor_txn["chargedAmount"] > gateway_capture["capturedAmount"]:
                return "AMOUNT_MISMATCH_FLAG"
        return "IN_SYNC"

    if status == "failed" and saleor_txn.get("chargePendingAmount", 0) > 0:
        return "NEEDS_REPORT_FAILURE"

    return "IN_SYNC"
classify.js
export function classifyChargeReconciliation(saleorTxn, gatewayCapture) {
  const { status, pspReference, capturedAmount } = gatewayCapture;

  if (status === "pending") return "IN_SYNC";

  if (status === "succeeded") {
    const hasMatchingSuccess = (saleorTxn.events || []).some(
      (e) => e.type === "CHARGE_SUCCESS" && e.pspReference === pspReference
    );
    if (!hasMatchingSuccess) {
      if (saleorTxn.chargedAmount < capturedAmount) return "NEEDS_REPORT_SUCCESS";
      if (saleorTxn.chargedAmount > capturedAmount) return "AMOUNT_MISMATCH_FLAG";
    }
    return "IN_SYNC";
  }

  if (status === "failed" && (saleorTxn.chargePendingAmount || 0) > 0) {
    return "NEEDS_REPORT_FAILURE";
  }

  return "IN_SYNC";
}
6

Report the confirmed success or failure back to Saleor

When the decision is NEEDS_REPORT_SUCCESS or NEEDS_REPORT_FAILURE, call transactionEventReport with the gateway's own amount and pspReference. Saleor dedupes on (id, type, pspReference, amount), so calling it again with the same values is a no-op reported back as alreadyReported. AMOUNT_MISMATCH_FLAG is never auto-written. It is only ever logged for finance to look at by hand.

report.py
REPORT_MUTATION = """
mutation TransactionEventReport($id: ID!, $type: TransactionEventTypeEnum!, $amount: PositiveDecimal!, $pspReference: String!, $availableActions: [TransactionActionEnum!]) {
  transactionEventReport(id: $id, type: $type, amount: $amount, pspReference: $pspReference, availableActions: $availableActions) {
    alreadyReported
    transaction { id chargedAmount { amount } }
    errors { field message code }
  }
}"""

def report_event(transaction_id, event_type, amount, psp_reference, available_actions=None):
    result = gql(REPORT_MUTATION, {
        "id": transaction_id,
        "type": event_type,
        "amount": amount,
        "pspReference": psp_reference,
        "availableActions": available_actions or [],
    })["transactionEventReport"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result
report.js
const REPORT_MUTATION = `
mutation TransactionEventReport($id: ID!, $type: TransactionEventTypeEnum!, $amount: PositiveDecimal!, $pspReference: String!, $availableActions: [TransactionActionEnum!]) {
  transactionEventReport(id: $id, type: $type, amount: $amount, pspReference: $pspReference, availableActions: $availableActions) {
    alreadyReported
    transaction { id chargedAmount { amount } }
    errors { field message code }
  }
}`;

async function reportEvent(transactionId, eventType, amount, pspReference, availableActions = []) {
  const result = (await gql(REPORT_MUTATION, {
    id: transactionId,
    type: eventType,
    amount,
    pspReference,
    availableActions,
  })).transactionEventReport;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result;
}
Run it safe

Always start with DRY_RUN=true. The script should only ever report a CHARGE_SUCCESS or CHARGE_FAILURE event when the gateway's status is unambiguous. Any AMOUNT_MISMATCH_FLAG result goes straight to a report for finance, never to a write.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because transactionEventReport is idempotent by pspReference, type, and amount.

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.
reconcile_charged_amount.py
"""Reconcile Saleor orders whose charged amount stays stale after a manual capture.

Saleor's totalCharged and TransactionItem.chargedAmount only recalculate from
reported TransactionEvent records, never live from the gateway. A manual capture
made outside the normal event flow leaves chargePendingAmount open until an app
reports it back. This script cross-checks stalled transactions against the
gateway by pspReference and reports the confirmed state back with
transactionEventReport. Ambiguous mismatches are flagged for finance, never
auto-written. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
GATEWAY_API_URL = os.environ.get("GATEWAY_API_URL", "")
GATEWAY_API_KEY = os.environ.get("GATEWAY_API_KEY", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number chargeStatus isPaid
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        transactions {
          id pspReference
          chargedAmount { amount currency }
          chargePendingAmount { amount currency }
          authorizedAmount { amount currency }
          events { type pspReference createdAt }
        }
      }
    }
  }
}"""

REPORT_MUTATION = """
mutation TransactionEventReport($id: ID!, $type: TransactionEventTypeEnum!, $amount: PositiveDecimal!, $pspReference: String!, $availableActions: [TransactionActionEnum!]) {
  transactionEventReport(id: $id, type: $type, amount: $amount, pspReference: $pspReference, availableActions: $availableActions) {
    alreadyReported
    transaction { id chargedAmount { amount } }
    errors { field message code }
  }
}"""


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 fetch_gateway_capture(psp_reference):
    r = requests.get(
        f"{GATEWAY_API_URL}/charges/{psp_reference}",
        headers={"Authorization": f"Bearer {GATEWAY_API_KEY}"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    return {
        "pspReference": psp_reference,
        "capturedAmount": body["capturedAmount"],
        "status": body["status"],
    }


def classify_charge_reconciliation(saleor_txn, gateway_capture):
    status = gateway_capture["status"]

    if status == "pending":
        return "IN_SYNC"

    if status == "succeeded":
        has_matching_success = any(
            e.get("type") == "CHARGE_SUCCESS" and e.get("pspReference") == gateway_capture["pspReference"]
            for e in saleor_txn.get("events", [])
        )
        if not has_matching_success:
            if saleor_txn["chargedAmount"] < gateway_capture["capturedAmount"]:
                return "NEEDS_REPORT_SUCCESS"
            if saleor_txn["chargedAmount"] > gateway_capture["capturedAmount"]:
                return "AMOUNT_MISMATCH_FLAG"
        return "IN_SYNC"

    if status == "failed" and saleor_txn.get("chargePendingAmount", 0) > 0:
        return "NEEDS_REPORT_FAILURE"

    return "IN_SYNC"


def report_event(transaction_id, event_type, amount, psp_reference):
    result = gql(REPORT_MUTATION, {
        "id": transaction_id,
        "type": event_type,
        "amount": amount,
        "pspReference": psp_reference,
        "availableActions": [],
    })["transactionEventReport"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result


def candidate_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 run():
    reported = 0
    flagged = 0
    for order in candidate_orders():
        for txn in order.get("transactions", []):
            psp_reference = txn.get("pspReference")
            pending = (txn.get("chargePendingAmount") or {}).get("amount", 0)
            if not psp_reference or not pending:
                continue

            saleor_txn = {
                "chargedAmount": (txn.get("chargedAmount") or {}).get("amount", 0),
                "chargePendingAmount": pending,
                "events": txn.get("events", []),
            }
            gateway_capture = fetch_gateway_capture(psp_reference)
            decision = classify_charge_reconciliation(saleor_txn, gateway_capture)

            if decision == "NEEDS_REPORT_SUCCESS":
                log.info("Order %s txn %s: gateway confirms capture. %s",
                          order["number"], txn["id"], "would report" if DRY_RUN else "reporting")
                if not DRY_RUN:
                    report_event(txn["id"], "CHARGE_SUCCESS", gateway_capture["capturedAmount"], psp_reference)
                reported += 1
            elif decision == "NEEDS_REPORT_FAILURE":
                log.info("Order %s txn %s: gateway confirms failure. %s",
                          order["number"], txn["id"], "would report" if DRY_RUN else "reporting")
                if not DRY_RUN:
                    report_event(txn["id"], "CHARGE_FAILURE", pending, psp_reference)
                reported += 1
            elif decision == "AMOUNT_MISMATCH_FLAG":
                log.warning("Order %s txn %s: amount mismatch, flagged for finance review.",
                            order["number"], txn["id"])
                flagged += 1

    log.info("Done. %d event(s) %s, %d flagged for review.",
              reported, "to report" if DRY_RUN else "reported", flagged)


if __name__ == "__main__":
    run()
reconcile-charged-amount.js
/**
 * Reconcile Saleor orders whose charged amount stays stale after a manual capture.
 *
 * Saleor's totalCharged and TransactionItem.chargedAmount only recalculate from
 * reported TransactionEvent records, never live from the gateway. A manual capture
 * made outside the normal event flow leaves chargePendingAmount open until an app
 * reports it back. This script cross-checks stalled transactions against the
 * gateway by pspReference and reports the confirmed state back with
 * transactionEventReport. Ambiguous mismatches are flagged for finance, never
 * auto-written. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/saleor/charged-amount-stale-after-manual-capture/
 */
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 GATEWAY_API_URL = process.env.GATEWAY_API_URL || "https://gateway.example.com/v1";
const GATEWAY_API_KEY = process.env.GATEWAY_API_KEY || "dummy-key";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function classifyChargeReconciliation(saleorTxn, gatewayCapture) {
  const { status, pspReference, capturedAmount } = gatewayCapture;

  if (status === "pending") return "IN_SYNC";

  if (status === "succeeded") {
    const hasMatchingSuccess = (saleorTxn.events || []).some(
      (e) => e.type === "CHARGE_SUCCESS" && e.pspReference === pspReference
    );
    if (!hasMatchingSuccess) {
      if (saleorTxn.chargedAmount < capturedAmount) return "NEEDS_REPORT_SUCCESS";
      if (saleorTxn.chargedAmount > capturedAmount) return "AMOUNT_MISMATCH_FLAG";
    }
    return "IN_SYNC";
  }

  if (status === "failed" && (saleorTxn.chargePendingAmount || 0) > 0) {
    return "NEEDS_REPORT_FAILURE";
  }

  return "IN_SYNC";
}

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;
}

async function fetchGatewayCapture(pspReference) {
  const res = await fetch(`${GATEWAY_API_URL}/charges/${pspReference}`, {
    headers: { Authorization: `Bearer ${GATEWAY_API_KEY}` },
  });
  if (!res.ok) throw new Error(`Gateway ${res.status}`);
  const body = await res.json();
  return { pspReference, capturedAmount: body.capturedAmount, status: body.status };
}

const ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number chargeStatus isPaid
        totalCharged { amount currency }
        totalAuthorized { amount currency }
        transactions {
          id pspReference
          chargedAmount { amount currency }
          chargePendingAmount { amount currency }
          authorizedAmount { amount currency }
          events { type pspReference createdAt }
        }
      }
    }
  }
}`;

const REPORT_MUTATION = `
mutation TransactionEventReport($id: ID!, $type: TransactionEventTypeEnum!, $amount: PositiveDecimal!, $pspReference: String!, $availableActions: [TransactionActionEnum!]) {
  transactionEventReport(id: $id, type: $type, amount: $amount, pspReference: $pspReference, availableActions: $availableActions) {
    alreadyReported
    transaction { id chargedAmount { amount } }
    errors { field message code }
  }
}`;

async function* candidateOrders() {
  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 reportEvent(transactionId, eventType, amount, pspReference) {
  const result = (await gql(REPORT_MUTATION, {
    id: transactionId,
    type: eventType,
    amount,
    pspReference,
    availableActions: [],
  })).transactionEventReport;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result;
}

export async function run() {
  let reported = 0;
  let flagged = 0;
  for await (const order of candidateOrders()) {
    for (const txn of order.transactions || []) {
      const pspReference = txn.pspReference;
      const pending = txn.chargePendingAmount?.amount || 0;
      if (!pspReference || !pending) continue;

      const saleorTxn = {
        chargedAmount: txn.chargedAmount?.amount || 0,
        chargePendingAmount: pending,
        events: txn.events || [],
      };
      const gatewayCapture = await fetchGatewayCapture(pspReference);
      const decision = classifyChargeReconciliation(saleorTxn, gatewayCapture);

      if (decision === "NEEDS_REPORT_SUCCESS") {
        console.log(`Order ${order.number} txn ${txn.id}: gateway confirms capture. ${DRY_RUN ? "would report" : "reporting"}`);
        if (!DRY_RUN) await reportEvent(txn.id, "CHARGE_SUCCESS", gatewayCapture.capturedAmount, pspReference);
        reported++;
      } else if (decision === "NEEDS_REPORT_FAILURE") {
        console.log(`Order ${order.number} txn ${txn.id}: gateway confirms failure. ${DRY_RUN ? "would report" : "reporting"}`);
        if (!DRY_RUN) await reportEvent(txn.id, "CHARGE_FAILURE", pending, pspReference);
        reported++;
      } else if (decision === "AMOUNT_MISMATCH_FLAG") {
        console.warn(`Order ${order.number} txn ${txn.id}: amount mismatch, flagged for finance review.`);
        flagged++;
      }
    }
  }
  console.log(`Done. ${reported} event(s) ${DRY_RUN ? "to report" : "reported"}, ${flagged} 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 classifier is the part most worth testing, because it decides whether Saleor's ledger gets written to or an order gets flagged for a human. Because classify_charge_reconciliation is pure, no network or Saleor account is needed. It just takes plain objects and checks the decision.

test_charged_reconciliation.py
from reconcile_charged_amount import classify_charge_reconciliation


def saleor_txn(**over):
    base = {"chargedAmount": 0, "chargePendingAmount": 100, "events": []}
    base.update(over)
    return base


def gateway(**over):
    base = {"pspReference": "psp_1", "capturedAmount": 100, "status": "succeeded"}
    base.update(over)
    return base


def test_pending_gateway_is_in_sync():
    assert classify_charge_reconciliation(saleor_txn(), gateway(status="pending")) == "IN_SYNC"


def test_succeeded_with_no_matching_event_needs_report_success():
    assert classify_charge_reconciliation(saleor_txn(chargedAmount=0), gateway()) == "NEEDS_REPORT_SUCCESS"


def test_succeeded_with_matching_event_is_in_sync():
    txn = saleor_txn(chargedAmount=100, events=[{"type": "CHARGE_SUCCESS", "pspReference": "psp_1"}])
    assert classify_charge_reconciliation(txn, gateway()) == "IN_SYNC"


def test_saleor_over_reports_flags_mismatch():
    txn = saleor_txn(chargedAmount=150, events=[])
    assert classify_charge_reconciliation(txn, gateway()) == "AMOUNT_MISMATCH_FLAG"


def test_failed_gateway_with_open_pending_needs_report_failure():
    txn = saleor_txn(chargePendingAmount=100)
    assert classify_charge_reconciliation(txn, gateway(status="failed")) == "NEEDS_REPORT_FAILURE"


def test_failed_gateway_with_no_pending_is_in_sync():
    txn = saleor_txn(chargePendingAmount=0)
    assert classify_charge_reconciliation(txn, gateway(status="failed")) == "IN_SYNC"
reconcile.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyChargeReconciliation } from "./reconcile-charged-amount.js";

const saleorTxn = (over = {}) => ({ chargedAmount: 0, chargePendingAmount: 100, events: [], ...over });
const gateway = (over = {}) => ({ pspReference: "psp_1", capturedAmount: 100, status: "succeeded", ...over });

test("pending gateway is in sync", () => {
  assert.equal(classifyChargeReconciliation(saleorTxn(), gateway({ status: "pending" })), "IN_SYNC");
});

test("succeeded with no matching event needs report success", () => {
  assert.equal(classifyChargeReconciliation(saleorTxn({ chargedAmount: 0 }), gateway()), "NEEDS_REPORT_SUCCESS");
});

test("succeeded with matching event is in sync", () => {
  const txn = saleorTxn({ chargedAmount: 100, events: [{ type: "CHARGE_SUCCESS", pspReference: "psp_1" }] });
  assert.equal(classifyChargeReconciliation(txn, gateway()), "IN_SYNC");
});

test("saleor over-reports flags mismatch", () => {
  const txn = saleorTxn({ chargedAmount: 150, events: [] });
  assert.equal(classifyChargeReconciliation(txn, gateway()), "AMOUNT_MISMATCH_FLAG");
});

test("failed gateway with open pending needs report failure", () => {
  const txn = saleorTxn({ chargePendingAmount: 100 });
  assert.equal(classifyChargeReconciliation(txn, gateway({ status: "failed" })), "NEEDS_REPORT_FAILURE");
});

test("failed gateway with no pending is in sync", () => {
  const txn = saleorTxn({ chargePendingAmount: 0 });
  assert.equal(classifyChargeReconciliation(txn, gateway({ status: "failed" })), "IN_SYNC");
});

Case studies

Manual dashboard capture

The support agent who captured straight at the gateway

A support agent needed to release a held order fast, so they logged into the payment gateway's dashboard directly and captured the authorization by hand, skipping the Saleor app entirely. The order kept showing chargeStatus: NONE and an open chargePendingAmount for two days, and the warehouse would not ship because the order still looked unpaid.

Running the reconciliation job found the transaction's pspReference, looked it up at the gateway, confirmed a clean succeeded capture with no matching CHARGE_SUCCESS event in Saleor, and reported it. totalCharged updated immediately and the order was released for fulfillment the same day.

Webhook that never arrived

An app crash between the request and the report

An app called transactionRequestAction to trigger a capture, the gateway processed it successfully, but the app's worker crashed before it could call transactionEventReport with the result. The webhook redelivery queue on the gateway side had already exhausted its retries by the time anyone noticed, so nothing was ever going to call Saleor back.

The nightly reconciliation run caught nine transactions in that exact state, all with a confirmed gateway success and a stale Saleor record. All nine were unambiguous, so the script reported them and the ledger caught up without anyone touching a dashboard.

What good looks like

After this runs on a schedule, a manual capture stops being an invisible gap between the gateway and Saleor's ledger. Every stale transaction gets checked against the authority that actually knows what happened, and totalCharged catches up on its own for the clean cases. Anything that does not resolve cleanly, an amount that runs the wrong direction, a reversed or ambiguous capture, goes to a human instead of getting written on a guess.

FAQ

Why does Saleor still show the old charged amount after I captured the payment at the gateway?

Saleor's payments model is not a state machine that polls the gateway. Order.totalCharged and TransactionItem.chargedAmount are derived fields recalculated only from the TransactionEvent records attached to a TransactionItem. A manual capture made directly at the gateway produces no such event until an app reports it back with transactionEventReport or transactionUpdate, so the order keeps reflecting the pre-capture state even though the money has already moved.

What is chargePendingAmount and why does it stay nonzero?

chargePendingAmount tracks money Saleor expects to be captured but has not yet confirmed with a CHARGE_SUCCESS or CHARGE_FAILURE event. If a manual or asynchronous capture is only reported back later, or never reported at all, that pending balance sits open indefinitely because nothing in Saleor recalculates it on its own.

Is it safe to auto-report a CHARGE_SUCCESS event when I find a mismatch?

Only when the gateway confirms a successful capture that Saleor has no matching event for, and the amount direction is unambiguous. transactionEventReport is idempotent by pspReference, type, and amount, so re-reporting a real success is safe. If the gateway shows less than Saleor recorded, or a failed or reversed capture, do not report a synthetic success. Flag that order for manual finance review instead.

Related field notes

Citations

On the problem:

  1. Saleor Commerce Documentation: transactions and how chargedAmount is derived. docs.saleor.io/developer/payments/transactions
  2. Saleor Commerce Documentation: the payments lifecycle. docs.saleor.io/developer/payments/lifecycle
  3. Saleor Commerce Documentation: payment events (legacy). docs.saleor.io/developer/extending/webhooks/synchronous-events/payment

On the solution:

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

If this saved your team from a stuck fulfillment or an untrustworthy 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