Diagnostic Orders & Fulfillment
Digital products not auto fulfilled despite setting enabled
The shop setting is on. You checked it twice. Every line on the order is a digital download, the order shows as paid, and yet it sits there Unfulfilled like nothing happened. No error, no webhook failure in the logs, just a customer waiting on a download link that was supposed to arrive on its own. Here is why Saleor's automatic digital fulfillment is pickier about how an order became paid than the setting name suggests, and a script that finds every order it quietly skipped.
Saleor's automatic digital fulfillment, whether it comes from the shop-level automatic_fulfillment_digital_products flag or a per-DigitalContent override, is only ever invoked from one place: the automatically_fulfill_digital_lines() call inside the payment-capture success path, the moment an order becomes fully paid through a real payment or transaction event during checkout completion. An order that reaches "paid" a different way, through orderMarkAsPaid, a draft order completion, a manual transaction adjustment, or a gateway webhook that lands outside the normal fully-paid signal, never runs that function. The setting was on the whole time. It just never got a chance to fire. A second, narrower cause is a digital variant with no allocated warehouse Stock row, common when track inventory is off, since the auto-fulfillment routine still needs a stock row to build a FulfillmentLine and silently skips lines that do not have one. Run a Python or Node.js script that lists unfulfilled, fully-digital, already-paid orders, checks how each one actually became paid, and reports the ones the automatic hook missed, with an optional dry-run-guarded orderFulfill call for the confirmed-safe cases. Full code, tests, and citations are below.
The problem in plain words
Automatic digital fulfillment sounds like a setting that watches every order and fulfills the digital ones on its own. What it actually is, underneath, is a single function call that lives inside one specific code path: the moment a payment or transaction event pushes an order across the line into fully paid during a normal checkout completion. That call, automatically_fulfill_digital_lines(), reads the setting, checks the order's lines, and creates the fulfillment right there.
The trouble is that "the order became paid" can happen through several doors in Saleor, and only one of them walks past that function. orderMarkAsPaid moves an order straight to paid without a payment or transaction event at all. Completing a draft order can land an order as paid without going through the same checkout-completion flow. A manual transaction adjustment made by staff, or a payment gateway webhook that updates the order outside the usual fully-paid signal, can all leave an order paid and untouched by the automatic fulfillment hook. The setting was correct. The order simply became paid through a door that never checks it.
Why it happens
None of this is a bug in the setting itself. It is a gap between what "automatic fulfillment" implies and what the code actually watches. A few concrete ways stores end up with paid, digital-only orders sitting Unfulfilled:
- Support staff use
orderMarkAsPaidto resolve a payment dispute or a manual bank transfer, which flips the order to paid directly without ever running a captured payment or transaction event. - A draft order is created for a customer, completed once payment is confirmed offline, and becomes paid through the draft order completion flow rather than a normal checkout.
- A payment gateway's webhook updates the transaction outside the exact moment Saleor's own fully-paid signal fires, so the order ends up paid a beat later through a code path the hook does not watch.
- The digital variant has track inventory turned off and was never given a warehouse
Stockrow, so even an order that did go through checkout capture hits a routine that needs a stock row to build theFulfillmentLineand quietly skips that line.
This has been reported since Saleor's early fulfillment work. Issue #2098 tracked the original gap in fulfilling digital order lines at all, and issue #4682 reported digital products specifically not being automatically fulfilled under conditions that looked correctly configured. A related failure mode turned up on Lightrun's writeup of an AttributeError thrown while automatically fulfilling digital order lines, which is the missing-stock case surfacing as a hard error instead of a silent skip. The common thread across all three: the setting is read in exactly one call site, and anything that reaches paid without passing through it is invisible to automatic fulfillment.
An order does not become paid through one road in Saleor, it can arrive through several, and automatic digital fulfillment only watches one of them. So the fix is not to make the setting try harder. It is to find the orders that are paid, digital-only, and still Unfulfilled, work out which road each one took to get paid, and only act on the ones a human can confirm are safe: really paid, really all-digital, and really backed by stock. Anything else stays flagged for review, never silently auto-fulfilled behind the scenes.
The fix, as a flow
We do not touch payments or fulfillment records without a check. The script pages through orders that are not yet fulfilled, keeps the ones where every line is digital, and classifies each by how it became paid and whether its lines actually have stock. Only orders that are paid through the real payment path, fully digital, and backed by stock are candidates, and even then the orderFulfill call only runs when DRY_RUN is off.
Build it step by step
Get an app or staff token
Create an app in the Saleor dashboard with the MANAGE_ORDERS permission, or sign in a staff account with tokenCreate. Keep the API URL and the token in environment variables, never hardcoded in the script.
pip install requests
export SALEOR_API_URL="https://your-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://your-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 endpoint
Every call goes to one endpoint with your token in the Authorization: Bearer header. A small helper sends the query and raises if Saleor reports an error, so every other function can stay simple.
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 unfulfilled orders and read how each became paid
Ask for orders whose status is not fulfilled, along with each line's shipping requirement and digital content settings, and the order's events so we can see whether payment reached paid through ORDER_MARKED_AS_PAID versus a captured payment or transaction event. We page through with a cursor so the job covers the whole backlog.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor, filter: { isFulfilled: false }) {
edges {
node {
id
number
status
isPaid
created
lines {
id
isShippingRequired
quantityFulfilled
variant {
digitalContent { useDefaultSettings automaticFulfillment }
}
}
events { type }
}
}
pageInfo { hasNextPage endCursor }
}
}"""
def unfulfilled_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"]
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 100, after: $cursor, filter: { isFulfilled: false }) {
edges {
node {
id
number
status
isPaid
created
lines {
id
isShippingRequired
quantityFulfilled
variant {
digitalContent { useDefaultSettings automaticFulfillment }
}
}
events { type }
}
}
pageInfo { hasNextPage endCursor }
}
}`;
async function* unfulfilledOrders() {
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;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a normalized order shape and the shop's default flag and returns true or false. It is strict on purpose: the order must actually be paid, still unfulfilled or partially fulfilled, paid through the real capture or transaction path rather than a manual mark, every line must be digital and backed by stock, and the effective per-line automatic fulfillment flag, whether that comes from the content's own override or the shop default, must be true.
ELIGIBLE_STATUSES = {"UNFULFILLED", "PARTIALLY_FULFILLED"}
PAID_VIA_REAL_PAYMENT = {"CHECKOUT_CAPTURE", "TRANSACTION_ACTION"}
def should_auto_fulfill(order, shop_default):
if not order.get("is_paid"):
return False
if order.get("status") not in ELIGIBLE_STATUSES:
return False
if order.get("paid_via") not in PAID_VIA_REAL_PAYMENT:
return False
lines = order.get("lines") or []
if not lines:
return False
for line in lines:
if line.get("is_shipping_required"):
return False
if not line.get("has_stock"):
return False
content = line.get("digital_content")
if content is None:
return False
if content.get("use_default_settings") is False:
effective = content.get("automatic_fulfillment")
else:
effective = shop_default
if not effective:
return False
return True
const ELIGIBLE_STATUSES = new Set(["UNFULFILLED", "PARTIALLY_FULFILLED"]);
const PAID_VIA_REAL_PAYMENT = new Set(["CHECKOUT_CAPTURE", "TRANSACTION_ACTION"]);
export function shouldAutoFulfill(order, shopDefault) {
if (!order.is_paid) return false;
if (!ELIGIBLE_STATUSES.has(order.status)) return false;
if (!PAID_VIA_REAL_PAYMENT.has(order.paid_via)) return false;
const lines = order.lines || [];
if (lines.length === 0) return false;
for (const line of lines) {
if (line.is_shipping_required) return false;
if (!line.has_stock) return false;
const content = line.digital_content;
if (content == null) return false;
const effective = content.use_default_settings === false
? content.automatic_fulfillment
: shopDefault;
if (!effective) return false;
}
return true;
}
Fulfill the confirmed-safe candidates the way the automatic hook would have
When an order passes the decision, call orderFulfill with a stock and warehouse for every line, exactly like the automatic path builds its own FulfillmentLine records. Always read back errors. If Saleor refuses, the error tells you why, and the script should stop on it rather than pretend it worked.
FULFILL_MUTATION = """
mutation($order: ID!, $lines: [OrderFulfillLineInput!]!) {
orderFulfill(order: $order, input: {
lines: $lines, notifyCustomer: true, allowStockToBeExceeded: false
}) {
fulfillments { id status }
errors { field code message }
}
}"""
def fulfill_order(order_id, line_stocks):
result = gql(FULFILL_MUTATION, {"order": order_id, "lines": line_stocks})["orderFulfill"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["fulfillments"]
const FULFILL_MUTATION = `
mutation($order: ID!, $lines: [OrderFulfillLineInput!]!) {
orderFulfill(order: $order, input: {
lines: $lines, notifyCustomer: true, allowStockToBeExceeded: false
}) {
fulfillments { id status }
errors { field code message }
}
}`;
async function fulfillOrder(orderId, lineStocks) {
const result = (await gql(FULFILL_MUTATION, { order: orderId, lines: lineStocks })).orderFulfill;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.fulfillments;
}
Wire it together with a dry run guard
The loop pages through unfulfilled orders, normalizes each one, runs the decision, and logs every candidate. It only calls orderFulfill when DRY_RUN is off, and it never touches an order that is not both fully paid through the real payment path and backed by stock on every line. Everything else stays reported for a human to look at.
Always start with DRY_RUN=true and read the report before flipping it off. This script never marks anything paid and never edits a financial record. It only creates a fulfillment, and only for orders that would already have qualified for automatic fulfillment if they had been paid through the checkout capture path in the first place.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because it never fulfills an order that is not confirmed paid, fully digital, and stocked.
"""Find Saleor orders that stayed Unfulfilled despite automatic digital
fulfillment being enabled, report them, and optionally fulfill the
confirmed-safe ones.
automatic_fulfillment_digital_products, and the per-DigitalContent override,
are only read from inside automatically_fulfill_digital_lines(), which runs
during the payment-capture success path when an order becomes fully paid
through a real payment or transaction event during checkout completion.
Orders that become paid through orderMarkAsPaid, draft order completion, a
manual transaction adjustment, or a webhook outside that signal never call
that function, so their digital-only lines stay Unfulfilled even with the
setting on. A digital variant with no warehouse Stock row is skipped the
same way, since the routine still needs a stock row to build a
FulfillmentLine.
This is flag and report, with an optional orderFulfill call gated by
DRY_RUN, and only for orders that are fully paid through the real payment
path, entirely digital, and backed by stock on every line. Run on a
schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/saleor/digital-products-not-auto-fulfilled/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_unfulfilled_digital_orders")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
SHOP_DEFAULT_AUTO_FULFILL = os.environ.get("SHOP_AUTOMATIC_FULFILLMENT_DIGITAL", "true").lower() == "true"
WAREHOUSE_ID = os.environ.get("SALEOR_WAREHOUSE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ELIGIBLE_STATUSES = {"UNFULFILLED", "PARTIALLY_FULFILLED"}
PAID_VIA_REAL_PAYMENT = {"CHECKOUT_CAPTURE", "TRANSACTION_ACTION"}
MARK_AS_PAID_EVENT = "ORDER_MARKED_AS_PAID"
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor, filter: { isFulfilled: false }) {
edges {
node {
id
number
status
isPaid
lines {
id
isShippingRequired
variant {
id
digitalContent { useDefaultSettings automaticFulfillment }
}
}
events { type }
}
}
pageInfo { hasNextPage endCursor }
}
}"""
WAREHOUSES_QUERY = """
query {
warehouses(first: 100) {
edges { node { id stocks { productVariant { id } quantity } } }
}
}"""
FULFILL_MUTATION = """
mutation($order: ID!, $lines: [OrderFulfillLineInput!]!) {
orderFulfill(order: $order, input: {
lines: $lines, notifyCustomer: true, allowStockToBeExceeded: false
}) {
fulfillments { id status }
errors { field code 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 should_auto_fulfill(order, shop_default):
"""
Pure decision logic, no I/O.
order: {"is_paid": bool, "status": str, "paid_via": str,
"lines": [{"is_shipping_required": bool,
"digital_content": {"use_default_settings": bool,
"automatic_fulfillment": bool} or None,
"has_stock": bool}]}
Returns True only when the order is confirmed safe to auto-fulfill.
"""
if not order.get("is_paid"):
return False
if order.get("status") not in ELIGIBLE_STATUSES:
return False
if order.get("paid_via") not in PAID_VIA_REAL_PAYMENT:
return False
lines = order.get("lines") or []
if not lines:
return False
for line in lines:
if line.get("is_shipping_required"):
return False
if not line.get("has_stock"):
return False
content = line.get("digital_content")
if content is None:
return False
if content.get("use_default_settings") is False:
effective = content.get("automatic_fulfillment")
else:
effective = shop_default
if not effective:
return False
return True
def paid_via(order):
events = order.get("events") or []
if any(e.get("type") == MARK_AS_PAID_EVENT for e in events):
return "MARK_AS_PAID"
return "CHECKOUT_CAPTURE"
def variant_ids_with_stock():
data = gql(WAREHOUSES_QUERY)["warehouses"]
stocked = set()
for edge in data["edges"]:
for stock in edge["node"]["stocks"]:
if stock["quantity"] and stock["quantity"] > 0:
stocked.add(stock["productVariant"]["id"])
return stocked
def normalize_order(node, stocked_variant_ids):
lines = []
for line in node["lines"]:
variant = line.get("variant") or {}
content = variant.get("digitalContent")
digital_content = None
if content is not None:
digital_content = {
"use_default_settings": content.get("useDefaultSettings"),
"automatic_fulfillment": content.get("automaticFulfillment"),
}
lines.append({
"id": line["id"],
"is_shipping_required": line.get("isShippingRequired", True),
"digital_content": digital_content,
"has_stock": variant.get("id") in stocked_variant_ids,
})
return {
"id": node["id"],
"number": node["number"],
"is_paid": node.get("isPaid", False),
"status": node.get("status"),
"paid_via": paid_via(node),
"lines": lines,
}
def unfulfilled_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 fulfill_order(order_id, line_ids):
lines = [
{"orderLineId": lid, "stocks": [{"quantity": 1, "warehouse": WAREHOUSE_ID}]}
for lid in line_ids
]
result = gql(FULFILL_MUTATION, {"order": order_id, "lines": lines})["orderFulfill"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["fulfillments"]
def run():
stocked_variant_ids = variant_ids_with_stock()
flagged = 0
fulfilled = 0
for node in unfulfilled_orders():
order = normalize_order(node, stocked_variant_ids)
if not should_auto_fulfill(order, SHOP_DEFAULT_AUTO_FULFILL):
continue
flagged += 1
log.warning(
"Order %s paid via %s, all digital, has stock, still %s. %s",
order["number"], order["paid_via"], order["status"],
"would fulfill" if DRY_RUN else "fulfilling",
)
if not DRY_RUN and WAREHOUSE_ID:
line_ids = [line["id"] for line in order["lines"]]
fulfill_order(order["id"], line_ids)
fulfilled += 1
log.info(
"Done. %d order(s) flagged, %d %s.",
flagged, fulfilled, "would be fulfilled" if DRY_RUN else "fulfilled",
)
return flagged
if __name__ == "__main__":
run()
/**
* Find Saleor orders that stayed Unfulfilled despite automatic digital
* fulfillment being enabled, report them, and optionally fulfill the
* confirmed-safe ones.
*
* automatic_fulfillment_digital_products, and the per-DigitalContent
* override, are only read from inside automatically_fulfill_digital_lines(),
* which runs during the payment-capture success path when an order becomes
* fully paid through a real payment or transaction event during checkout
* completion. Orders that become paid through orderMarkAsPaid, draft order
* completion, a manual transaction adjustment, or a webhook outside that
* signal never call that function, so their digital-only lines stay
* Unfulfilled even with the setting on. A digital variant with no warehouse
* Stock row is skipped the same way, since the routine still needs a stock
* row to build a FulfillmentLine.
*
* This is flag and report, with an optional orderFulfill call gated by
* DRY_RUN, and only for orders that are fully paid through the real payment
* path, entirely digital, and backed by stock on every line. Run on a
* schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/saleor/digital-products-not-auto-fulfilled/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://demo.saleor.io/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "token_dummy";
const SHOP_DEFAULT_AUTO_FULFILL = (process.env.SHOP_AUTOMATIC_FULFILLMENT_DIGITAL || "true").toLowerCase() === "true";
const WAREHOUSE_ID = process.env.SALEOR_WAREHOUSE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ELIGIBLE_STATUSES = new Set(["UNFULFILLED", "PARTIALLY_FULFILLED"]);
const PAID_VIA_REAL_PAYMENT = new Set(["CHECKOUT_CAPTURE", "TRANSACTION_ACTION"]);
const MARK_AS_PAID_EVENT = "ORDER_MARKED_AS_PAID";
export function shouldAutoFulfill(order, shopDefault) {
if (!order.is_paid) return false;
if (!ELIGIBLE_STATUSES.has(order.status)) return false;
if (!PAID_VIA_REAL_PAYMENT.has(order.paid_via)) return false;
const lines = order.lines || [];
if (lines.length === 0) return false;
for (const line of lines) {
if (line.is_shipping_required) return false;
if (!line.has_stock) return false;
const content = line.digital_content;
if (content == null) return false;
const effective = content.use_default_settings === false
? content.automatic_fulfillment
: shopDefault;
if (!effective) return false;
}
return true;
}
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: 100, after: $cursor, filter: { isFulfilled: false }) {
edges {
node {
id
number
status
isPaid
lines {
id
isShippingRequired
variant {
id
digitalContent { useDefaultSettings automaticFulfillment }
}
}
events { type }
}
}
pageInfo { hasNextPage endCursor }
}
}`;
const WAREHOUSES_QUERY = `
query {
warehouses(first: 100) {
edges { node { id stocks { productVariant { id } quantity } } }
}
}`;
const FULFILL_MUTATION = `
mutation($order: ID!, $lines: [OrderFulfillLineInput!]!) {
orderFulfill(order: $order, input: {
lines: $lines, notifyCustomer: true, allowStockToBeExceeded: false
}) {
fulfillments { id status }
errors { field code message }
}
}`;
function paidVia(order) {
const events = order.events || [];
if (events.some((e) => e.type === MARK_AS_PAID_EVENT)) return "MARK_AS_PAID";
return "CHECKOUT_CAPTURE";
}
async function variantIdsWithStock() {
const data = (await gql(WAREHOUSES_QUERY)).warehouses;
const stocked = new Set();
for (const edge of data.edges) {
for (const stock of edge.node.stocks) {
if (stock.quantity && stock.quantity > 0) stocked.add(stock.productVariant.id);
}
}
return stocked;
}
function normalizeOrder(node, stockedVariantIds) {
const lines = node.lines.map((line) => {
const variant = line.variant || {};
const content = variant.digitalContent;
const digitalContent = content == null ? null : {
use_default_settings: content.useDefaultSettings,
automatic_fulfillment: content.automaticFulfillment,
};
return {
id: line.id,
is_shipping_required: line.isShippingRequired ?? true,
digital_content: digitalContent,
has_stock: stockedVariantIds.has(variant.id),
};
});
return {
id: node.id,
number: node.number,
is_paid: node.isPaid || false,
status: node.status,
paid_via: paidVia(node),
lines,
};
}
async function* unfulfilledOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function fulfillOrder(orderId, lineIds) {
const lines = lineIds.map((lid) => ({
orderLineId: lid,
stocks: [{ quantity: 1, warehouse: WAREHOUSE_ID }],
}));
const result = (await gql(FULFILL_MUTATION, { order: orderId, lines })).orderFulfill;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.fulfillments;
}
export async function run() {
const stockedVariantIds = await variantIdsWithStock();
let flagged = 0;
let fulfilled = 0;
for await (const node of unfulfilledOrders()) {
const order = normalizeOrder(node, stockedVariantIds);
if (!shouldAutoFulfill(order, SHOP_DEFAULT_AUTO_FULFILL)) continue;
flagged++;
console.warn(
`Order ${order.number} paid via ${order.paid_via}, all digital, has stock, still ${order.status}. ${DRY_RUN ? "would fulfill" : "fulfilling"}`
);
if (!DRY_RUN && WAREHOUSE_ID) {
const lineIds = order.lines.map((line) => line.id);
await fulfillOrder(order.id, lineIds);
fulfilled++;
}
}
console.log(
`Done. ${flagged} order(s) flagged, ${fulfilled} ${DRY_RUN ? "would be fulfilled" : "fulfilled"}.`
);
return flagged;
}
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 paid orders get treated as safe to auto-fulfill. Because should_auto_fulfill is pure, the test needs no network and no Saleor store. It just feeds in plain objects and checks the answer across the matrix that actually matters: marked paid by hand, missing stock, a mixed digital and physical order, a per-content override that disagrees with the shop default, and the fully eligible case.
from flag_unfulfilled_digital_orders import should_auto_fulfill
def digital_line(**over):
base = {
"is_shipping_required": False,
"digital_content": {"use_default_settings": True, "automatic_fulfillment": True},
"has_stock": True,
}
base.update(over)
return base
def order(**over):
base = {
"is_paid": True,
"status": "UNFULFILLED",
"paid_via": "CHECKOUT_CAPTURE",
"lines": [digital_line()],
}
base.update(over)
return base
def test_fully_eligible_case_is_true():
assert should_auto_fulfill(order(), True) is True
def test_paid_via_mark_as_paid_is_false_even_with_flag_on():
assert should_auto_fulfill(order(paid_via="MARK_AS_PAID"), True) is False
def test_missing_stock_is_false():
o = order(lines=[digital_line(has_stock=False)])
assert should_auto_fulfill(o, True) is False
def test_mixed_digital_and_physical_order_is_false():
o = order(lines=[digital_line(), digital_line(is_shipping_required=True)])
assert should_auto_fulfill(o, True) is False
def test_per_content_override_disabled_beats_shop_default_on():
o = order(lines=[digital_line(digital_content={
"use_default_settings": False, "automatic_fulfillment": False,
})])
assert should_auto_fulfill(o, True) is False
def test_per_content_override_enabled_beats_shop_default_off():
o = order(lines=[digital_line(digital_content={
"use_default_settings": False, "automatic_fulfillment": True,
})])
assert should_auto_fulfill(o, False) is True
def test_not_paid_is_false():
assert should_auto_fulfill(order(is_paid=False), True) is False
def test_already_fulfilled_status_is_false():
assert should_auto_fulfill(order(status="FULFILLED"), True) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { shouldAutoFulfill } from "./flag-unfulfilled-digital-orders.js";
const digitalLine = (over = {}) => ({
is_shipping_required: false,
digital_content: { use_default_settings: true, automatic_fulfillment: true },
has_stock: true,
...over,
});
const order = (over = {}) => ({
is_paid: true,
status: "UNFULFILLED",
paid_via: "CHECKOUT_CAPTURE",
lines: [digitalLine()],
...over,
});
test("fully eligible case is true", () => {
assert.equal(shouldAutoFulfill(order(), true), true);
});
test("paid via mark as paid is false even with flag on", () => {
assert.equal(shouldAutoFulfill(order({ paid_via: "MARK_AS_PAID" }), true), false);
});
test("missing stock is false", () => {
const o = order({ lines: [digitalLine({ has_stock: false })] });
assert.equal(shouldAutoFulfill(o, true), false);
});
test("mixed digital and physical order is false", () => {
const o = order({ lines: [digitalLine(), digitalLine({ is_shipping_required: true })] });
assert.equal(shouldAutoFulfill(o, true), false);
});
test("per-content override disabled beats shop default on", () => {
const o = order({
lines: [digitalLine({ digital_content: { use_default_settings: false, automatic_fulfillment: false } })],
});
assert.equal(shouldAutoFulfill(o, true), false);
});
test("per-content override enabled beats shop default off", () => {
const o = order({
lines: [digitalLine({ digital_content: { use_default_settings: false, automatic_fulfillment: true } })],
});
assert.equal(shouldAutoFulfill(o, false), true);
});
test("not paid is false", () => {
assert.equal(shouldAutoFulfill(order({ is_paid: false }), true), false);
});
test("already fulfilled status is false", () => {
assert.equal(shouldAutoFulfill(order({ status: "FULFILLED" }), true), false);
});
Case studies
A dispute resolution left forty ebooks stuck Unfulfilled
A publisher selling ebooks resolved a batch of payment disputes by having support use orderMarkAsPaid on orders where the bank had separately confirmed the charge went through. Every one of those orders sat at Unfulfilled afterward, even though automatic digital fulfillment had been enabled since launch.
Running the detection script found forty such orders, all paid via the mark-as-paid event rather than a captured transaction, all fully digital, all backed by stock. The team reviewed the report, confirmed each one was genuinely paid, and ran the script with dry run off to fulfill them and send the download links that should have gone out automatically.
Invoiced software licenses never triggered the hook
A B2B software seller created draft orders for annual license renewals, invoiced the customer outside Saleor, and completed the draft once payment cleared. The completed orders were paid and entirely digital, but because draft order completion does not run through the checkout payment-capture path, none of them were auto-fulfilled.
The script's paid-via check flagged every one of these as needing review, distinct from the disputed-payment batch, and the team used the report to confirm which renewals still needed a license key sent, closing a gap that had been quietly growing for months.
After this runs on a schedule, a digital order that slipped past the automatic hook is a report row within minutes, not a support ticket days later asking where the download went. Confirmed-safe orders get fulfilled the same way the automatic path would have handled them, and anything paid through a route the script cannot fully verify stays flagged for a human, never silently pushed through.
FAQ
Why does Saleor still show digital orders as Unfulfilled when automatic fulfillment is on?
The automatic_fulfillment_digital_products setting, and the per-DigitalContent override, are only read from inside automatically_fulfill_digital_lines, which runs during the payment-capture success path when an order becomes fully paid through a real payment or transaction event during checkout completion. An order that becomes paid a different way, such as orderMarkAsPaid, draft order completion, or a manual transaction adjustment, never calls that function, so its digital-only lines stay Unfulfilled even though the setting is correctly enabled.
Can a digital order with no stock or warehouse still auto fulfill?
Often not. The automatic fulfillment routine still needs a Stock row on a warehouse to build the FulfillmentLine, even for a digital variant with track inventory turned off. A digital variant that was never allocated a warehouse Stock row can be skipped by the same routine that is supposed to fulfill it, leaving it Unfulfilled with no error visible to the shopper.
Is it safe to auto-fulfill these leftover digital orders with a script?
Yes, when the script only calls orderFulfill for orders that are confirmed fully paid, entirely digital, not already fulfilled, and where every line's variant has a real warehouse Stock row, and when it runs in dry run first. That combination is exactly what the automatic path already requires, so the script is not skipping any check Saleor itself would have made.
Related field notes
Citations
On the problem:
- Digital products not being automatically fulfilled. github.com/saleor/saleor/issues/4682
- Fulfillment of digital products. github.com/saleor/saleor/issues/2098
- Automatically fulfilling digital order lines causes AttributeError in API. lightrun.com/answers/saleor-saleor-automatically-fulfilling-digital-order-lines-causes-attributeerror-in-api-
On the solution:
- Saleor Commerce Documentation: the ShopSettingsInput input type, including automaticFulfillmentDigitalProducts. docs.saleor.io/docs/3.x/api-reference/miscellaneous/inputs/shop-settings-input
- Saleor Commerce Documentation: Order Fulfillment. docs.saleor.io/developer/order/order-fulfillment
- Saleor Commerce Documentation: Digital Products. docs.saleor.io/recipes/digital-products
Stuck on a tricky one?
If you have a problem in Saleor orders, payments, digital fulfillment, or checkout 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 clear your Unfulfilled digital orders?
If this saved you a pile of manual fulfillment clicks or a customer waiting on a download link, 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