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.
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.
Why it happens
- Debiting a gift card happens as part of payment processing, at checkout completion, and it is recorded with a
GiftCardEventof typeUSED_IN_ORDERthat carries the order id and the balance before and after the spend. - Order cancellation, whether through
orderCancelor the checkout cancellation flow generally, is a separate code path focused on stock allocations and the order's own status field. It has no awareness of, and no hook into, the gift card ledger. - Because these two systems are architecturally decoupled, cancelling an order never fires any event that the gift card side listens for. There is no compensating transaction, no automatic reversal, nothing.
- Saleor's own documentation is direct about this: cancelling an order does not refund or restore the gift card balance, and store operators are expected to manually run
giftCardUpdateto correct it. This is documented behavior, not a bug, but it is easy to miss until a customer complains that their gift card is short.
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 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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
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.
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"]
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;
}
}
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.
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,
}
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,
};
}
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.
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
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;
}
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.
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"]}
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 };
}
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.
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.
"""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 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.
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"
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
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.
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.
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:
- Saleor Commerce Documentation: Gift Cards, including order cancellation not restoring balance. docs.saleor.io/developer/gift-cards
- Cannot unfulfill and delete order with Gift card. github.com/saleor/saleor/issues/9654
- Canceling the orders that haven't been paid. github.com/saleor/saleor/issues/11257
On the solution:
- Saleor Commerce Documentation: the giftCardUpdate mutation. docs.saleor.io/api-reference/gift-cards/mutations/gift-card-update
- Saleor Commerce Documentation: the GiftCard object. docs.saleor.io/api-reference/gift-cards/objects/gift-card
- 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.
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