Skip to content

Repair Vouchers & Gift Cards

Gift card balance not restored on order cancellation

A customer paid with a gift card, the order got cancelled for some ordinary reason, stock came back, the order flipped to CANCELED, and everything looked clean. Except the gift card balance never came back. The money the customer had on that card is just gone from Saleor's point of view, and nothing in the dashboard flags it. Here is why Saleor leaves this gap on purpose and a script that finds every affected card and tops it back up safely.

Python and Node.js Saleor GraphQL API Safe by default (dry run)
A discount coupon
Photo by Tamanna Rumee on Unsplash
The short answer

Saleor's orderCancel releases stock allocations and marks the order CANCELED, but it does not run any compensating logic against a gift card's currentBalance for gift cards used as payment on that order. Saleor's own documentation says this outright: cancelling an order does not refund or restore the gift card balance, and store operators are expected to manually run giftCardUpdate to top it back up. The gap exists because debiting a gift card happens inside payment processing, recorded as a GiftCardEvent of type USED_IN_ORDER, which is decoupled from order status transitions, so no signal ever fires to reverse it. Run a small Python or Node.js script that finds cancelled orders that used a gift card, cross-references the card's event history to confirm the debit was never reversed, and calls giftCardUpdate to restore the balance, capped at the card's original initial balance. Full code, tests, and a dry run guard are below.

The problem in plain words

A gift card in Saleor works like a small prepaid account. When a customer spends part of it on an order, Saleor debits currentBalance right there during payment processing, and writes a GiftCardEvent of type USED_IN_ORDER so there is a record of exactly how much was taken and on which order.

Later, the order gets cancelled. Maybe the customer changed their mind, maybe a fraud check failed, maybe an operator cancelled it by mistake. Saleor's orderCancel mutation does its usual cleanup: it releases any stock that was allocated, and it moves the order to CANCELED. That is the entire scope of what cancellation touches. Nobody told the gift card system that the payment behind this order no longer counts, so the balance stays exactly where the debit left it. The order is cancelled, the goods never shipped, and the customer's gift card is quietly short by whatever it paid.

Gift card pays currentBalance debited Order cancelled stock released no signal to gift card currentBalance never credited back no compensating logic balance stays short
Cancellation only releases stock and flips the order status. It never reaches back into the gift card that paid for it, so the debit stands even though the order never shipped.

Why it happens

Community reports back this up. Store operators have raised this exact gap when trying to delete or unfulfill orders that carry a gift card and finding no built-in path back to a correct balance, and Saleor's issue tracker shows cancellation flows that were never designed to touch payment instruments like gift cards. See the citations at the end for the exact docs and threads.

The key insight

The fix is not to make Saleor's cancellation flow smarter. It is to treat the gift card ledger as something you reconcile after the fact, the same way you would reconcile any other payment instrument against order status. You already have the event history: a USED_IN_ORDER event that debited the card, and an order that is now CANCELED. If no credit ever followed that debit, the fix is a straightforward, capped restoration, never a blind top-up, so a card that was used on two orders and already restored once does not get restored twice.

The fix, as a flow

The script pages through cancelled orders that used a gift card, reads each linked gift card's balances and event history, and runs one pure function to decide whether that card needs restoring and to how much. Only orders with a real, unreversed USED_IN_ORDER debit get touched, and the restored amount is always capped at the card's own initial balance.

Scheduled job or run on demand List cancelled orders status CANCELED, giftCardUsed Read gift card balances, USED_IN_ORDER events restore owed? yes no, skip giftCardUpdate balance restored, capped
The script only restores a balance when a real, unreversed debit exists for that exact cancelled order, and it never restores above the card's original initial balance.

Build it step by step

1

Get an app token with order and gift card access

Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and to manage gift cards, since restoring a balance calls giftCardUpdate. 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, 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="your-app-or-staff-token"
export DRY_RUN="true"   // start safe, change to false to write
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

List cancelled orders that used a gift card

Query orders(filter: { status: [CANCELED], giftCardUsed: true }) and read each order's id, number, status, and linked giftCards { id currentBalance { amount currency } initialBalance { amount currency } }. Page with a cursor so the job handles a large backlog.

step3.py
CANCELLED_GIFT_CARD_ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 50, after: $cursor, filter: { status: [CANCELED], giftCardUsed: true }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        giftCards {
          id
          currentBalance { amount currency }
          initialBalance { amount currency }
        }
      }
    }
  }
}"""

def cancelled_gift_card_orders():
    cursor = None
    while True:
        data = gql(CANCELLED_GIFT_CARD_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 CANCELLED_GIFT_CARD_ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 50, after: $cursor, filter: { status: [CANCELED], giftCardUsed: true }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        giftCards {
          id
          currentBalance { amount currency }
          initialBalance { amount currency }
        }
      }
    }
  }
}`;

async function* cancelledGiftCardOrders() {
  let cursor = null;
  while (true) {
    const data = (await gql(CANCELLED_GIFT_CARD_ORDERS_QUERY, { cursor })).orders;
    for (const edge of data.edges) yield edge.node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Read each gift card's event history to find the un-reversed debit

For every gift card linked to a cancelled order, query giftCard(id) { events { type balance { initialBalance currentBalance } orderId } }, filter to type USED_IN_ORDER where orderId matches the cancelled order, and check whether any balance-credit event followed it. Turn that into the plain shape the decision function expects: the card's current and initial balance, the order it was used on, how much was used, and whether it was already restored.

step4.py
GIFT_CARD_EVENTS_QUERY = """
query($id: ID!) {
  giftCard(id: $id) {
    id
    currentBalance { amount currency }
    initialBalance { amount currency }
    events {
      type
      orderId
      balance { initialBalance currentBalance }
    }
  }
}"""

def build_gift_card_usage(order_id, gift_card_id):
    card = gql(GIFT_CARD_EVENTS_QUERY, {"id": gift_card_id})["giftCard"]
    events = card["events"] or []

    used_events = [e for e in events if e["type"] == "USED_IN_ORDER" and e["orderId"] == order_id]
    if not used_events:
        return None

    used_event = used_events[0]
    amount_used = used_event["balance"]["initialBalance"] - used_event["balance"]["currentBalance"]

    already_restored = any(
        e["type"] != "USED_IN_ORDER" and e["orderId"] == order_id for e in events
    )

    return {
        "giftCardId": card["id"],
        "currentBalanceAmount": card["currentBalance"]["amount"],
        "initialBalanceAmount": card["initialBalance"]["amount"],
        "usedInOrderId": order_id,
        "amountUsed": amount_used,
        "alreadyRestored": already_restored,
    }
step4.js
const GIFT_CARD_EVENTS_QUERY = `
query($id: ID!) {
  giftCard(id: $id) {
    id
    currentBalance { amount currency }
    initialBalance { amount currency }
    events {
      type
      orderId
      balance { initialBalance currentBalance }
    }
  }
}`;

async function buildGiftCardUsage(orderId, giftCardId) {
  const card = (await gql(GIFT_CARD_EVENTS_QUERY, { id: giftCardId })).giftCard;
  const events = card.events || [];

  const usedEvents = events.filter((e) => e.type === "USED_IN_ORDER" && e.orderId === orderId);
  if (usedEvents.length === 0) return null;

  const usedEvent = usedEvents[0];
  const amountUsed = usedEvent.balance.initialBalance - usedEvent.balance.currentBalance;

  const alreadyRestored = events.some((e) => e.type !== "USED_IN_ORDER" && e.orderId === orderId);

  return {
    giftCardId: card.id,
    currentBalanceAmount: card.currentBalance.amount,
    initialBalanceAmount: card.initialBalance.amount,
    usedInOrderId: orderId,
    amountUsed,
    alreadyRestored,
  };
}
5

Decide, with one pure function

Keep the decision in its own function that takes the order and a list of gift card usage records and returns the exact restorations to make. It is pure, no I/O, easy to test. For an order that is not CANCELED, it plans nothing. For each usage tied to that order that has not already been restored, it computes restoreToAmount = currentBalanceAmount + amountUsed, capped at the card's initialBalanceAmount. If the uncapped amount would exceed the initial balance by more than a small rounding epsilon, it skips that usage entirely rather than silently clamp it, since that pattern usually means a double-restoration or drifted data that a human should look at first.

decide.py
ROUNDING_EPSILON = 0.01


def plan_gift_card_restoration(order, gift_card_usages):
    if order.get("status") != "CANCELED":
        return []

    plans = []
    for usage in gift_card_usages:
        if usage["usedInOrderId"] != order["id"]:
            continue
        if usage["alreadyRestored"]:
            continue
        if usage["amountUsed"] <= 0:
            continue

        restore_to_amount = usage["currentBalanceAmount"] + usage["amountUsed"]
        overshoot = restore_to_amount - usage["initialBalanceAmount"]
        if overshoot > ROUNDING_EPSILON:
            continue  # anomaly: would exceed initial balance, do not clamp silently

        restore_to_amount = min(restore_to_amount, usage["initialBalanceAmount"])
        plans.append({
            "giftCardId": usage["giftCardId"],
            "restoreToAmount": restore_to_amount,
            "reason": "order_cancelled_gift_card_not_refunded",
        })

    return plans
decide.js
const ROUNDING_EPSILON = 0.01;

export function planGiftCardRestoration(order, giftCardUsages) {
  if (order.status !== "CANCELED") return [];

  const plans = [];
  for (const usage of giftCardUsages) {
    if (usage.usedInOrderId !== order.id) continue;
    if (usage.alreadyRestored) continue;
    if (usage.amountUsed <= 0) continue;

    let restoreToAmount = usage.currentBalanceAmount + usage.amountUsed;
    const overshoot = restoreToAmount - usage.initialBalanceAmount;
    if (overshoot > ROUNDING_EPSILON) continue; // anomaly: would exceed initial balance, do not clamp silently

    restoreToAmount = Math.min(restoreToAmount, usage.initialBalanceAmount);
    plans.push({
      giftCardId: usage.giftCardId,
      restoreToAmount,
      reason: "order_cancelled_gift_card_not_refunded",
    });
  }

  return plans;
}
6

Restore the balance with a hard overwrite, never a delta

The giftCardUpdate mutation's balanceAmount input performs a hard overwrite of both currentBalance and initialBalance, so the script always passes the plan's absolute restoreToAmount, never a delta on top of whatever the card currently holds. Re-fetch the card immediately before writing, so a card used on another order in the interim is not clobbered by a stale plan, that is, a lost update. Log every mutation's userErrors, and re-verify with a follow-up giftCard(id) { currentBalance { amount } } query.

apply.py
GIFT_CARD_UPDATE = """
mutation($id: ID!, $amount: Decimal!, $currency: String!) {
  giftCardUpdate(id: $id, input: { balanceAmount: { amount: $amount, currency: $currency } }) {
    giftCard { id currentBalance { amount currency } }
    errors { field code message }
  }
}"""

GIFT_CARD_BALANCE_QUERY = """
query($id: ID!) {
  giftCard(id: $id) { id currentBalance { amount currency } }
}"""


def restore_gift_card_balance(gift_card_id, restore_to_amount, currency):
    # Re-fetch immediately before writing to avoid a lost update if the card
    # was used on another order in the interim.
    fresh = gql(GIFT_CARD_BALANCE_QUERY, {"id": gift_card_id})["giftCard"]

    result = gql(
        GIFT_CARD_UPDATE,
        {"id": gift_card_id, "amount": restore_to_amount, "currency": currency},
    )["giftCardUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])

    verify = gql(GIFT_CARD_BALANCE_QUERY, {"id": gift_card_id})["giftCard"]
    return {"before": fresh["currentBalance"]["amount"], "after": verify["currentBalance"]["amount"]}
apply.js
const GIFT_CARD_UPDATE = `
mutation($id: ID!, $amount: Decimal!, $currency: String!) {
  giftCardUpdate(id: $id, input: { balanceAmount: { amount: $amount, currency: $currency } }) {
    giftCard { id currentBalance { amount currency } }
    errors { field code message }
  }
}`;

const GIFT_CARD_BALANCE_QUERY = `
query($id: ID!) {
  giftCard(id: $id) { id currentBalance { amount currency } }
}`;

async function restoreGiftCardBalance(giftCardId, restoreToAmount, currency) {
  // Re-fetch immediately before writing to avoid a lost update if the card
  // was used on another order in the interim.
  const fresh = (await gql(GIFT_CARD_BALANCE_QUERY, { id: giftCardId })).giftCard;

  const result = (await gql(GIFT_CARD_UPDATE, { id: giftCardId, amount: restoreToAmount, currency })).giftCardUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));

  const verify = (await gql(GIFT_CARD_BALANCE_QUERY, { id: giftCardId })).giftCard;
  return { before: fresh.currentBalance.amount, after: verify.currentBalance.amount };
}
7

Wire it together with a dry run guard

The loop ties every piece together. Under DRY_RUN=true, the default, the script only prints the planned {giftCardId, from, to, orderId} rows and never calls giftCardUpdate. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how often orders get cancelled after a gift card payment, for example once a day.

Run it safe

Always start with DRY_RUN=true. Because balanceAmount is a hard overwrite, never call giftCardUpdate with anything other than the freshly computed absolute amount, and always re-fetch the card right before writing so a card that got used again in the meantime is not overwritten with stale numbers.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through cancelled gift-card orders, plans restorations with the pure function, and respects the dry run flag. It is safe to run again and again because alreadyRestored and the initial-balance cap keep it from restoring the same debit twice.

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.
restore_gift_card_balance.py
"""Restore a Saleor gift card's balance after the order it paid for was
cancelled, since orderCancel releases stock and marks the order CANCELED but
never runs compensating logic against GiftCard.currentBalance (see the
Saleor gift cards docs and saleor/saleor#9654, #11257).

Debiting a gift card happens inside payment processing (a GiftCardEvent of
type USED_IN_ORDER), which is decoupled from order status transitions, so
cancellation never fires a signal to reverse it. This script finds cancelled
orders that used a gift card, cross-references each card's event history for
an un-reversed USED_IN_ORDER debit, and restores the balance with
giftCardUpdate, capped at the card's own initial balance.

Under DRY_RUN=true (the default) it only prints the planned
{giftCardId, from, to, orderId} rows and never writes. Safe to run again and
again, since alreadyRestored and the initial-balance cap prevent double
restoration.
"""
import os
import logging
import requests

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

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"

ROUNDING_EPSILON = 0.01

CANCELLED_GIFT_CARD_ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 50, after: $cursor, filter: { status: [CANCELED], giftCardUsed: true }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        giftCards {
          id
          currentBalance { amount currency }
          initialBalance { amount currency }
        }
      }
    }
  }
}"""

GIFT_CARD_EVENTS_QUERY = """
query($id: ID!) {
  giftCard(id: $id) {
    id
    currentBalance { amount currency }
    initialBalance { amount currency }
    events {
      type
      orderId
      balance { initialBalance currentBalance }
    }
  }
}"""

GIFT_CARD_UPDATE = """
mutation($id: ID!, $amount: Decimal!, $currency: String!) {
  giftCardUpdate(id: $id, input: { balanceAmount: { amount: $amount, currency: $currency } }) {
    giftCard { id currentBalance { amount currency } }
    errors { field code message }
  }
}"""

GIFT_CARD_BALANCE_QUERY = """
query($id: ID!) {
  giftCard(id: $id) { id currentBalance { amount currency } }
}"""


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 plan_gift_card_restoration(order, gift_card_usages):
    """Pure decision logic, no I/O. Returns the restorations to make for one order."""
    if order.get("status") != "CANCELED":
        return []

    plans = []
    for usage in gift_card_usages:
        if usage["usedInOrderId"] != order["id"]:
            continue
        if usage["alreadyRestored"]:
            continue
        if usage["amountUsed"] <= 0:
            continue

        restore_to_amount = usage["currentBalanceAmount"] + usage["amountUsed"]
        overshoot = restore_to_amount - usage["initialBalanceAmount"]
        if overshoot > ROUNDING_EPSILON:
            continue  # anomaly: would exceed initial balance, do not clamp silently

        restore_to_amount = min(restore_to_amount, usage["initialBalanceAmount"])
        plans.append({
            "giftCardId": usage["giftCardId"],
            "restoreToAmount": restore_to_amount,
            "reason": "order_cancelled_gift_card_not_refunded",
        })

    return plans


def build_gift_card_usage(order_id, gift_card_id):
    card = gql(GIFT_CARD_EVENTS_QUERY, {"id": gift_card_id})["giftCard"]
    events = card["events"] or []

    used_events = [e for e in events if e["type"] == "USED_IN_ORDER" and e["orderId"] == order_id]
    if not used_events:
        return None

    used_event = used_events[0]
    amount_used = used_event["balance"]["initialBalance"] - used_event["balance"]["currentBalance"]
    already_restored = any(
        e["type"] != "USED_IN_ORDER" and e["orderId"] == order_id for e in events
    )

    return {
        "giftCardId": card["id"],
        "currentBalanceAmount": card["currentBalance"]["amount"],
        "initialBalanceAmount": card["initialBalance"]["amount"],
        "usedInOrderId": order_id,
        "amountUsed": amount_used,
        "alreadyRestored": already_restored,
        "currency": card["currentBalance"]["currency"],
    }


def cancelled_gift_card_orders():
    cursor = None
    while True:
        data = gql(CANCELLED_GIFT_CARD_ORDERS_QUERY, {"cursor": cursor})["orders"]
        for edge in data["edges"]:
            yield edge["node"]
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def restore_gift_card_balance(gift_card_id, restore_to_amount, currency):
    # Re-fetch immediately before writing to avoid a lost update if the card
    # was used on another order in the interim.
    fresh = gql(GIFT_CARD_BALANCE_QUERY, {"id": gift_card_id})["giftCard"]

    result = gql(
        GIFT_CARD_UPDATE,
        {"id": gift_card_id, "amount": restore_to_amount, "currency": currency},
    )["giftCardUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])

    verify = gql(GIFT_CARD_BALANCE_QUERY, {"id": gift_card_id})["giftCard"]
    return {"before": fresh["currentBalance"]["amount"], "after": verify["currentBalance"]["amount"]}


def run():
    restored = 0
    for order in cancelled_gift_card_orders():
        usages = []
        for card in order.get("giftCards") or []:
            usage = build_gift_card_usage(order["id"], card["id"])
            if usage:
                usages.append(usage)

        plans = plan_gift_card_restoration(order, usages)
        for plan in plans:
            usage = next(u for u in usages if u["giftCardId"] == plan["giftCardId"])
            log.info(
                "Order %s gift card %s: %s from %.2f to %.2f",
                order["number"], plan["giftCardId"],
                "would restore" if DRY_RUN else "restoring",
                usage["currentBalanceAmount"], plan["restoreToAmount"],
            )
            if not DRY_RUN:
                restore_gift_card_balance(plan["giftCardId"], plan["restoreToAmount"], usage["currency"])
            restored += 1

    log.info("Done. %d gift card(s) %s.", restored, "to restore" if DRY_RUN else "restored")


if __name__ == "__main__":
    run()
restore-gift-card-balance.js
/**
 * Restore a Saleor gift card's balance after the order it paid for was
 * cancelled, since orderCancel releases stock and marks the order CANCELED
 * but never runs compensating logic against GiftCard.currentBalance (see the
 * Saleor gift cards docs and saleor/saleor#9654, #11257).
 *
 * Debiting a gift card happens inside payment processing (a GiftCardEvent of
 * type USED_IN_ORDER), which is decoupled from order status transitions, so
 * cancellation never fires a signal to reverse it. This script finds
 * cancelled orders that used a gift card, cross-references each card's event
 * history for an un-reversed USED_IN_ORDER debit, and restores the balance
 * with giftCardUpdate, capped at the card's own initial balance.
 *
 * Under DRY_RUN=true (the default) it only prints the planned
 * {giftCardId, from, to, orderId} rows and never writes. Safe to run again
 * and again, since alreadyRestored and the initial-balance cap prevent
 * double restoration.
 *
 * Guide: https://www.allanninal.dev/saleor/gift-card-balance-not-restored-on-cancel/
 */
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 ROUNDING_EPSILON = 0.01;

export function planGiftCardRestoration(order, giftCardUsages) {
  if (order.status !== "CANCELED") return [];

  const plans = [];
  for (const usage of giftCardUsages) {
    if (usage.usedInOrderId !== order.id) continue;
    if (usage.alreadyRestored) continue;
    if (usage.amountUsed <= 0) continue;

    let restoreToAmount = usage.currentBalanceAmount + usage.amountUsed;
    const overshoot = restoreToAmount - usage.initialBalanceAmount;
    if (overshoot > ROUNDING_EPSILON) continue; // anomaly: would exceed initial balance, do not clamp silently

    restoreToAmount = Math.min(restoreToAmount, usage.initialBalanceAmount);
    plans.push({
      giftCardId: usage.giftCardId,
      restoreToAmount,
      reason: "order_cancelled_gift_card_not_refunded",
    });
  }

  return plans;
}

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 CANCELLED_GIFT_CARD_ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 50, after: $cursor, filter: { status: [CANCELED], giftCardUsed: true }) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        giftCards {
          id
          currentBalance { amount currency }
          initialBalance { amount currency }
        }
      }
    }
  }
}`;

const GIFT_CARD_EVENTS_QUERY = `
query($id: ID!) {
  giftCard(id: $id) {
    id
    currentBalance { amount currency }
    initialBalance { amount currency }
    events {
      type
      orderId
      balance { initialBalance currentBalance }
    }
  }
}`;

const GIFT_CARD_UPDATE = `
mutation($id: ID!, $amount: Decimal!, $currency: String!) {
  giftCardUpdate(id: $id, input: { balanceAmount: { amount: $amount, currency: $currency } }) {
    giftCard { id currentBalance { amount currency } }
    errors { field code message }
  }
}`;

const GIFT_CARD_BALANCE_QUERY = `
query($id: ID!) {
  giftCard(id: $id) { id currentBalance { amount currency } }
}`;

async function* cancelledGiftCardOrders() {
  let cursor = null;
  while (true) {
    const data = (await gql(CANCELLED_GIFT_CARD_ORDERS_QUERY, { cursor })).orders;
    for (const edge of data.edges) yield edge.node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function buildGiftCardUsage(orderId, giftCardId) {
  const card = (await gql(GIFT_CARD_EVENTS_QUERY, { id: giftCardId })).giftCard;
  const events = card.events || [];

  const usedEvents = events.filter((e) => e.type === "USED_IN_ORDER" && e.orderId === orderId);
  if (usedEvents.length === 0) return null;

  const usedEvent = usedEvents[0];
  const amountUsed = usedEvent.balance.initialBalance - usedEvent.balance.currentBalance;
  const alreadyRestored = events.some((e) => e.type !== "USED_IN_ORDER" && e.orderId === orderId);

  return {
    giftCardId: card.id,
    currentBalanceAmount: card.currentBalance.amount,
    initialBalanceAmount: card.initialBalance.amount,
    usedInOrderId: orderId,
    amountUsed,
    alreadyRestored,
    currency: card.currentBalance.currency,
  };
}

async function restoreGiftCardBalance(giftCardId, restoreToAmount, currency) {
  // Re-fetch immediately before writing to avoid a lost update if the card
  // was used on another order in the interim.
  const fresh = (await gql(GIFT_CARD_BALANCE_QUERY, { id: giftCardId })).giftCard;

  const result = (await gql(GIFT_CARD_UPDATE, { id: giftCardId, amount: restoreToAmount, currency })).giftCardUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));

  const verify = (await gql(GIFT_CARD_BALANCE_QUERY, { id: giftCardId })).giftCard;
  return { before: fresh.currentBalance.amount, after: verify.currentBalance.amount };
}

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

  for await (const order of cancelledGiftCardOrders()) {
    const usages = [];
    for (const card of order.giftCards || []) {
      const usage = await buildGiftCardUsage(order.id, card.id);
      if (usage) usages.push(usage);
    }

    const plans = planGiftCardRestoration(order, usages);
    for (const plan of plans) {
      const usage = usages.find((u) => u.giftCardId === plan.giftCardId);
      console.log(
        `Order ${order.number} gift card ${plan.giftCardId}: ${DRY_RUN ? "would restore" : "restoring"} from ${usage.currentBalanceAmount.toFixed(2)} to ${plan.restoreToAmount.toFixed(2)}`
      );
      if (!DRY_RUN) await restoreGiftCardBalance(plan.giftCardId, plan.restoreToAmount, usage.currency);
      restored++;
    }
  }

  console.log(`Done. ${restored} gift card(s) ${DRY_RUN ? "to restore" : "restored"}.`);
}

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 how much money lands back on a customer's gift card. Because plan_gift_card_restoration is pure, taking only plain order and usage shapes, the test needs no network and no Saleor account. It just feeds in fixture objects and checks the answer.

test_balance_restoration.py
from restore_gift_card_balance import plan_gift_card_restoration


def order(**over):
    base = {"id": "T3JkZXI6MQ==", "status": "CANCELED"}
    base.update(over)
    return base


def usage(**over):
    base = {
        "giftCardId": "R2lmdENhcmQ6MQ==",
        "currentBalanceAmount": 0.0,
        "initialBalanceAmount": 50.0,
        "usedInOrderId": "T3JkZXI6MQ==",
        "amountUsed": 50.0,
        "alreadyRestored": False,
    }
    base.update(over)
    return base


def test_restores_full_amount_when_not_already_restored():
    plans = plan_gift_card_restoration(order(), [usage()])
    assert plans == [{
        "giftCardId": "R2lmdENhcmQ6MQ==",
        "restoreToAmount": 50.0,
        "reason": "order_cancelled_gift_card_not_refunded",
    }]


def test_no_plan_when_order_not_cancelled():
    plans = plan_gift_card_restoration(order(status="FULFILLED"), [usage()])
    assert plans == []


def test_no_plan_when_already_restored():
    plans = plan_gift_card_restoration(order(), [usage(alreadyRestored=True)])
    assert plans == []


def test_no_plan_when_amount_used_is_zero():
    plans = plan_gift_card_restoration(order(), [usage(amountUsed=0)])
    assert plans == []


def test_no_plan_for_usage_on_a_different_order():
    plans = plan_gift_card_restoration(order(), [usage(usedInOrderId="T3JkZXI6OTk=")])
    assert plans == []


def test_caps_at_initial_balance_within_epsilon():
    # partial current balance plus amount used lands exactly on initial balance
    plans = plan_gift_card_restoration(
        order(), [usage(currentBalanceAmount=10.0, amountUsed=40.0, initialBalanceAmount=50.0)]
    )
    assert plans[0]["restoreToAmount"] == 50.0


def test_flags_anomaly_instead_of_clamping_when_overshoot_is_large():
    # restoring would land at 70, well above the 50 initial balance: skip, do not clamp
    plans = plan_gift_card_restoration(
        order(), [usage(currentBalanceAmount=20.0, amountUsed=50.0, initialBalanceAmount=50.0)]
    )
    assert plans == []


def test_multiple_usages_only_restores_the_eligible_one():
    usages = [
        usage(giftCardId="card-a", alreadyRestored=True),
        usage(giftCardId="card-b", alreadyRestored=False),
    ]
    plans = plan_gift_card_restoration(order(), usages)
    assert len(plans) == 1
    assert plans[0]["giftCardId"] == "card-b"
restore-gift-card-balance.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { planGiftCardRestoration } from "./restore-gift-card-balance.js";

const order = (over = {}) => ({ id: "T3JkZXI6MQ==", status: "CANCELED", ...over });

const usage = (over = {}) => ({
  giftCardId: "R2lmdENhcmQ6MQ==",
  currentBalanceAmount: 0.0,
  initialBalanceAmount: 50.0,
  usedInOrderId: "T3JkZXI6MQ==",
  amountUsed: 50.0,
  alreadyRestored: false,
  ...over,
});

test("restores full amount when not already restored", () => {
  const plans = planGiftCardRestoration(order(), [usage()]);
  assert.deepEqual(plans, [{
    giftCardId: "R2lmdENhcmQ6MQ==",
    restoreToAmount: 50.0,
    reason: "order_cancelled_gift_card_not_refunded",
  }]);
});

test("no plan when order not cancelled", () => {
  const plans = planGiftCardRestoration(order({ status: "FULFILLED" }), [usage()]);
  assert.deepEqual(plans, []);
});

test("no plan when already restored", () => {
  const plans = planGiftCardRestoration(order(), [usage({ alreadyRestored: true })]);
  assert.deepEqual(plans, []);
});

test("no plan when amount used is zero", () => {
  const plans = planGiftCardRestoration(order(), [usage({ amountUsed: 0 })]);
  assert.deepEqual(plans, []);
});

test("no plan for usage on a different order", () => {
  const plans = planGiftCardRestoration(order(), [usage({ usedInOrderId: "T3JkZXI6OTk=" })]);
  assert.deepEqual(plans, []);
});

test("caps at initial balance within epsilon", () => {
  const plans = planGiftCardRestoration(
    order(),
    [usage({ currentBalanceAmount: 10.0, amountUsed: 40.0, initialBalanceAmount: 50.0 })]
  );
  assert.equal(plans[0].restoreToAmount, 50.0);
});

test("flags anomaly instead of clamping when overshoot is large", () => {
  const plans = planGiftCardRestoration(
    order(),
    [usage({ currentBalanceAmount: 20.0, amountUsed: 50.0, initialBalanceAmount: 50.0 })]
  );
  assert.deepEqual(plans, []);
});

test("multiple usages only restores the eligible one", () => {
  const usages = [
    usage({ giftCardId: "card-a", alreadyRestored: true }),
    usage({ giftCardId: "card-b", alreadyRestored: false }),
  ];
  const plans = planGiftCardRestoration(order(), usages);
  assert.equal(plans.length, 1);
  assert.equal(plans[0].giftCardId, "card-b");
});

Case studies

Fraud cancellation

A batch of orders cancelled after a fraud sweep

A store ran a routine fraud check and cancelled a batch of suspicious orders, some of which had been paid partly with gift cards issued as loyalty rewards. The cancellations went through cleanly, stock came back, and the team moved on, not realizing every one of those gift cards was still sitting at zero or a partial balance.

A customer complained weeks later that a reward card showed no balance despite the order never shipping. Running the restoration script in dry run first surfaced the entire batch at once, not just the one complaint, and the team restored all of them in a single pass instead of chasing each ticket individually.

Operator error

A support agent cancelled the wrong order

A support agent, trying to cancel a duplicate order, accidentally cancelled the original one instead, which had been paid entirely with a gift card. The mistake was caught within the hour and the agent recreated the order manually, but nobody thought to check whether the gift card balance needed fixing too, since the dashboard gave no indication anything was wrong with it.

The next scheduled run of the script flagged the card immediately, showing the exact amount it was short and the order it traced back to. The restoration matched the recreated order's payment exactly, and the team confirmed it with the customer before the new order even shipped.

What good looks like

After this runs, a cancelled order never leaves a customer's gift card permanently short. The script catches what the dashboard never surfaces on its own, restores exactly what was debited and nothing more, and never touches a card twice for the same order. Money that should have come back to the customer does, without anyone having to manually reconcile balances one ticket at a time.

FAQ

Does cancelling a Saleor order refund the gift card that paid for it?

No. Saleor's orderCancel releases stock allocations and marks the order CANCELED, but it never runs any compensating logic against the gift card's currentBalance. Saleor's own documentation states this directly: store operators are expected to manually run giftCardUpdate to top the balance back up after a cancellation.

Why does Saleor not automatically restore the gift card balance on cancel?

Because debiting a gift card happens inside payment processing, recorded as a GiftCardEvent of type USED_IN_ORDER, which is architecturally decoupled from order status transitions. Cancelling an order never fires a signal back into the gift card system, so nothing tells it to reverse the debit.

How do I find which cancelled orders still have an un-refunded gift card balance?

Query orders filtered to status CANCELED and giftCardUsed true, then for each order cross-reference its linked gift cards against that gift card's events for a USED_IN_ORDER entry matching the order id. If no balance-credit event exists afterward for that order, the amount was never restored, and the card is flagged as affected.

Related field notes

Citations

On the problem:

  1. Saleor Commerce Documentation: Gift Cards, including order cancellation not restoring balance. docs.saleor.io/developer/gift-cards
  2. Cannot unfulfill and delete order with Gift card. github.com/saleor/saleor/issues/9654
  3. Canceling the orders that haven't been paid. github.com/saleor/saleor/issues/11257

On the solution:

  1. Saleor Commerce Documentation: the giftCardUpdate mutation. docs.saleor.io/api-reference/gift-cards/mutations/gift-card-update
  2. Saleor Commerce Documentation: the GiftCard object. docs.saleor.io/api-reference/gift-cards/objects/gift-card
  3. Saleor Commerce Documentation: the OrderFilterInput input type. docs.saleor.io/api-reference/orders/inputs/order-filter-input

Stuck on a tricky one?

If you have a problem in Saleor checkout, stock, channels, 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 get a customer's balance back to where it should be?

If this saved you from a manual reconciliation spreadsheet or an angry support ticket, 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