Diagnostic Vouchers & Gift Cards
Gift card balance update overwrites initial balance
A support agent tops up a gift card to refund a customer, or a script "adds" a bit more balance after a promotion, and the mutation looks like it worked. Then someone notices the card's remaining balance no longer matches what the customer had spent. It did not add to the balance. It replaced it, and the card's whole spend history quietly disappeared in the same write. Here is why Saleor's gift card update behaves this way and a script that finds every card it happened to.
Saleor's giftCardUpdate mutation exposes a single balanceAmount field on GiftCardUpdateInput, and whatever amount you pass gets written to both initialBalance and currentBalance at once. There is no separate "top up the remaining balance" field, and no server-side check for whether the card has already been partially spent. So a call meant to top up only the remaining balance on an active, already-used card ends up resetting currentBalance back up to match the new initialBalance, erasing the spend recorded in the card's GiftCardEvents. Run a small Python or Node.js script that pages through gift cards, flags any card where this already happened, and reports the last known-good remaining balance recovered from the event log. Full code, tests, and a dry run guard are below.
The problem in plain words
A gift card in Saleor tracks two numbers: initialBalance, what the card was originally worth, and currentBalance, what is left to spend. The gap between them is the card's spend history, redemptions the customer already made. That gap is the entire point of tracking two numbers instead of one.
giftCardUpdate does not know about that gap. It takes one input field, balanceAmount, the same field used when a card is first authored with giftCardCreate, and applies it as a flat write to both balances. Call it on a brand new, unused card and nothing looks wrong, because initialBalance and currentBalance already matched. Call it on a card a customer has been spending down for weeks, meaning to add a top-up on top of what remains, and the mutation does not add anything. It sets both numbers to the exact value you passed, discarding whatever had already been spent.
Why it happens
GiftCardUpdateInputonly hasbalanceAmountfor changing what a card is worth, there is no separate "adjust remaining balance" or "top-up" field to add tocurrentBalancewithout touchinginitialBalance.- The mutation applies that one amount as a single write to both
initialBalanceandcurrentBalance, the same code path used when a card is first authored withgiftCardCreate. - There is no server-side check on the card's usage state before that write. Saleor does not reject or warn when the result would leave
currentBalanceequal to a freshly setinitialBalanceon a card that had already been spent down. - Staff tooling or scripts built for a customer-service refund-to-card end up calling the same mutation meant for authoring the original balance, because it is the only balance-writing mutation available.
Nothing about this raises an error. The mutation returns a perfectly normal GiftCard object with the new balances applied. The only trace left behind is a mismatch between the card's GiftCardEvent log, which still shows the spend that happened, and the balances now sitting on the card, which no longer reflect it. See the citations at the end for the exact docs.
currentBalance can never legitimately be greater than initialBalance. That single fact turns the most obvious case of this bug into a one-line check. The harder case, a card whose balances are internally consistent but whose history was still wiped, only shows up by reading the card's own GiftCardEvents: an UPDATED event where the balance before the write had already diverged (spend had happened), immediately followed by a write that collapsed both fields back to one number.
The fix, as a flow
We do not try to guess a corrected balance and write it back automatically. The true remaining balance the customer should have is not recoverable from the card object once it has been overwritten, Saleor keeps no separate ledger column, only the event log's snapshot of the balance at the moment just before the faulty update. So the script's job is to find every affected card, recover that last known-good number from its own history, and hand a clear report to a human.
Build it step by step
Get an app token with gift card read access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read gift cards. 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, this script only reports by default
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" // start safe, this script only reports by default
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;
}
Page through gift cards with their balances and events
Ask giftCards(first: 100, after: $cursor) for each card's id, displayCode, isActive, initialBalance, currentBalance, and its last 50 events with their type, date, and before-and-after balance snapshot. The events list is what lets the decision function see a card's history, not just its current numbers.
GIFT_CARDS_QUERY = """
query($cursor: String) {
giftCards(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
displayCode
isActive
initialBalance { amount currency }
currentBalance { amount currency }
created
lastUsedOn
events(first: 50) {
edges {
node {
type
date
balance { initialBalance currentBalance oldInitialBalance oldCurrentBalance }
}
}
}
}
}
}
}"""
def gift_cards():
cursor = None
while True:
data = gql(GIFT_CARDS_QUERY, {"cursor": cursor})["giftCards"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const GIFT_CARDS_QUERY = `
query($cursor: String) {
giftCards(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
displayCode
isActive
initialBalance { amount currency }
currentBalance { amount currency }
created
lastUsedOn
events(first: 50) {
edges {
node {
type
date
balance { initialBalance currentBalance oldInitialBalance oldCurrentBalance }
}
}
}
}
}
}
}`;
async function* giftCards() {
let cursor = null;
while (true) {
const data = (await gql(GIFT_CARDS_QUERY, { cursor })).giftCards;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a card's balances and a plain list of its events, and returns a classification. There are two ways in: the literal anomaly, currentBalance greater than initialBalance, which can never happen legitimately and is unrecoverable from the current state alone. Or the subtler case, scanning events for an UPDATED entry where the balance had already diverged (spend happened) right before a write that collapsed both fields to one number, in which case the event's own oldCurrentBalanceAmount is the recovered figure.
def classify_gift_card_balance_overwrite(card):
if card["currentBalanceAmount"] > card["initialBalanceAmount"]:
return {"affected": True, "reason": "current_exceeds_initial", "recoveredCurrentBalanceAmount": None}
for event in card["events"]:
if event["type"] != "UPDATED":
continue
old_initial = event["oldInitialBalanceAmount"]
old_current = event["oldCurrentBalanceAmount"]
new_initial = event["newInitialBalanceAmount"]
new_current = event["newCurrentBalanceAmount"]
if old_initial is None or old_current is None:
continue
if old_current == old_initial:
continue
if new_initial is None or new_current is None or new_initial != new_current:
continue
return {"affected": True, "reason": "update_reset_spent_card", "recoveredCurrentBalanceAmount": old_current}
return {"affected": False, "reason": None, "recoveredCurrentBalanceAmount": None}
export function classifyGiftCardBalanceOverwrite(card) {
if (card.currentBalanceAmount > card.initialBalanceAmount) {
return { affected: true, reason: "current_exceeds_initial", recoveredCurrentBalanceAmount: null };
}
for (const event of card.events) {
if (event.type !== "UPDATED") continue;
const { oldInitialBalanceAmount, oldCurrentBalanceAmount, newInitialBalanceAmount, newCurrentBalanceAmount } = event;
if (oldInitialBalanceAmount == null || oldCurrentBalanceAmount == null) continue;
if (oldCurrentBalanceAmount === oldInitialBalanceAmount) continue;
if (newInitialBalanceAmount == null || newCurrentBalanceAmount == null) continue;
if (newInitialBalanceAmount !== newCurrentBalanceAmount) continue;
return { affected: true, reason: "update_reset_spent_card", recoveredCurrentBalanceAmount: oldCurrentBalanceAmount };
}
return { affected: false, reason: null, recoveredCurrentBalanceAmount: null };
}
Report the overwrite, do not auto-write a fix
When a card is affected, log {id, displayCode, recoveredCurrentBalanceAmount, currentBalanceAmount, initialBalanceAmount, reason}. Do not guess a corrected value and write it back. If reason is current_exceeds_initial, there is no recoverable figure at all, the card object alone cannot tell you what it should be. If reason is update_reset_spent_card, the recovered figure is a strong lead, but only a human who has confirmed it against the customer's own records should turn it into a write.
This script's default behavior is report-only, and it should stay that way. If a human confirms a recovered balance is correct, the only corrective lever is another giftCardUpdate(input: { balanceAmount: $recoveredCurrentBalance }) call, and that call will again set both initialBalance and currentBalance to the same value. That is only safe when the card's original initialBalance should also legitimately become that recovered figure, for example closing out the card at its last known remaining value. Going forward, prefer never calling giftCardUpdate.balanceAmount on a card where currentBalance and initialBalance already differ, and route real top-ups through order or refund flows, or record them with giftCardAddNote and a manual ledger instead.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks every gift card with its events, classifies each one, and reports every card whose balance was overwritten. The default run never writes to Saleor at all, since flagging is the safe behavior for this issue.
"""Find Saleor gift cards whose balance was overwritten by a giftCardUpdate
call that used balanceAmount to top up the remaining balance on a card
that had already been partially spent (see the GiftCard object docs and
the giftCardUpdate mutation docs).
balanceAmount on GiftCardUpdateInput is written to both initialBalance and
currentBalance in one go, with no server-side check for whether the card
had already been spent down. This script never writes a corrected balance.
Saleor keeps no separate ledger column, so the true remaining balance only
survives as the oldCurrentBalance snapshot on the GiftCardEvent just before
the faulty update. Under DRY_RUN=true (the default, and the only mode this
script supports out of the box) it logs a report entry for every affected
card: {id, displayCode, recoveredCurrentBalanceAmount, currentBalanceAmount,
initialBalanceAmount, reason}. Hand that report to staff for a confirmed
manual correction. 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("audit_gift_card_balances")
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"
GIFT_CARDS_QUERY = """
query($cursor: String) {
giftCards(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
displayCode
isActive
initialBalance { amount currency }
currentBalance { amount currency }
created
lastUsedOn
events(first: 50) {
edges {
node {
type
date
balance { initialBalance currentBalance oldInitialBalance oldCurrentBalance }
}
}
}
}
}
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def classify_gift_card_balance_overwrite(card):
if card["currentBalanceAmount"] > card["initialBalanceAmount"]:
return {"affected": True, "reason": "current_exceeds_initial", "recoveredCurrentBalanceAmount": None}
for event in card["events"]:
if event["type"] != "UPDATED":
continue
old_initial = event["oldInitialBalanceAmount"]
old_current = event["oldCurrentBalanceAmount"]
new_initial = event["newInitialBalanceAmount"]
new_current = event["newCurrentBalanceAmount"]
if old_initial is None or old_current is None:
continue
if old_current == old_initial:
continue
if new_initial is None or new_current is None or new_initial != new_current:
continue
return {"affected": True, "reason": "update_reset_spent_card", "recoveredCurrentBalanceAmount": old_current}
return {"affected": False, "reason": None, "recoveredCurrentBalanceAmount": None}
def _to_plain_card(node):
events = []
for edge in node["events"]["edges"]:
ev = edge["node"]
bal = ev.get("balance") or {}
events.append({
"type": ev["type"],
"oldInitialBalanceAmount": bal.get("oldInitialBalance"),
"oldCurrentBalanceAmount": bal.get("oldCurrentBalance"),
"newInitialBalanceAmount": bal.get("initialBalance"),
"newCurrentBalanceAmount": bal.get("currentBalance"),
})
return {
"id": node["id"],
"displayCode": node["displayCode"],
"initialBalanceAmount": node["initialBalance"]["amount"],
"currentBalanceAmount": node["currentBalance"]["amount"],
"events": events,
}
def gift_cards():
cursor = None
while True:
data = gql(GIFT_CARDS_QUERY, {"cursor": cursor})["giftCards"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
flagged = 0
for node in gift_cards():
card = _to_plain_card(node)
result = classify_gift_card_balance_overwrite(card)
if not result["affected"]:
continue
report_entry = {
"id": card["id"],
"displayCode": card["displayCode"],
"recoveredCurrentBalanceAmount": result["recoveredCurrentBalanceAmount"],
"currentBalanceAmount": card["currentBalanceAmount"],
"initialBalanceAmount": card["initialBalanceAmount"],
"reason": result["reason"],
}
log.warning("Overwritten gift card balance found. %s %s", report_entry,
"(dry run, reporting only)" if DRY_RUN else "(reporting only, confirm before any write)")
flagged += 1
log.info("Done. %d gift card(s) flagged for staff review.", flagged)
if __name__ == "__main__":
run()
/**
* Find Saleor gift cards whose balance was overwritten by a giftCardUpdate
* call that used balanceAmount to top up the remaining balance on a card
* that had already been partially spent (see the GiftCard object docs and
* the giftCardUpdate mutation docs).
*
* balanceAmount on GiftCardUpdateInput is written to both initialBalance
* and currentBalance in one go, with no server-side check for whether the
* card had already been spent down. This script never writes a corrected
* balance. Saleor keeps no separate ledger column, so the true remaining
* balance only survives as the oldCurrentBalance snapshot on the
* GiftCardEvent just before the faulty update. Under DRY_RUN=true (the
* default, and the only mode this script supports out of the box) it logs
* a report entry for every affected card. Hand that report to staff for a
* confirmed manual correction. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/gift-card-balance-update-overwrites-initial/
*/
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";
export function classifyGiftCardBalanceOverwrite(card) {
if (card.currentBalanceAmount > card.initialBalanceAmount) {
return { affected: true, reason: "current_exceeds_initial", recoveredCurrentBalanceAmount: null };
}
for (const event of card.events) {
if (event.type !== "UPDATED") continue;
const { oldInitialBalanceAmount, oldCurrentBalanceAmount, newInitialBalanceAmount, newCurrentBalanceAmount } = event;
if (oldInitialBalanceAmount == null || oldCurrentBalanceAmount == null) continue;
if (oldCurrentBalanceAmount === oldInitialBalanceAmount) continue;
if (newInitialBalanceAmount == null || newCurrentBalanceAmount == null) continue;
if (newInitialBalanceAmount !== newCurrentBalanceAmount) continue;
return { affected: true, reason: "update_reset_spent_card", recoveredCurrentBalanceAmount: oldCurrentBalanceAmount };
}
return { affected: false, reason: null, recoveredCurrentBalanceAmount: null };
}
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 GIFT_CARDS_QUERY = `
query($cursor: String) {
giftCards(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
displayCode
isActive
initialBalance { amount currency }
currentBalance { amount currency }
created
lastUsedOn
events(first: 50) {
edges {
node {
type
date
balance { initialBalance currentBalance oldInitialBalance oldCurrentBalance }
}
}
}
}
}
}
}`;
function toPlainCard(node) {
const events = node.events.edges.map((edge) => {
const ev = edge.node;
const bal = ev.balance || {};
return {
type: ev.type,
oldInitialBalanceAmount: bal.oldInitialBalance ?? null,
oldCurrentBalanceAmount: bal.oldCurrentBalance ?? null,
newInitialBalanceAmount: bal.initialBalance ?? null,
newCurrentBalanceAmount: bal.currentBalance ?? null,
};
});
return {
id: node.id,
displayCode: node.displayCode,
initialBalanceAmount: node.initialBalance.amount,
currentBalanceAmount: node.currentBalance.amount,
events,
};
}
async function* giftCards() {
let cursor = null;
while (true) {
const data = (await gql(GIFT_CARDS_QUERY, { cursor })).giftCards;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
export async function run() {
let flagged = 0;
for await (const node of giftCards()) {
const card = toPlainCard(node);
const result = classifyGiftCardBalanceOverwrite(card);
if (!result.affected) continue;
const reportEntry = {
id: card.id,
displayCode: card.displayCode,
recoveredCurrentBalanceAmount: result.recoveredCurrentBalanceAmount,
currentBalanceAmount: card.currentBalanceAmount,
initialBalanceAmount: card.initialBalanceAmount,
reason: result.reason,
};
console.warn(
"Overwritten gift card balance found.",
reportEntry,
DRY_RUN ? "(dry run, reporting only)" : "(reporting only, confirm before any write)"
);
flagged++;
}
console.log(`Done. ${flagged} gift card(s) flagged for staff review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which cards get reported as overwritten and what balance gets recovered. Because classify_gift_card_balance_overwrite is pure, taking a plain card object instead of hitting the API, the test needs no network and no Saleor account. It just feeds in fixture data and checks the answer.
from audit_gift_card_balances import classify_gift_card_balance_overwrite
def event(**over):
base = {
"type": "UPDATED",
"oldInitialBalanceAmount": None,
"oldCurrentBalanceAmount": None,
"newInitialBalanceAmount": None,
"newCurrentBalanceAmount": None,
}
base.update(over)
return base
def card(**over):
base = {"initialBalanceAmount": 50, "currentBalanceAmount": 50, "events": []}
base.update(over)
return base
def test_not_affected_for_a_healthy_untouched_card():
result = classify_gift_card_balance_overwrite(card())
assert result == {"affected": False, "reason": None, "recoveredCurrentBalanceAmount": None}
def test_current_exceeds_initial_is_unrecoverable():
result = classify_gift_card_balance_overwrite(card(initialBalanceAmount=50, currentBalanceAmount=60))
assert result == {"affected": True, "reason": "current_exceeds_initial", "recoveredCurrentBalanceAmount": None}
def test_update_reset_a_spent_card_recovers_old_current_balance():
c = card(initialBalanceAmount=60, currentBalanceAmount=60, events=[
event(oldInitialBalanceAmount=50, oldCurrentBalanceAmount=12,
newInitialBalanceAmount=60, newCurrentBalanceAmount=60),
])
result = classify_gift_card_balance_overwrite(c)
assert result == {"affected": True, "reason": "update_reset_spent_card", "recoveredCurrentBalanceAmount": 12}
def test_update_on_a_never_spent_card_is_not_flagged():
c = card(initialBalanceAmount=60, currentBalanceAmount=60, events=[
event(oldInitialBalanceAmount=50, oldCurrentBalanceAmount=50,
newInitialBalanceAmount=60, newCurrentBalanceAmount=60),
])
result = classify_gift_card_balance_overwrite(c)
assert result == {"affected": False, "reason": None, "recoveredCurrentBalanceAmount": None}
def test_update_that_keeps_balances_apart_is_not_flagged():
c = card(initialBalanceAmount=50, currentBalanceAmount=20, events=[
event(oldInitialBalanceAmount=50, oldCurrentBalanceAmount=30,
newInitialBalanceAmount=50, newCurrentBalanceAmount=20),
])
result = classify_gift_card_balance_overwrite(c)
assert result == {"affected": False, "reason": None, "recoveredCurrentBalanceAmount": None}
def test_events_with_missing_balance_data_are_skipped_not_crashed():
c = card(events=[event(type="ISSUED")])
result = classify_gift_card_balance_overwrite(c)
assert result == {"affected": False, "reason": None, "recoveredCurrentBalanceAmount": None}
def test_earliest_matching_update_wins_when_scanning_chronologically():
c = card(initialBalanceAmount=60, currentBalanceAmount=60, events=[
event(oldInitialBalanceAmount=50, oldCurrentBalanceAmount=12,
newInitialBalanceAmount=60, newCurrentBalanceAmount=60),
event(oldInitialBalanceAmount=60, oldCurrentBalanceAmount=12,
newInitialBalanceAmount=90, newCurrentBalanceAmount=90),
])
result = classify_gift_card_balance_overwrite(c)
assert result["recoveredCurrentBalanceAmount"] == 12
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyGiftCardBalanceOverwrite } from "./audit-gift-card-balances.js";
const event = (over = {}) => ({
type: "UPDATED",
oldInitialBalanceAmount: null,
oldCurrentBalanceAmount: null,
newInitialBalanceAmount: null,
newCurrentBalanceAmount: null,
...over,
});
const card = (over = {}) => ({ initialBalanceAmount: 50, currentBalanceAmount: 50, events: [], ...over });
test("not affected for a healthy untouched card", () => {
const result = classifyGiftCardBalanceOverwrite(card());
assert.deepEqual(result, { affected: false, reason: null, recoveredCurrentBalanceAmount: null });
});
test("current exceeds initial is unrecoverable", () => {
const result = classifyGiftCardBalanceOverwrite(card({ initialBalanceAmount: 50, currentBalanceAmount: 60 }));
assert.deepEqual(result, { affected: true, reason: "current_exceeds_initial", recoveredCurrentBalanceAmount: null });
});
test("update reset a spent card recovers old current balance", () => {
const c = card({
initialBalanceAmount: 60,
currentBalanceAmount: 60,
events: [event({ oldInitialBalanceAmount: 50, oldCurrentBalanceAmount: 12, newInitialBalanceAmount: 60, newCurrentBalanceAmount: 60 })],
});
const result = classifyGiftCardBalanceOverwrite(c);
assert.deepEqual(result, { affected: true, reason: "update_reset_spent_card", recoveredCurrentBalanceAmount: 12 });
});
test("update on a never spent card is not flagged", () => {
const c = card({
initialBalanceAmount: 60,
currentBalanceAmount: 60,
events: [event({ oldInitialBalanceAmount: 50, oldCurrentBalanceAmount: 50, newInitialBalanceAmount: 60, newCurrentBalanceAmount: 60 })],
});
const result = classifyGiftCardBalanceOverwrite(c);
assert.deepEqual(result, { affected: false, reason: null, recoveredCurrentBalanceAmount: null });
});
test("update that keeps balances apart is not flagged", () => {
const c = card({
initialBalanceAmount: 50,
currentBalanceAmount: 20,
events: [event({ oldInitialBalanceAmount: 50, oldCurrentBalanceAmount: 30, newInitialBalanceAmount: 50, newCurrentBalanceAmount: 20 })],
});
const result = classifyGiftCardBalanceOverwrite(c);
assert.deepEqual(result, { affected: false, reason: null, recoveredCurrentBalanceAmount: null });
});
test("events with missing balance data are skipped, not crashed", () => {
const c = card({ events: [event({ type: "ISSUED" })] });
const result = classifyGiftCardBalanceOverwrite(c);
assert.deepEqual(result, { affected: false, reason: null, recoveredCurrentBalanceAmount: null });
});
test("earliest matching update wins when scanning chronologically", () => {
const c = card({
initialBalanceAmount: 60,
currentBalanceAmount: 60,
events: [
event({ oldInitialBalanceAmount: 50, oldCurrentBalanceAmount: 12, newInitialBalanceAmount: 60, newCurrentBalanceAmount: 60 }),
event({ oldInitialBalanceAmount: 60, oldCurrentBalanceAmount: 12, newInitialBalanceAmount: 90, newCurrentBalanceAmount: 90 }),
],
});
const result = classifyGiftCardBalanceOverwrite(c);
assert.equal(result.recoveredCurrentBalanceAmount, 12);
});
Case studies
A refund-to-card wiped a loyal customer's remaining balance
A support agent handling a return decided to refund the customer to their existing gift card instead of issuing a new one. They called giftCardUpdate with balanceAmount set to the old balance plus the refund, expecting it to add the refund on top. The card had 18 left on a 50 card. The call set both initialBalance and currentBalance to the new total, and the 32 already spent simply vanished from the numbers, though the order history still showed it.
Running the audit script found the card immediately: the UPDATED event showed oldCurrentBalance of 18 against an oldInitialBalance of 50, followed by a write where both new balances matched. The recovered figure of 18 matched exactly what the customer's own order history implied, and staff manually corrected the record after confirming it.
A loyalty bonus script quietly reset an entire batch of cards
A store ran a script during a promotion to add a bonus amount to every active gift card. The script read each card's current balance, added the bonus, and called giftCardUpdate with that sum as balanceAmount. For cards nobody had used yet, this worked exactly as intended. For cards that customers had already spent from, the write reset initialBalance up to match, silently restoring balance that had actually already been redeemed.
The audit script flagged every affected card in one pass, with the recovered oldCurrentBalance for each showing exactly how much had genuinely been spent before the bonus write. Staff used the report to manually recalculate what each card should hold, and the team rewrote the promotion script to only ever add the bonus to currentBalance conceptually, routing new top-ups through a proper refund or order flow instead of giftCardUpdate.
After this runs on a schedule, no overwritten gift card balance goes unnoticed. The audit surfaces every affected card with the exact balance recovered from its own event history, so staff can decide, with real evidence, whether to correct the card and to what value. Nothing gets written automatically, since the only safe move here is a confirmed human decision, and future top-ups get routed away from giftCardUpdate.balanceAmount entirely once a card has any spend on it.
FAQ
Why did topping up a gift card erase the customer's remaining balance?
The giftCardUpdate mutation has a single balanceAmount field, and Saleor writes that same amount to both initialBalance and currentBalance in one update. If you meant to top up only the remaining balance on a card that had already been partially spent, the write also reset initialBalance to match, and since currentBalance was set to that same new value, the card's spend history was overwritten rather than added to.
How do I find gift cards whose balance was overwritten this way?
Query giftCards with initialBalance, currentBalance, and events, and flag a card when currentBalance is greater than initialBalance, which can never happen legitimately, or when its events show an UPDATED entry where oldCurrentBalance differed from oldInitialBalance (proof the card had been spent) immediately followed by newInitialBalance equal to newCurrentBalance (proof the update reset both fields together).
Can I automatically restore the correct balance once I find an affected card?
Not safely as a blanket fix. Saleor keeps no separate ledger column, so the true remaining balance after the overwrite only survives as the oldCurrentBalance snapshot on the GiftCardEvent just before the faulty update. Report that recovered figure for each affected card and have a human confirm it before writing anything back, since any follow-up giftCardUpdate call will again set initialBalance and currentBalance to the same value.
Related field notes
Citations
On the problem:
- Saleor Commerce Documentation: Gift Cards overview. docs.saleor.io/developer/gift-cards
- Saleor Commerce Documentation: the GiftCard object, including initialBalance and currentBalance. docs.saleor.io/api-reference/gift-cards/objects/gift-card
- saleor/saleor GitHub repository, gift card issues. github.com/saleor/saleor/issues
On the solution:
- Saleor Commerce Documentation: the giftCardUpdate mutation. docs.saleor.io/api-reference/gift-cards/mutations/gift-card-update
- Saleor Commerce Documentation: the giftCardCreate mutation. docs.saleor.io/api-reference/gift-cards/mutations/gift-card-create
- Saleor Commerce Documentation: API reference overview. docs.saleor.io/api-reference/
Stuck on a tricky one?
If you have a problem in Saleor checkout, payments, discounts, 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 catch an overwritten gift card balance for you?
If this saved you from a confused customer or an unrecoverable balance mistake, 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