Skip to content

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.

Python and Node.js Saleor GraphQL API Safe by default (detect and report, no auto-write)
A sale sign
Photo by Justin Lim on Unsplash
The short answer

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.

Draft order voucher applied, total correct draftOrderComplete triggers recalculation Prices refresh should_refresh_prices path Discount not re-derived voucher link dropped or wrong base used Real order discount smaller or gone Finance sees wrong total Meanwhile in Saleor the discount was never a snapshot, it is recomputed on every trigger point.
The discount is re-derived, not stored. A recalculation trigger like draftOrderComplete can fail to re-derive it correctly, and nothing about the draft itself has to look wrong first.

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:

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.

The key insight

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.

Before complete snapshot draft order draftOrderComplete runs as normal, untouched Snapshot new order voucherCode, discounts, total Discount dropped? no, all good yes Log for review, prep orderDiscountAdd apply only if DRY_RUN=false
Detection never touches the completed order. Only an explicit, human-confirmed run with DRY_RUN off calls orderDiscountAdd, and even then only for manual finance review cases.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

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

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.

step3.py
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"],
    }
step3.js
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,
  };
}
4

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.

diff_voucher.py
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,
    }
diff-voucher.js
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 };
}
5

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.py
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"]
complete.js
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;
}
6

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.

Run it safe

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.

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.
detect_dropped_voucher.py
"""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-dropped-voucher.js
/**
 * 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.

test_draft_voucher_diff.py
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
diff-voucher.test.js
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

Wholesale draft orders

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.

ENTIRE_ORDER percentage voucher

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.

What good looks like

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:

  1. draftOrderComplete removes voucher. github.com/saleor/saleor/issues/7541
  2. Bug: Percentage vouchers (ENTIRE_ORDER) discount calculated incorrectly. github.com/saleor/saleor/issues/17453
  3. [RFC] Refactor sales price calculations in the checkout flow. github.com/saleor/saleor/issues/11887

On the solution:

  1. Saleor Commerce Documentation: Draft Orders. docs.saleor.io/developer/order/draft-order
  2. Saleor Commerce Documentation: Vouchers. docs.saleor.io/developer/discounts/vouchers
  3. 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.

Contact me on LinkedIn

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

Back to all Saleor field notes