Diagnostic Orders & Fulfillment
draftOrderComplete drops the applied voucher
A staff member builds a draft order, applies a voucher code, and the total shows the right discount right there in the admin. Then draftOrderComplete turns it into a real order, and the discount is smaller than it should be, or gone entirely. The voucher was never removed by anyone. Saleor's own price recalculation quietly let it go. Here is why that recalculation path drops the voucher and a script that catches it before finance reconciles the wrong total.
Saleor recalculates an order's prices through its pricing manager at several points: draft edits, shipping or line changes, and the transition that draftOrderComplete performs. The discount is not a frozen snapshot, it is re-derived from the order's stored voucher and voucherCode reference every time that recalculation runs. That path has historically failed to consistently re-derive the discount, so a draft order can carry a valid voucher and a correct discounted total right up until draftOrderComplete runs a fresh recalculation that drops the OrderDiscount linkage or recomputes a percentage voucher against the wrong base. Query the draft order for voucherCode, discounts, total, and undiscountedTotal before completion, call draftOrderComplete, then query the same fields on the resulting order and diff them. Do not auto-repair a completed order, since the total may already be tied to a captured payment. Report the gap for a human to review. Full code, tests, and a dry run guard are below.
The problem in plain words
A discount in Saleor is not something that gets computed once and locked in. It is a value the pricing manager works out fresh, every time it runs, from what the order currently references: which voucher, which code, which lines, which shipping method. Most of the time that recalculation is invisible and correct, because nothing about the order changed in a way that would move the discount.
draftOrderComplete is one of the moments that recalculation runs, because completing a draft is itself a transition that Saleor treats as a reason to refresh prices before the order becomes real. If that refresh does not consistently re-derive the discount from the order's stored voucher reference, one of two things happens. Either the linkage is dropped outright and the completed order shows no voucher and no discount, or the discount is recomputed against the wrong base, for example an ENTIRE_ORDER percentage voucher applied to the undiscounted subtotal instead of what should already be discounted, understating the discount instead of erasing it. Either way, the order that finance sees is not the order the staff member built.
Why it happens
Saleor's order pricing runs through a pricing manager that recalculates totals whenever it decides prices are stale, driven by a should_refresh_prices style check rather than a one-time computation locked to the draft. A few concrete ways this surfaces:
draftOrderCompleteitself is a transition Saleor treats as a trigger to refresh prices before the draft becomes a real order, and that refresh has to correctly re-derive the discount from the order's stored voucher and voucherCode reference every time, which it has not always done.- Earlier versions of this bug removed the voucher outright on completion, so the resulting order shows no voucher and no discount at all, even though the draft clearly had one applied.
- Later versions of the bug are subtler: an
ENTIRE_ORDERpercentage voucher gets recomputed against the wrong base, for example the undiscounted subtotal instead of a total that should already reflect the discount, which understates the discount instead of erasing it. - Any other trigger point that shares the same recalculation path, such as editing draft lines or changing the shipping method, can expose the same gap, since the root cause is the recalculation logic itself, not something specific to the completion mutation.
This class of bug has been reported more than once. GitHub issue #7541 covers draftOrderComplete removing the voucher outright in an earlier version. Issue #17453 covers ENTIRE_ORDER percentage vouchers being calculated incorrectly in a later version. Saleor's own maintainers have pointed to price recalculation refactors, discussed in RFC #11887, as the recurring fix point rather than a single line of code, which is why this keeps resurfacing in different forms instead of staying fixed once.
Because recomputing and re-applying a lost discount after the fact would change amounts that may already be charged or allocated, the safe response is never to auto-mutate a completed order. Detect the gap by diffing a pre-complete snapshot against a post-complete snapshot, report it with the expected and actual discount, and let a human decide whether an orderDiscountAdd call is appropriate. Detection first. Repair only behind an explicit confirmation.
The fix, as a flow
We do not touch draftOrderComplete itself, and we do not blindly re-apply a discount after the order exists. The script snapshots the draft order before completion, lets completion run as normal, snapshots the resulting order, and diffs the two. Anything that looks like a dropped or shrunk voucher discount gets logged for finance to review, with an orderDiscountAdd call prepared but never executed unless DRY_RUN is explicitly turned off by a human.
Build it step by step
Get an app or staff token
Create an app in the Saleor dashboard with the MANAGE_ORDERS and MANAGE_DISCOUNTS permissions, 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 only after human review
// 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 only after human review
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;
}
Snapshot the draft order before completion
Ask for the fields the decision needs: voucherCode, discounts, total.gross.amount, and undiscountedTotal.gross.amount. The effective discount is the difference between those two totals, computed the same way on both sides of completion.
ORDER_SNAPSHOT_QUERY = """
query($id: ID!) {
order(id: $id) {
id
status
voucherCode
voucher { code }
discounts { type valueType value amount { amount } }
total { gross { amount } }
undiscountedTotal { gross { amount } }
}
}"""
def fetch_order_snapshot(order_id):
order = gql(ORDER_SNAPSHOT_QUERY, {"id": order_id})["order"]
return {
"voucherCode": order["voucherCode"],
"totalGross": order["total"]["gross"]["amount"],
"undiscountedTotalGross": order["undiscountedTotal"]["gross"]["amount"],
}
const ORDER_SNAPSHOT_QUERY = `
query($id: ID!) {
order(id: $id) {
id
status
voucherCode
voucher { code }
discounts { type valueType value amount { amount } }
total { gross { amount } }
undiscountedTotal { gross { amount } }
}
}`;
async function fetchOrderSnapshot(orderId) {
const { order } = await gql(ORDER_SNAPSHOT_QUERY, { id: orderId });
return {
voucherCode: order.voucherCode,
totalGross: order.total.gross.amount,
undiscountedTotalGross: order.undiscountedTotal.gross.amount,
};
}
Decide, with one pure function
Keep the diff in its own function that takes two plain snapshots and returns whether the voucher was dropped. The expected discount comes from the draft snapshot, the actual discount comes from the completed snapshot, and a small tolerance absorbs rounding noise from tax or shipping without hiding a real drop.
def diff_voucher_discount(draft_snapshot, completed_snapshot, tolerance=0.01):
"""
draft_snapshot / completed_snapshot: {"voucherCode": str | None,
"totalGross": float,
"undiscountedTotalGross": float}
Returns {"isDropped": bool, "expectedDiscount": float,
"actualDiscount": float, "delta": float}
"""
expected_discount = draft_snapshot["undiscountedTotalGross"] - draft_snapshot["totalGross"]
actual_discount = completed_snapshot["undiscountedTotalGross"] - completed_snapshot["totalGross"]
delta = expected_discount - actual_discount
voucher_was_removed = bool(draft_snapshot["voucherCode"]) and not completed_snapshot["voucherCode"]
discount_shrank = delta > tolerance
is_dropped = (
bool(draft_snapshot["voucherCode"])
and expected_discount > tolerance
and (voucher_was_removed or discount_shrank)
)
return {
"isDropped": is_dropped,
"expectedDiscount": expected_discount,
"actualDiscount": actual_discount,
"delta": delta,
}
export function diffVoucherDiscount(draftSnapshot, completedSnapshot, tolerance = 0.01) {
const expectedDiscount = draftSnapshot.undiscountedTotalGross - draftSnapshot.totalGross;
const actualDiscount = completedSnapshot.undiscountedTotalGross - completedSnapshot.totalGross;
const delta = expectedDiscount - actualDiscount;
const voucherWasRemoved = Boolean(draftSnapshot.voucherCode) && !completedSnapshot.voucherCode;
const discountShrank = delta > tolerance;
const isDropped =
Boolean(draftSnapshot.voucherCode) &&
expectedDiscount > tolerance &&
(voucherWasRemoved || discountShrank);
return { isDropped, expectedDiscount, actualDiscount, delta };
}
Complete the draft and snapshot the result
Call draftOrderComplete untouched, exactly as your checkout or admin flow already does. Then fetch a snapshot of the resulting order with the same query used on the draft, so both sides of the diff are read the same way.
COMPLETE_MUTATION = """
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { id }
errors { field message }
}
}"""
def complete_draft_order(draft_id):
result = gql(COMPLETE_MUTATION, {"id": draft_id})["draftOrderComplete"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["id"]
const COMPLETE_MUTATION = `
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { id }
errors { field message }
}
}`;
async function completeDraftOrder(draftId) {
const result = (await gql(COMPLETE_MUTATION, { id: draftId })).draftOrderComplete;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.id;
}
Wire it together with a report-only default
The run function snapshots the draft, completes it, snapshots the result, and diffs the two. Every dropped voucher is logged with the order id, expected discount, actual discount, and delta for finance to review. The orderDiscountAdd call is only prepared, never executed, unless a human explicitly sets DRY_RUN to false.
Never call orderDiscountAdd unattended. Re-adding a manual discount after an order may already have a captured Transaction or Payment can desync the order total from money that already moved. Keep DRY_RUN=true as the default, log the finding, and let a person decide.
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 never mutates a completed order unless a human has explicitly turned dry run off after reviewing the report.
"""Detect Saleor draft orders whose voucher discount was dropped or shrunk by
draftOrderComplete's price recalculation, and report them for finance review.
Saleor recalculates an order's prices through its pricing manager at several
trigger points, including the transition draftOrderComplete performs. The
discount is re-derived from the order's stored voucher and voucherCode
reference every time that recalculation runs, and that path has historically
failed to consistently re-derive it, dropping the OrderDiscount linkage or
recomputing it against the wrong base.
There is no safe auto-fix: re-adding a discount after completion can desync
the order total from an already-captured Transaction or Payment. This is
detect and report, with an optional orderDiscountAdd call gated by DRY_RUN
and meant to run only after a human has reviewed the finding.
Guide: https://www.allanninal.dev/saleor/draft-order-complete-drops-voucher/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_dropped_voucher")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDER_SNAPSHOT_QUERY = """
query($id: ID!) {
order(id: $id) {
id
status
voucherCode
voucher { code }
discounts { type valueType value amount { amount } }
total { gross { amount } }
undiscountedTotal { gross { amount } }
}
}"""
COMPLETE_MUTATION = """
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { id }
errors { field message }
}
}"""
DISCOUNT_ADD_MUTATION = """
mutation($orderId: ID!, $value: PositiveDecimal!, $reason: String!) {
orderDiscountAdd(orderId: $orderId, input: { valueType: FIXED, value: $value, reason: $reason }) {
order { 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 fetch_order_snapshot(order_id):
order = gql(ORDER_SNAPSHOT_QUERY, {"id": order_id})["order"]
return {
"voucherCode": order["voucherCode"],
"totalGross": order["total"]["gross"]["amount"],
"undiscountedTotalGross": order["undiscountedTotal"]["gross"]["amount"],
}
def diff_voucher_discount(draft_snapshot, completed_snapshot, tolerance=0.01):
"""
Pure decision logic, no I/O.
draft_snapshot / completed_snapshot: {"voucherCode": str | None,
"totalGross": float,
"undiscountedTotalGross": float}
Returns {"isDropped": bool, "expectedDiscount": float,
"actualDiscount": float, "delta": float}
"""
expected_discount = draft_snapshot["undiscountedTotalGross"] - draft_snapshot["totalGross"]
actual_discount = completed_snapshot["undiscountedTotalGross"] - completed_snapshot["totalGross"]
delta = expected_discount - actual_discount
voucher_was_removed = bool(draft_snapshot["voucherCode"]) and not completed_snapshot["voucherCode"]
discount_shrank = delta > tolerance
is_dropped = (
bool(draft_snapshot["voucherCode"])
and expected_discount > tolerance
and (voucher_was_removed or discount_shrank)
)
return {
"isDropped": is_dropped,
"expectedDiscount": expected_discount,
"actualDiscount": actual_discount,
"delta": delta,
}
def complete_draft_order(draft_id):
result = gql(COMPLETE_MUTATION, {"id": draft_id})["draftOrderComplete"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["id"]
def recover_discount(order_id, expected_discount):
result = gql(
DISCOUNT_ADD_MUTATION,
{
"orderId": order_id,
"value": round(expected_discount, 2),
"reason": "Recovered voucher discount from draft order snapshot",
},
)["orderDiscountAdd"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["id"]
def run(draft_order_ids):
mode = "dry run" if DRY_RUN else "live"
log.info("Checking %d draft order(s) for dropped vouchers (%s)", len(draft_order_ids), mode)
flagged = 0
for draft_id in draft_order_ids:
draft_snapshot = fetch_order_snapshot(draft_id)
completed_id = complete_draft_order(draft_id)
completed_snapshot = fetch_order_snapshot(completed_id)
result = diff_voucher_discount(draft_snapshot, completed_snapshot)
if not result["isDropped"]:
continue
flagged += 1
log.warning(
"Voucher dropped on order=%s expected=%.2f actual=%.2f delta=%.2f",
completed_id, result["expectedDiscount"], result["actualDiscount"], result["delta"],
)
if not DRY_RUN:
recover_discount(completed_id, result["expectedDiscount"])
log.info(
"Done. %d order(s) with a dropped voucher %s.",
flagged, "to review" if DRY_RUN else "had a discount re-added",
)
return flagged
if __name__ == "__main__":
run([])
/**
* Detect Saleor draft orders whose voucher discount was dropped or shrunk by
* draftOrderComplete's price recalculation, and report them for finance review.
*
* Saleor recalculates an order's prices through its pricing manager at several
* trigger points, including the transition draftOrderComplete performs. The
* discount is re-derived from the order's stored voucher and voucherCode
* reference every time that recalculation runs, and that path has historically
* failed to consistently re-derive it, dropping the OrderDiscount linkage or
* recomputing it against the wrong base.
*
* There is no safe auto-fix: re-adding a discount after completion can desync
* the order total from an already-captured Transaction or Payment. This is
* detect and report, with an optional orderDiscountAdd call gated by DRY_RUN
* and meant to run only after a human has reviewed the finding.
*
* Guide: https://www.allanninal.dev/saleor/draft-order-complete-drops-voucher/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function diffVoucherDiscount(draftSnapshot, completedSnapshot, tolerance = 0.01) {
const expectedDiscount = draftSnapshot.undiscountedTotalGross - draftSnapshot.totalGross;
const actualDiscount = completedSnapshot.undiscountedTotalGross - completedSnapshot.totalGross;
const delta = expectedDiscount - actualDiscount;
const voucherWasRemoved = Boolean(draftSnapshot.voucherCode) && !completedSnapshot.voucherCode;
const discountShrank = delta > tolerance;
const isDropped =
Boolean(draftSnapshot.voucherCode) &&
expectedDiscount > tolerance &&
(voucherWasRemoved || discountShrank);
return { isDropped, expectedDiscount, actualDiscount, delta };
}
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 ORDER_SNAPSHOT_QUERY = `
query($id: ID!) {
order(id: $id) {
id
status
voucherCode
voucher { code }
discounts { type valueType value amount { amount } }
total { gross { amount } }
undiscountedTotal { gross { amount } }
}
}`;
const COMPLETE_MUTATION = `
mutation($id: ID!) {
draftOrderComplete(id: $id) {
order { id }
errors { field message }
}
}`;
const DISCOUNT_ADD_MUTATION = `
mutation($orderId: ID!, $value: PositiveDecimal!, $reason: String!) {
orderDiscountAdd(orderId: $orderId, input: { valueType: FIXED, value: $value, reason: $reason }) {
order { id }
errors { field message }
}
}`;
async function fetchOrderSnapshot(orderId) {
const { order } = await gql(ORDER_SNAPSHOT_QUERY, { id: orderId });
return {
voucherCode: order.voucherCode,
totalGross: order.total.gross.amount,
undiscountedTotalGross: order.undiscountedTotal.gross.amount,
};
}
async function completeDraftOrder(draftId) {
const result = (await gql(COMPLETE_MUTATION, { id: draftId })).draftOrderComplete;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.id;
}
async function recoverDiscount(orderId, expectedDiscount) {
const result = (
await gql(DISCOUNT_ADD_MUTATION, {
orderId,
value: Math.round(expectedDiscount * 100) / 100,
reason: "Recovered voucher discount from draft order snapshot",
})
).orderDiscountAdd;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.id;
}
export async function run(draftOrderIds) {
const mode = DRY_RUN ? "dry run" : "live";
console.log(`Checking ${draftOrderIds.length} draft order(s) for dropped vouchers (${mode})`);
let flagged = 0;
for (const draftId of draftOrderIds) {
const draftSnapshot = await fetchOrderSnapshot(draftId);
const completedId = await completeDraftOrder(draftId);
const completedSnapshot = await fetchOrderSnapshot(completedId);
const result = diffVoucherDiscount(draftSnapshot, completedSnapshot);
if (!result.isDropped) continue;
flagged++;
console.warn(
`Voucher dropped on order=${completedId} expected=${result.expectedDiscount.toFixed(2)} actual=${result.actualDiscount.toFixed(2)} delta=${result.delta.toFixed(2)}`
);
if (!DRY_RUN) await recoverDiscount(completedId, result.expectedDiscount);
}
console.log(
`Done. ${flagged} order(s) with a dropped voucher ${DRY_RUN ? "to review" : "had a discount re-added"}.`
);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run([]).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The diff rule is the part most worth testing, because it decides which orders get flagged for finance review and which ones are left alone. Because diffVoucherDiscount is pure, the test needs no network and no Saleor store. It just feeds in plain snapshot objects and checks the answer.
from detect_dropped_voucher import diff_voucher_discount
def snapshot(**over):
base = {"voucherCode": "SAVE10", "totalGross": 90.0, "undiscountedTotalGross": 100.0}
base.update(over)
return base
def test_voucher_preserved_is_not_flagged():
draft = snapshot()
completed = snapshot()
result = diff_voucher_discount(draft, completed)
assert result["isDropped"] is False
assert result["delta"] == 0
def test_voucher_fully_dropped_is_flagged():
draft = snapshot()
completed = snapshot(voucherCode=None, totalGross=100.0)
result = diff_voucher_discount(draft, completed)
assert result["isDropped"] is True
assert result["expectedDiscount"] == 10.0
assert result["actualDiscount"] == 0.0
def test_voucher_partially_recalculated_smaller_is_flagged():
draft = snapshot()
completed = snapshot(totalGross=97.0) # discount shrank from 10 to 3
result = diff_voucher_discount(draft, completed)
assert result["isDropped"] is True
assert round(result["delta"], 2) == 7.0
def test_no_voucher_applied_on_draft_is_not_flagged():
draft = snapshot(voucherCode=None, totalGross=100.0)
completed = snapshot(voucherCode=None, totalGross=100.0)
result = diff_voucher_discount(draft, completed)
assert result["isDropped"] is False
def test_rounding_noise_under_tolerance_is_not_flagged():
draft = snapshot()
completed = snapshot(totalGross=90.005) # 0.005 shift from tax rounding
result = diff_voucher_discount(draft, completed, tolerance=0.01)
assert result["isDropped"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffVoucherDiscount } from "./detect-dropped-voucher.js";
const snapshot = (over = {}) => ({
voucherCode: "SAVE10",
totalGross: 90.0,
undiscountedTotalGross: 100.0,
...over,
});
test("voucher preserved is not flagged", () => {
const result = diffVoucherDiscount(snapshot(), snapshot());
assert.equal(result.isDropped, false);
assert.equal(result.delta, 0);
});
test("voucher fully dropped is flagged", () => {
const completed = snapshot({ voucherCode: null, totalGross: 100.0 });
const result = diffVoucherDiscount(snapshot(), completed);
assert.equal(result.isDropped, true);
assert.equal(result.expectedDiscount, 10.0);
assert.equal(result.actualDiscount, 0.0);
});
test("voucher partially recalculated smaller is flagged", () => {
const completed = snapshot({ totalGross: 97.0 });
const result = diffVoucherDiscount(snapshot(), completed);
assert.equal(result.isDropped, true);
assert.equal(Math.round(result.delta * 100) / 100, 7.0);
});
test("no voucher applied on draft is not flagged", () => {
const draft = snapshot({ voucherCode: null, totalGross: 100.0 });
const completed = snapshot({ voucherCode: null, totalGross: 100.0 });
const result = diffVoucherDiscount(draft, completed);
assert.equal(result.isDropped, false);
});
test("rounding noise under tolerance is not flagged", () => {
const completed = snapshot({ totalGross: 90.005 });
const result = diffVoucherDiscount(snapshot(), completed, 0.01);
assert.equal(result.isDropped, false);
});
Case studies
A ten percent code quietly became zero
A B2B team built draft orders for repeat wholesale buyers and applied a stored discount code before completing each one. The admin screen showed the discounted total clearly, so nobody thought to check it again after completion. Weeks later, finance noticed several completed orders billed at full price with the voucher code nowhere on the order.
Running the detection script against the recent batch of completions found the pattern immediately: every affected order had a voucherCode on the draft snapshot and none on the completed one. The team flagged them for manual credit memos instead of quietly eating the difference or guessing which orders were affected.
The discount shrank instead of disappearing
A store used an ENTIRE_ORDER percentage voucher on seasonal draft orders. The draft consistently showed the correct discounted total, but a few completed orders came out with a discount noticeably smaller than expected, not zero, just wrong.
Because the detection script computes the effective discount as undiscounted total minus total on both sides, it caught the shrinkage even without the voucher code disappearing. The delta reported for each order matched exactly what the team found by hand when they dug into a couple of cases, which was enough evidence to open a support ticket with a clear, reproducible before and after.
After this runs around every draft order completion, a dropped or shrunk voucher discount is a logged finding with an order id, an expected amount, and an actual amount, not a silent gap that finance discovers during reconciliation. Nothing about a completed order gets touched automatically. A human decides whether orderDiscountAdd is the right repair, and only then does it run.
FAQ
Why does draftOrderComplete remove or shrink a draft order's voucher discount?
A discount on a Saleor order is not stored as a fixed, immutable snapshot. It is re-derived whenever the pricing manager recalculates prices, which happens on draft edits, shipping or line changes, and the transition that draftOrderComplete performs. That recalculation path has historically failed to consistently re-derive the discount from the order's stored voucher or voucherCode reference, so it can drop the OrderDiscount and voucher_code linkage outright, or recompute a percentage voucher against the wrong base, silently understating or zeroing out the discount on the resulting real order.
Is it safe to automatically re-add the missing discount once it is detected?
No, not unattended. Re-adding a discount with orderDiscountAdd after draftOrderComplete has already run can desync the order's total from a Transaction or Payment amount that was calculated and possibly captured against the wrong total. The safe pattern is to detect and report the gap for manual finance review, prepare the orderDiscountAdd call, and only execute it behind an explicit human confirmation with DRY_RUN set to false.
How do I detect that draftOrderComplete dropped a voucher discount?
Query the draft order before completion for voucherCode, discounts, total, and undiscountedTotal, and compute the effective discount as undiscountedTotal minus total. After calling draftOrderComplete, query the same fields on the resulting order and compute the same delta. If the draft had a voucherCode and a real discount, but the completed order has no voucherCode, an empty discounts array, or a materially smaller discount than the draft, the voucher was dropped or under-applied during price recalculation.
Related field notes
Citations
On the problem:
- draftOrderComplete removes voucher. github.com/saleor/saleor/issues/7541
- Bug: Percentage vouchers (ENTIRE_ORDER) discount calculated incorrectly. github.com/saleor/saleor/issues/17453
- [RFC] Refactor sales price calculations in the checkout flow. github.com/saleor/saleor/issues/11887
On the solution:
- Saleor Commerce Documentation: Draft Orders. docs.saleor.io/developer/order/draft-order
- Saleor Commerce Documentation: Vouchers. docs.saleor.io/developer/discounts/vouchers
- Saleor Commerce Documentation: the Order object. docs.saleor.io/api-reference/orders/objects/order
Stuck on a tricky one?
If you have a problem in Saleor orders, discounts, checkout, 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 a discount before finance did?
If this saved you a reconciliation headache or a support ticket about a wrong total, 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