Skip to content

Diagnostic Orders & Fulfillment

Cannot unfulfill or delete an order paid with a gift card

A customer wants a return reversed, or a support agent just wants to clean up a test order, and the obvious steps refuse to work. Canceling the fulfillment throws an error. Deleting the line throws a different error. Nothing in the dashboard explains why. The order just sits there, FULFILLED, immovable. Here is why Saleor locks these orders on purpose and a script that finds them and points you at the fix that is actually supported.

Python and Node.js Saleor GraphQL API Flag and report, not force
A red and white sale sign
Photo by Claudio Schwarz on Unsplash
The short answer

Saleor treats any order containing a gift-card line as non-reversible once it is fulfilled. Fulfilling that line issues a live, spendable GiftCard record, and there is no safe way to claw that value back once it exists, so the mutations refuse outright. FulfillmentCancel calls order_has_gift_card_lines(order) and raises CANNOT_CANCEL_FULFILLMENT for any fulfillment on that order. OrderLineDelete and OrderLineUpdate check line.isGift and raise NON_REMOVABLE_GIFT_LINE or NON_EDITABLE_GIFT_LINE. There is no override mutation for any of this, by design. Run a small Python or Node.js script that pages through orders, flags the ones a lifecycle mutation would block, and reports the correct manual remediation: deactivate the gift card, refund out of band, and leave the order as is with a note. Full code, tests, and a dry run guard are below.

The problem in plain words

It looks like a normal order problem at first. Someone fulfilled an order that happened to include a gift card product, and now they need to undo that, maybe because of a return, a mistake, or a test order that should never have gone live. So they try the normal path: cancel the fulfillment, then delete the line, then move on. Except Saleor stops them at the first step, and if they try to work around it by deleting the line directly, it stops them there too.

The reason is not a bug and not a missing feature. It is a deliberate safety rule. The moment a gift-card order line is fulfilled, Saleor creates a real GiftCard record with a real, spendable code. That code can be redeemed at checkout like money, right away, by anyone who has it. Canceling the fulfillment or deleting the line afterward cannot undo that. The code may already be in someone's inbox, already partially spent, or already forwarded to a third party. There is no safe way for Saleor to reach into the world and take that value back, so instead of pretending to undo it, the mutations simply refuse. The order is left FULFILLED or PARTIALLY_FULFILLED, with no lifecycle path forward through the normal unfulfill-then-delete flow, and the person hitting it just sees an opaque GraphQL error with no explanation in the dashboard.

Gift card line order is fulfilled GiftCard issued live, spendable code cannot claw back value FulfillmentCancel / OrderLineDelete refuse error codes raised order stuck FULFILLED
Once a gift card line is fulfilled, the value it created is already loose in the world. Saleor refuses to unwind the order rather than pretend it can undo that.

Why it happens

None of this is explained anywhere in the dashboard. Support staff and merchants just hit a GraphQL error with a code they have never seen, on an order that otherwise looks completely normal. See the citations at the end for the exact GitHub issue and the error code and gift card docs.

The key insight

Do not try to find a way around CANNOT_CANCEL_FULFILLMENT or NON_REMOVABLE_GIFT_LINE. There is no supported override, and forcing one open with a database edit or a custom mutation reintroduces exactly the risk Saleor is protecting against: a spendable gift card code with no order left to account for it. The fix is not to reverse the order lifecycle at all. It is to deactivate the specific gift card so its code cannot be spent, refund the money separately through the payment gateway, and leave a clear note on the order for whoever looks at it next.

The fix, as a flow

The script runs on a schedule or on demand. It pages through orders with their gift card linkage, lines, and fulfillment status, and runs a single pure function to decide whether a lifecycle mutation on that order would be blocked, and by which error code. Nothing is forced. When an order is flagged and a human has confirmed the gift card should be voided, the script's guarded remediation path deactivates the gift card and adds a note, never the fulfillment cancel or line delete.

Scheduled job or run on demand Page orders, read giftCards, lines, fulfillments classifyGiftCard OrderBlock, pure function blocked: true? yes no, skip Report + deactivate on confirmed, not force cancel
The script only ever reports the blocked orders and the exact error code involved. The only write path is deactivating the gift card once a human confirms it, never forcing the cancel or the delete.

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 gift cards. If you plan to run the guarded remediation, it also needs permission to manage gift cards and to add order notes, since that path calls giftCardDeactivate and orderNoteAdd. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true"   # start safe, this script never writes without it off
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true"   // start safe, this script never writes without it off
2

Talk to the Saleor GraphQL API

Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

Page through orders and read the fields the decision needs

Ask for orders(first, after) and read back id, number, status, isPaid, each order's giftCards { id last4CodeChars }, lines { id isGift quantity }, and fulfillments { id status }. Page with a cursor so the job handles a large backlog.

step3.py
ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        isPaid
        giftCards { id last4CodeChars }
        lines { id isGift quantity }
        fulfillments { id status }
      }
    }
  }
}"""

def all_orders():
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
        for edge in data["edges"]:
            yield edge["node"]
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        isPaid
        giftCards { id last4CodeChars }
        lines { id isGift quantity }
        fulfillments { id status }
      }
    }
  }
}`;

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

Decide, with one pure function

Keep the decision in its own function that takes a plain order shape and returns {blocked, blockingCode, reason}. A pure function like this is easy to read and test, which we do later. It mirrors the two checks Saleor's own mutations run. If the order has any gift cards linked and any fulfillment is FULFILLED, PARTIALLY_FULFILLED, or WAITING_FOR_APPROVAL, an orderFulfillmentCancel call would raise CANNOT_CANCEL_FULFILLMENT. If any line has isGift true, an orderLineDelete call would raise NON_REMOVABLE_GIFT_LINE. Otherwise the order is not blocked.

decide.py
FULFILLMENT_BLOCKING_STATUSES = {"FULFILLED", "PARTIALLY_FULFILLED", "WAITING_FOR_APPROVAL"}


def classify_gift_card_order_block(order):
    gift_cards = order.get("giftCards") or []
    lines = order.get("lines") or []
    fulfillments = order.get("fulfillments") or []

    has_blocking_fulfillment = any(
        f.get("status") in FULFILLMENT_BLOCKING_STATUSES for f in fulfillments
    )
    if gift_cards and has_blocking_fulfillment:
        return {
            "blocked": True,
            "blockingCode": "CANNOT_CANCEL_FULFILLMENT",
            "reason": "Order has gift card lines and a fulfillment that cannot be cancelled.",
        }

    if any(line.get("isGift") for line in lines):
        return {
            "blocked": True,
            "blockingCode": "NON_REMOVABLE_GIFT_LINE",
            "reason": "Order has a gift card line that cannot be deleted.",
        }

    return {"blocked": False, "blockingCode": None, "reason": "No gift card lifecycle block found."}
decide.js
const FULFILLMENT_BLOCKING_STATUSES = new Set(["FULFILLED", "PARTIALLY_FULFILLED", "WAITING_FOR_APPROVAL"]);

export function classifyGiftCardOrderBlock(order) {
  const giftCards = order.giftCards || [];
  const lines = order.lines || [];
  const fulfillments = order.fulfillments || [];

  const hasBlockingFulfillment = fulfillments.some((f) => FULFILLMENT_BLOCKING_STATUSES.has(f.status));
  if (giftCards.length > 0 && hasBlockingFulfillment) {
    return {
      blocked: true,
      blockingCode: "CANNOT_CANCEL_FULFILLMENT",
      reason: "Order has gift card lines and a fulfillment that cannot be cancelled.",
    };
  }

  if (lines.some((line) => line.isGift)) {
    return {
      blocked: true,
      blockingCode: "NON_REMOVABLE_GIFT_LINE",
      reason: "Order has a gift card line that cannot be deleted.",
    };
  }

  return { blocked: false, blockingCode: null, reason: "No gift card lifecycle block found." };
}
5

Remediate only through the supported path, never by forcing the lifecycle

When an order is flagged and a human has confirmed the gift card should be voided, the supported fix is not to force orderFulfillmentCancel, orderLineDelete, or orderDelete. It is to deactivate the specific gift card with giftCardDeactivate so its code can no longer be spent, then handle the refund separately through the payment or transaction gateway, and add an internal note to the order with orderNoteAdd explaining the manual reconciliation. The order itself stays FULFILLED or PARTIALLY_FULFILLED.

apply.py
# Guarded remediation only. Never calls orderFulfillmentCancel, orderLineDelete,
# or orderDelete against a gift-card-line order. Deactivate, then refund
# out of band, then leave a note.
GIFT_CARD_DEACTIVATE = """
mutation($id: ID!) {
  giftCardDeactivate(id: $id) {
    giftCard { id isActive }
    errors { field code message }
  }
}"""

ORDER_NOTE_ADD = """
mutation($order: ID!, $input: OrderNoteInput!) {
  orderNoteAdd(order: $order, input: $input) {
    event { id }
    errors { field message }
  }
}"""

def deactivate_gift_card(gift_card_id):
    result = gql(GIFT_CARD_DEACTIVATE, {"id": gift_card_id})["giftCardDeactivate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["giftCard"]

def add_reconciliation_note(order_id, message):
    result = gql(ORDER_NOTE_ADD, {"order": order_id, "input": {"message": message}})["orderNoteAdd"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
apply.js
// Guarded remediation only. Never calls orderFulfillmentCancel, orderLineDelete,
// or orderDelete against a gift-card-line order. Deactivate, then refund
// out of band, then leave a note.
const GIFT_CARD_DEACTIVATE = `
mutation($id: ID!) {
  giftCardDeactivate(id: $id) {
    giftCard { id isActive }
    errors { field code message }
  }
}`;

const ORDER_NOTE_ADD = `
mutation($order: ID!, $input: OrderNoteInput!) {
  orderNoteAdd(order: $order, input: $input) {
    event { id }
    errors { field message }
  }
}`;

async function deactivateGiftCard(giftCardId) {
  const result = (await gql(GIFT_CARD_DEACTIVATE, { id: giftCardId })).giftCardDeactivate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.giftCard;
}

async function addReconciliationNote(orderId, message) {
  const result = (await gql(ORDER_NOTE_ADD, { order: orderId, input: { message } })).orderNoteAdd;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
}
6

Wire it together with a dry run guard

The loop ties every piece together. Under DRY_RUN=true, the default, the script only logs a report entry for each blocked order: {orderId, number, blockingCode}. It never calls giftCardDeactivate or orderNoteAdd from the default path, since deciding to void a gift card needs a human to confirm the money side first. Run it on demand when support flags a stuck order, or on a schedule to build a standing list for review.

Run it safe

This script's default behavior is report-only, and there is no code path in it, guarded or not, that calls orderFulfillmentCancel, orderLineDelete, or orderDelete on a gift-card order. Even the guarded remediation only deactivates the gift card and adds a note. Always keep DRY_RUN=true until a human has confirmed the refund side is handled.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through orders, classifies each with the pure function, and reports every blocked order with its exact error code. The guarded remediation functions are included but never called by the default report-only flow.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
flag_gift_card_blocks.py
"""Flag Saleor orders that cannot be unfulfilled or have a line deleted because
they contain a gift card line, because fulfilling that line already issued a
live, spendable GiftCard record that Saleor cannot safely claw back
(see saleor/saleor#9654, the OrderErrorCode enum, and the gift cards docs).

Saleor deliberately has no override mutation for CANNOT_CANCEL_FULFILLMENT,
NON_REMOVABLE_GIFT_LINE, or NON_EDITABLE_GIFT_LINE, so this script never calls
orderFulfillmentCancel, orderLineDelete, orderLineUpdate, or orderDelete
against a gift-card-line order. Under DRY_RUN=true (the default) it only logs
a report entry for each blocked order. The guarded remediation path
(deactivate_gift_card, add_reconciliation_note) is opt-in only, meant to run
after a human has confirmed the refund side out of band, and should only ever
run with DRY_RUN=false. Safe to run again and again.
"""
import os
import logging
import requests

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

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"

FULFILLMENT_BLOCKING_STATUSES = {"FULFILLED", "PARTIALLY_FULFILLED", "WAITING_FOR_APPROVAL"}

ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        isPaid
        giftCards { id last4CodeChars }
        lines { id isGift quantity }
        fulfillments { id status }
      }
    }
  }
}"""

# Guarded remediation only. Never calls orderFulfillmentCancel, orderLineDelete,
# or orderDelete against a gift-card-line order. Deactivate, then refund
# out of band, then leave a note.
GIFT_CARD_DEACTIVATE = """
mutation($id: ID!) {
  giftCardDeactivate(id: $id) {
    giftCard { id isActive }
    errors { field code message }
  }
}"""

ORDER_NOTE_ADD = """
mutation($order: ID!, $input: OrderNoteInput!) {
  orderNoteAdd(order: $order, input: $input) {
    event { id }
    errors { field message }
  }
}"""


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_order_block(order):
    gift_cards = order.get("giftCards") or []
    lines = order.get("lines") or []
    fulfillments = order.get("fulfillments") or []

    has_blocking_fulfillment = any(
        f.get("status") in FULFILLMENT_BLOCKING_STATUSES for f in fulfillments
    )
    if gift_cards and has_blocking_fulfillment:
        return {
            "blocked": True,
            "blockingCode": "CANNOT_CANCEL_FULFILLMENT",
            "reason": "Order has gift card lines and a fulfillment that cannot be cancelled.",
        }

    if any(line.get("isGift") for line in lines):
        return {
            "blocked": True,
            "blockingCode": "NON_REMOVABLE_GIFT_LINE",
            "reason": "Order has a gift card line that cannot be deleted.",
        }

    return {"blocked": False, "blockingCode": None, "reason": "No gift card lifecycle block found."}


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


def deactivate_gift_card(gift_card_id):
    """Opt-in only. Never called by run(). Wire in yourself once a human has
    confirmed the refund side is handled out of band."""
    result = gql(GIFT_CARD_DEACTIVATE, {"id": gift_card_id})["giftCardDeactivate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["giftCard"]


def add_reconciliation_note(order_id, message):
    """Opt-in only. Never called by run()."""
    result = gql(ORDER_NOTE_ADD, {"order": order_id, "input": {"message": message}})["orderNoteAdd"]
    if result["errors"]:
        raise RuntimeError(result["errors"])


def run():
    flagged = 0
    for order in all_orders():
        decision = classify_gift_card_order_block(order)
        if not decision["blocked"]:
            continue

        report_entry = {
            "orderId": order["id"],
            "number": order["number"],
            "blockingCode": decision["blockingCode"],
        }
        log.warning("Blocked gift card order found. %s %s", report_entry,
                    "(dry run, reporting only)" if DRY_RUN else "(reporting only)")
        flagged += 1

    log.info("Done. %d blocked order(s) flagged for manual review.", flagged)


if __name__ == "__main__":
    run()
flag-gift-card-blocks.js
/**
 * Flag Saleor orders that cannot be unfulfilled or have a line deleted because
 * they contain a gift card line, because fulfilling that line already issued a
 * live, spendable GiftCard record that Saleor cannot safely claw back
 * (see saleor/saleor#9654, the OrderErrorCode enum, and the gift cards docs).
 *
 * Saleor deliberately has no override mutation for CANNOT_CANCEL_FULFILLMENT,
 * NON_REMOVABLE_GIFT_LINE, or NON_EDITABLE_GIFT_LINE, so this script never calls
 * orderFulfillmentCancel, orderLineDelete, orderLineUpdate, or orderDelete
 * against a gift-card-line order. Under DRY_RUN=true (the default) it only logs
 * a report entry for each blocked order. The guarded remediation path
 * (deactivateGiftCard, addReconciliationNote) is opt-in only, meant to run
 * after a human has confirmed the refund side out of band, and should only
 * ever run with DRY_RUN=false.
 *
 * Guide: https://www.allanninal.dev/saleor/gift-card-order-blocks-unfulfill-delete/
 */
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 FULFILLMENT_BLOCKING_STATUSES = new Set(["FULFILLED", "PARTIALLY_FULFILLED", "WAITING_FOR_APPROVAL"]);

export function classifyGiftCardOrderBlock(order) {
  const giftCards = order.giftCards || [];
  const lines = order.lines || [];
  const fulfillments = order.fulfillments || [];

  const hasBlockingFulfillment = fulfillments.some((f) => FULFILLMENT_BLOCKING_STATUSES.has(f.status));
  if (giftCards.length > 0 && hasBlockingFulfillment) {
    return {
      blocked: true,
      blockingCode: "CANNOT_CANCEL_FULFILLMENT",
      reason: "Order has gift card lines and a fulfillment that cannot be cancelled.",
    };
  }

  if (lines.some((line) => line.isGift)) {
    return {
      blocked: true,
      blockingCode: "NON_REMOVABLE_GIFT_LINE",
      reason: "Order has a gift card line that cannot be deleted.",
    };
  }

  return { blocked: false, blockingCode: null, reason: "No gift card lifecycle block found." };
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        number
        status
        isPaid
        giftCards { id last4CodeChars }
        lines { id isGift quantity }
        fulfillments { id status }
      }
    }
  }
}`;

// Guarded remediation only. Never calls orderFulfillmentCancel, orderLineDelete,
// or orderDelete against a gift-card-line order. Deactivate, then refund
// out of band, then leave a note.
const GIFT_CARD_DEACTIVATE = `
mutation($id: ID!) {
  giftCardDeactivate(id: $id) {
    giftCard { id isActive }
    errors { field code message }
  }
}`;

const ORDER_NOTE_ADD = `
mutation($order: ID!, $input: OrderNoteInput!) {
  orderNoteAdd(order: $order, input: $input) {
    event { id }
    errors { field message }
  }
}`;

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

// Opt-in only. Never called by run(). Wire in yourself once a human has
// confirmed the refund side is handled out of band.
async function deactivateGiftCard(giftCardId) {
  const result = (await gql(GIFT_CARD_DEACTIVATE, { id: giftCardId })).giftCardDeactivate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.giftCard;
}

// Opt-in only. Never called by run().
async function addReconciliationNote(orderId, message) {
  const result = (await gql(ORDER_NOTE_ADD, { order: orderId, input: { message } })).orderNoteAdd;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
}

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

  for await (const order of allOrders()) {
    const decision = classifyGiftCardOrderBlock(order);
    if (!decision.blocked) continue;

    const reportEntry = {
      orderId: order.id,
      number: order.number,
      blockingCode: decision.blockingCode,
    };
    console.warn("Blocked gift card order found.", reportEntry, DRY_RUN ? "(dry run, reporting only)" : "(reporting only)");
    flagged++;
  }

  console.log(`Done. ${flagged} blocked order(s) flagged for manual 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 orders get reported as blocked, and by which code. Because classify_gift_card_order_block is pure, taking only a plain order shape, the test needs no network and no Saleor account. It just feeds in fixture objects and checks the answer.

test_gift_card_classify.py
from flag_gift_card_blocks import classify_gift_card_order_block


def order(**over):
    base = {
        "status": "FULFILLED",
        "giftCards": [{"id": "R2lmdENhcmQ6MQ==", "last4CodeChars": "9F2K"}],
        "lines": [{"id": "T3JkZXJMaW5lOjE=", "isGift": True, "quantity": 1}],
        "fulfillments": [{"id": "RnVsZmlsbG1lbnQ6MQ==", "status": "FULFILLED"}],
    }
    base.update(over)
    return base


def test_blocked_with_cannot_cancel_fulfillment_when_gift_card_and_fulfilled():
    result = classify_gift_card_order_block(order())
    assert result["blocked"] is True
    assert result["blockingCode"] == "CANNOT_CANCEL_FULFILLMENT"


def test_blocked_with_non_removable_gift_line_when_no_blocking_fulfillment():
    result = classify_gift_card_order_block(order(giftCards=[], fulfillments=[]))
    assert result["blocked"] is True
    assert result["blockingCode"] == "NON_REMOVABLE_GIFT_LINE"


def test_partially_fulfilled_still_blocks_cancel():
    result = classify_gift_card_order_block(order(fulfillments=[{"id": "Zg==", "status": "PARTIALLY_FULFILLED"}]))
    assert result["blockingCode"] == "CANNOT_CANCEL_FULFILLMENT"


def test_waiting_for_approval_still_blocks_cancel():
    result = classify_gift_card_order_block(order(fulfillments=[{"id": "Zg==", "status": "WAITING_FOR_APPROVAL"}]))
    assert result["blockingCode"] == "CANNOT_CANCEL_FULFILLMENT"


def test_not_blocked_when_no_gift_cards_and_no_gift_lines():
    result = classify_gift_card_order_block(order(giftCards=[], lines=[{"id": "L2", "isGift": False, "quantity": 1}]))
    assert result == {"blocked": False, "blockingCode": None, "reason": "No gift card lifecycle block found."}


def test_not_blocked_when_fulfillment_is_cancelled():
    result = classify_gift_card_order_block(
        order(lines=[{"id": "L2", "isGift": False, "quantity": 1}], fulfillments=[{"id": "Zg==", "status": "CANCELED"}])
    )
    assert result["blocked"] is False


def test_gift_card_present_but_only_unfulfilled_fulfillment_falls_through_to_line_check():
    result = classify_gift_card_order_block(order(fulfillments=[{"id": "Zg==", "status": "UNFULFILLED"}]))
    assert result["blockingCode"] == "NON_REMOVABLE_GIFT_LINE"
classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyGiftCardOrderBlock } from "./flag-gift-card-blocks.js";

const order = (over = {}) => ({
  status: "FULFILLED",
  giftCards: [{ id: "R2lmdENhcmQ6MQ==", last4CodeChars: "9F2K" }],
  lines: [{ id: "T3JkZXJMaW5lOjE=", isGift: true, quantity: 1 }],
  fulfillments: [{ id: "RnVsZmlsbG1lbnQ6MQ==", status: "FULFILLED" }],
  ...over,
});

test("blocked with CANNOT_CANCEL_FULFILLMENT when gift card and fulfilled", () => {
  const result = classifyGiftCardOrderBlock(order());
  assert.equal(result.blocked, true);
  assert.equal(result.blockingCode, "CANNOT_CANCEL_FULFILLMENT");
});

test("blocked with NON_REMOVABLE_GIFT_LINE when no blocking fulfillment", () => {
  const result = classifyGiftCardOrderBlock(order({ giftCards: [], fulfillments: [] }));
  assert.equal(result.blocked, true);
  assert.equal(result.blockingCode, "NON_REMOVABLE_GIFT_LINE");
});

test("partially fulfilled still blocks cancel", () => {
  const result = classifyGiftCardOrderBlock(order({ fulfillments: [{ id: "Zg==", status: "PARTIALLY_FULFILLED" }] }));
  assert.equal(result.blockingCode, "CANNOT_CANCEL_FULFILLMENT");
});

test("waiting for approval still blocks cancel", () => {
  const result = classifyGiftCardOrderBlock(order({ fulfillments: [{ id: "Zg==", status: "WAITING_FOR_APPROVAL" }] }));
  assert.equal(result.blockingCode, "CANNOT_CANCEL_FULFILLMENT");
});

test("not blocked when no gift cards and no gift lines", () => {
  const result = classifyGiftCardOrderBlock(order({ giftCards: [], lines: [{ id: "L2", isGift: false, quantity: 1 }] }));
  assert.deepEqual(result, { blocked: false, blockingCode: null, reason: "No gift card lifecycle block found." });
});

test("not blocked when fulfillment is cancelled", () => {
  const result = classifyGiftCardOrderBlock(
    order({ lines: [{ id: "L2", isGift: false, quantity: 1 }], fulfillments: [{ id: "Zg==", status: "CANCELED" }] })
  );
  assert.equal(result.blocked, false);
});

test("gift card present but only unfulfilled fulfillment falls through to line check", () => {
  const result = classifyGiftCardOrderBlock(order({ fulfillments: [{ id: "Zg==", status: "UNFULFILLED" }] }));
  assert.equal(result.blockingCode, "NON_REMOVABLE_GIFT_LINE");
});

Case studies

Support escalation

A test order that would not go away

A merchant's staff created a test order during setup that happened to include a physical gift card product, fulfilled it to see the flow, and later tried to delete the whole thing during cleanup. Every attempt, cancel the fulfillment, delete the line, even delete the order, came back with a GraphQL error nobody recognized, and support escalated it as a bug.

Running the flag script against that order immediately named the exact code, CANNOT_CANCEL_FULFILLMENT, and the reason. Once the team understood a gift card had actually been issued from the test run, they deactivated it with giftCardDeactivate, confirmed no refund was owed since no real charge existed, and left a note on the order instead of fighting the lifecycle mutations further.

Customer return

A returned order that could not be reversed

A customer returned a bundle that included a gift card line among physical products. Staff processed the physical items as a normal return but assumed the whole order would unwind the same way, and were confused when canceling that one fulfillment failed for the entire order, not just the gift card line.

The report from the script made clear the block applied at the order level, not the line level, because Saleor will not cancel any fulfillment on an order that has gift card lines at all. The team deactivated the specific gift card the customer had not spent, refunded the customer through the payment gateway directly, and documented the reconciliation on the order, which stayed PARTIALLY_FULFILLED exactly as Saleor left it.

What good looks like

After this runs, a gift-card order stuck in FULFILLED or PARTIALLY_FULFILLED stops being a mystery. Support sees the exact blocking code and the reason behind it within seconds, instead of guessing at an opaque GraphQL error. Nobody tries to force orderFulfillmentCancel or orderLineDelete open, since the report points straight at the supported fix: deactivate the gift card, refund out of band, and leave a note. The order lifecycle stays exactly as safe as Saleor intended.

FAQ

Why can I not cancel a fulfillment on a Saleor order that includes a gift card?

Because fulfilling a gift-card line issues a real, spendable GiftCard record the moment it ships. Saleor's FulfillmentCancel mutation calls order_has_gift_card_lines on the order and raises CANNOT_CANCEL_FULFILLMENT for any fulfillment on that order, since canceling it cannot safely claw back a code that may already be redeemed.

What do NON_REMOVABLE_GIFT_LINE and NON_EDITABLE_GIFT_LINE mean?

They are OrderErrorCode values returned by orderLineDelete and orderLineUpdate. Both mutations check line.isGift on the order line, and if it is true they refuse the delete or the edit outright, because removing or changing a fulfilled gift-card line would leave a live gift card with no matching order line to explain it.

How do I actually undo a gift card order if I cannot cancel or delete it?

You do not force the order lifecycle backward. The supported path is to deactivate the specific gift card with giftCardDeactivate so its code can no longer be spent, refund the money through the payment or transaction gateway out of band, and add an internal note to the order with orderNoteAdd explaining the manual reconciliation. The order itself stays FULFILLED or PARTIALLY_FULFILLED.

Related field notes

Citations

On the problem:

  1. Cannot unfulfill and delete order with Gift card. github.com/saleor/saleor/issues/9654
  2. Saleor Commerce Documentation: the OrderErrorCode enum. docs.saleor.io/api-reference/orders/enums/order-error-code
  3. Saleor Commerce Documentation: Gift Cards. docs.saleor.io/developer/gift-cards

On the solution:

  1. Saleor Commerce Documentation: the orderFulfillmentCancel mutation. docs.saleor.io/api-reference/orders/mutations/order-fulfillment-cancel
  2. Saleor Commerce Documentation: the OrderLine object. docs.saleor.io/api-reference/orders/objects/order-line
  3. Saleor Commerce Documentation: Refunds. docs.saleor.io/developer/payments/refunds

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 untangle a stuck gift card order for you?

If this saved you from forcing a risky workaround or filing a bug report that was actually working as intended, 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