Diagnostic Vouchers & Gift Cards
Entire order percentage voucher discount miscalculated
A shopper adds a product that is already on sale, applies a store-wide percentage voucher on top, and the total should reflect two discounts compounding, one after the other. Instead the discount is too small, or two otherwise identical orders end up with different totals. Nobody edited a price by hand. Saleor's own order-discount pipeline computed the voucher against the wrong base. Here is why that happens and a script that finds every order where it did.
Saleor's docs say an ENTIRE_ORDER voucher discount applies to the checkout or order subtotal, the sum of line prices after any catalogue Promotion has already reduced them. In affected versions, the order-discount calculation pipeline instead sourced its base amount from the undiscounted total. So when a line also carried an active catalogue Promotion, the voucher percentage was computed against the pre-promotion price, and the two percentage discounts stacked additively instead of compounding on top of each other. This was tracked as Saleor GitHub issue #17453, which also showed non-deterministic totals on otherwise-identical orders, pointing to a race between when prices are recalculated and when the voucher discount is applied. Query every order with an ENTIRE_ORDER percentage voucher, recompute the expected discount from the documented formula against the already-discounted subtotal, and flag any order whose applied discount does not match. Full code, tests, and a dry run guard are below.
The problem in plain words
An ENTIRE_ORDER percentage voucher is meant to be the last discount applied, on top of whatever the order already costs after catalogue promotions have done their work. Saleor's documentation is explicit about this: the voucher discount is a percentage of the subtotal, and the subtotal is supposed to already reflect any active sale on the product. Ten percent off an item that is already forty percent off should be ten percent of the sale price, not ten percent of the original price.
In affected versions, the calculation pipeline that builds the order-level discount did not read that already-discounted subtotal. It read the undiscounted total instead, the price before the catalogue Promotion touched it. The two percentage discounts then behave as if they were being added together rather than applied one after the other, which understates the real combined discount the shopper should get, or in the worst documented case zeroes out the discount on an order where it clearly should not be zero. Because this depends on exactly when the order's prices get recalculated relative to when the voucher is evaluated, two orders built the same way, seconds apart, have shown different totals.
Why it happens
Saleor computes order and checkout prices through a pipeline of steps: line prices first reflect any catalogue Promotion, then voucher and manual discounts are layered on top of that already-adjusted subtotal. A few concrete ways this surfaces:
- The order-discount step that resolves an
ENTIRE_ORDERpercentage voucher has, in affected versions, taken its base amount fromundiscountedTotalrather than the subtotal that already reflects a catalogue Promotion, which is the base the documentation specifies. - This only shows up when a line also carries an active Promotion, so an order with a voucher but no sale items looks completely correct, which makes the bug easy to miss in routine testing.
- GitHub issue #17453 documents this as reproducible with an exact scenario, a ten-unit line with a forty percent catalogue promotion and a seventy percent entire-order voucher, where the buggy pipeline computed a total of zero instead of the correct non-zero total.
- The same issue reports non-deterministic behavior across otherwise-identical orders, which points at a race or ordering issue in when order prices are recomputed relative to when the voucher discount step runs, not a pure formula defect alone.
- Issue #15334 covers the closely related problem that the base price fields on Checkout and Order should contain only the catalogue promotion discount, underscoring that this is a base-amount sourcing bug in the pricing pipeline, not something a merchant configured wrong.
The fix is never to rewrite a finalized order's stored total by hand. Saleor has no mutation that accepts an arbitrary total override, and orderUpdate will not do it either. Rewriting totals on an order that is already paid or fulfilled risks breaking reconciliation with a payment gateway or an accounting export. The safe pattern is detection: recompute the expected discount from the documented formula, subtotal times voucher percentage, capped so it never drives the order below zero, and compare it to what Saleor actually applied. Report every mismatch for finance and support to review, and only ever repair a still-open order, and only after a human approves it.
The fix, as a flow
We page through orders, keep the ones carrying an ENTIRE_ORDER percentage voucher, and recompute what the discount should have been from the subtotal Saleor reports, which already reflects any catalogue promotion. Compare that to the discount Saleor actually recorded on the order. Anything off by more than a cent gets logged with the order id, the expected and actual discount, and the delta, for a human to act on.
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, this is a report-only diagnostic
// 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, this is a report-only diagnostic
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;
}
Page through orders carrying an entire order percentage voucher
Ask for the fields the decision needs: the subtotal, the undiscounted total, the voucher's type, discount value type, and channel discount value, the order's own discounts list, and each line's unit discount amount and undiscounted unit price. We page with a cursor so the diagnostic handles a large order history.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
subtotal { gross { amount } }
undiscountedTotal { gross { amount } }
total { gross { amount } }
channel { slug }
voucher {
id
type
discountValueType
channelListings { channel { slug } discountValue }
}
discounts { type value valueType amount { amount } }
lines {
id
unitDiscountAmount
unitDiscountType
undiscountedUnitPrice { gross { amount } }
}
}
}
}
}"""
def entire_order_voucher_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
node = edge["node"]
voucher = node.get("voucher")
if (
voucher
and voucher.get("type") == "ENTIRE_ORDER"
and voucher.get("discountValueType") == "PERCENTAGE"
):
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
subtotal { gross { amount } }
undiscountedTotal { gross { amount } }
total { gross { amount } }
channel { slug }
voucher {
id
type
discountValueType
channelListings { channel { slug } discountValue }
}
discounts { type value valueType amount { amount } }
lines {
id
unitDiscountAmount
unitDiscountType
undiscountedUnitPrice { gross { amount } }
}
}
}
}
}`;
async function* entireOrderVoucherOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) {
const node = edge.node;
const voucher = node.voucher;
if (voucher && voucher.type === "ENTIRE_ORDER" && voucher.discountValueType === "PERCENTAGE") {
yield node;
}
}
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the expected-discount formula in its own function that takes plain numbers and returns a number, no I/O. It follows the documented ENTIRE_ORDER semantics exactly: multiply the already-discounted subtotal by the voucher percentage, unless the voucher only applies once per order, in which case only the single cheapest unit is discounted. The result is capped so a voucher can never push the order below zero.
def round2(value):
return round(value + 1e-9, 2)
def compute_expected_entire_order_percentage_discount(
subtotal_amount, voucher_discount_value, apply_once_per_order, cheapest_line_unit_price=None
):
"""
Pure decision logic, no I/O.
subtotal_amount MUST already reflect any catalogue-promotion line discounts,
never the undiscounted total, per the documented ENTIRE_ORDER semantics.
Returns the expected discount amount, capped at subtotal_amount.
"""
if apply_once_per_order:
base = cheapest_line_unit_price or 0.0
discount = round2(base * (voucher_discount_value / 100))
else:
discount = round2(subtotal_amount * (voucher_discount_value / 100))
return min(discount, subtotal_amount)
export function round2(value) {
return Math.round((value + 1e-9) * 100) / 100;
}
export function computeExpectedEntireOrderPercentageDiscount(
subtotalAmount,
voucherDiscountValue,
applyOncePerOrder,
cheapestLineUnitPrice
) {
// Pure decision logic, no I/O.
// subtotalAmount MUST already reflect any catalogue-promotion line discounts,
// never the undiscounted total, per the documented ENTIRE_ORDER semantics.
let discount;
if (applyOncePerOrder) {
const base = cheapestLineUnitPrice || 0;
discount = round2(base * (voucherDiscountValue / 100));
} else {
discount = round2(subtotalAmount * (voucherDiscountValue / 100));
}
return Math.min(discount, subtotalAmount);
}
Compare the expected discount to what Saleor actually applied
The actual discount is the sum of the order's discounts entries of type VOUCHER, or the gap between undiscountedTotal and total when a voucher is the only discount present. Flag the order when the difference is bigger than a cent, and separately flag it when its lines carry both a promotion discount and a voucher, since that is the highest-risk stacking case from issue #17453.
TOLERANCE = 0.01
def actual_voucher_discount(order):
voucher_amounts = [
d["amount"]["amount"] for d in (order.get("discounts") or []) if d.get("type") == "VOUCHER"
]
if voucher_amounts:
return sum(voucher_amounts)
undiscounted = order["undiscountedTotal"]["gross"]["amount"]
total = order["total"]["gross"]["amount"]
return round2(undiscounted - total)
def has_stacked_promotion_and_voucher(order):
lines = order.get("lines") or []
return any((line.get("unitDiscountAmount") or 0) > 0 for line in lines)
def flag_order(order, apply_once_per_order=False, cheapest_line_unit_price=None):
channel_slug = order["channel"]["slug"]
listing = next(
(c for c in order["voucher"]["channelListings"] if c["channel"]["slug"] == channel_slug),
None,
)
if listing is None:
return None
subtotal = order["subtotal"]["gross"]["amount"]
expected = compute_expected_entire_order_percentage_discount(
subtotal, listing["discountValue"], apply_once_per_order, cheapest_line_unit_price
)
actual = actual_voucher_discount(order)
delta = round2(actual - expected)
if abs(delta) <= TOLERANCE:
return None
return {
"order_id": order["id"],
"order_number": order["number"],
"expected_discount": expected,
"actual_discount": actual,
"delta": delta,
"channel": channel_slug,
"voucher_code": order["voucher"]["id"],
"stacked_with_promotion": has_stacked_promotion_and_voucher(order),
}
const TOLERANCE = 0.01;
export function actualVoucherDiscount(order) {
const voucherAmounts = (order.discounts || [])
.filter((d) => d.type === "VOUCHER")
.map((d) => d.amount.amount);
if (voucherAmounts.length) return voucherAmounts.reduce((a, b) => a + b, 0);
const undiscounted = order.undiscountedTotal.gross.amount;
const total = order.total.gross.amount;
return round2(undiscounted - total);
}
export function hasStackedPromotionAndVoucher(order) {
const lines = order.lines || [];
return lines.some((line) => (line.unitDiscountAmount || 0) > 0);
}
export function flagOrder(order, applyOncePerOrder = false, cheapestLineUnitPrice) {
const channelSlug = order.channel.slug;
const listing = (order.voucher.channelListings || []).find(
(c) => c.channel.slug === channelSlug
);
if (!listing) return null;
const subtotal = order.subtotal.gross.amount;
const expected = computeExpectedEntireOrderPercentageDiscount(
subtotal, listing.discountValue, applyOncePerOrder, cheapestLineUnitPrice
);
const actual = actualVoucherDiscount(order);
const delta = round2(actual - expected);
if (Math.abs(delta) <= TOLERANCE) return null;
return {
orderId: order.id,
orderNumber: order.number,
expectedDiscount: expected,
actualDiscount: actual,
delta,
channel: channelSlug,
voucherCode: order.voucher.id,
stackedWithPromotion: hasStackedPromotionAndVoucher(order),
};
}
Wire it together with a report-only default
The run function pages through orders, flags the mismatched ones, and logs each one with the order number, expected and actual discount, delta, channel, and voucher id. Nothing gets written back. DRY_RUN exists so a future repair step for still-open orders stays behind an explicit human decision, never a default the script makes for you.
Never mutate a finalized order's total to fix this. There is no orderUpdate field for it, and forcing one through orderDiscountAdd on an already-paid order can desync the amount charged from the amount now expected. Report the mismatch, let finance decide, and only touch a still-open order after a human approves the specific correction.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every mismatched order it finds, respects the dry run flag, and never writes to an order on its own.
"""Flag Saleor orders where an ENTIRE_ORDER percentage voucher was calculated
against the wrong base amount, understating the discount when a line also
carried an active catalogue Promotion.
Saleor's docs say an ENTIRE_ORDER voucher discount applies to the subtotal,
the sum of line prices after any catalogue promotion has already reduced
them. In affected versions the order-discount pipeline instead sourced its
base amount from the undiscounted total, so the voucher percentage and the
promotion percentage stacked additively instead of compounding. Tracked as
Saleor GitHub issue #17453, which also reported non-deterministic totals on
otherwise-identical orders.
There is no safe auto-fix for a finalized order: Saleor has no mutation that
overwrites a stored total or discount directly, and orderUpdate does not
accept one. This is detect and report, run in DRY_RUN mode by default, for
finance and support to review before any correction is made by hand.
Guide: https://www.allanninal.dev/saleor/entire-order-percentage-voucher-miscalculated/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_entire_order_voucher_mismatch")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
TOLERANCE = 0.01
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
subtotal { gross { amount } }
undiscountedTotal { gross { amount } }
total { gross { amount } }
channel { slug }
voucher {
id
type
discountValueType
channelListings { channel { slug } discountValue }
}
discounts { type value valueType amount { amount } }
lines {
id
unitDiscountAmount
unitDiscountType
undiscountedUnitPrice { gross { amount } }
}
}
}
}
}"""
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 round2(value):
return round(value + 1e-9, 2)
def compute_expected_entire_order_percentage_discount(
subtotal_amount, voucher_discount_value, apply_once_per_order, cheapest_line_unit_price=None
):
"""
Pure decision logic, no I/O.
subtotal_amount MUST already reflect any catalogue-promotion line discounts,
never the undiscounted total, per the documented ENTIRE_ORDER semantics.
Returns the expected discount amount, capped at subtotal_amount.
"""
if apply_once_per_order:
base = cheapest_line_unit_price or 0.0
discount = round2(base * (voucher_discount_value / 100))
else:
discount = round2(subtotal_amount * (voucher_discount_value / 100))
return min(discount, subtotal_amount)
def actual_voucher_discount(order):
voucher_amounts = [
d["amount"]["amount"] for d in (order.get("discounts") or []) if d.get("type") == "VOUCHER"
]
if voucher_amounts:
return sum(voucher_amounts)
undiscounted = order["undiscountedTotal"]["gross"]["amount"]
total = order["total"]["gross"]["amount"]
return round2(undiscounted - total)
def has_stacked_promotion_and_voucher(order):
lines = order.get("lines") or []
return any((line.get("unitDiscountAmount") or 0) > 0 for line in lines)
def flag_order(order, apply_once_per_order=False, cheapest_line_unit_price=None):
channel_slug = order["channel"]["slug"]
listing = next(
(c for c in order["voucher"]["channelListings"] if c["channel"]["slug"] == channel_slug),
None,
)
if listing is None:
return None
subtotal = order["subtotal"]["gross"]["amount"]
expected = compute_expected_entire_order_percentage_discount(
subtotal, listing["discountValue"], apply_once_per_order, cheapest_line_unit_price
)
actual = actual_voucher_discount(order)
delta = round2(actual - expected)
if abs(delta) <= TOLERANCE:
return None
return {
"order_id": order["id"],
"order_number": order["number"],
"expected_discount": expected,
"actual_discount": actual,
"delta": delta,
"channel": channel_slug,
"voucher_code": order["voucher"]["id"],
"stacked_with_promotion": has_stacked_promotion_and_voucher(order),
}
def entire_order_voucher_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
node = edge["node"]
voucher = node.get("voucher")
if (
voucher
and voucher.get("type") == "ENTIRE_ORDER"
and voucher.get("discountValueType") == "PERCENTAGE"
):
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
mode = "dry run" if DRY_RUN else "live"
log.info("Scanning orders for entire order percentage voucher mismatches (%s)", mode)
flagged = 0
for order in entire_order_voucher_orders():
finding = flag_order(order)
if finding is None:
continue
flagged += 1
log.warning(
"Mismatch on order=%s expected=%.2f actual=%.2f delta=%.2f channel=%s stacked=%s",
finding["order_number"], finding["expected_discount"], finding["actual_discount"],
finding["delta"], finding["channel"], finding["stacked_with_promotion"],
)
log.info("Done. %d order(s) flagged for finance review.", flagged)
return flagged
if __name__ == "__main__":
run()
/**
* Flag Saleor orders where an ENTIRE_ORDER percentage voucher was calculated
* against the wrong base amount, understating the discount when a line also
* carried an active catalogue Promotion.
*
* Saleor's docs say an ENTIRE_ORDER voucher discount applies to the subtotal,
* the sum of line prices after any catalogue promotion has already reduced
* them. In affected versions the order-discount pipeline instead sourced its
* base amount from the undiscounted total, so the voucher percentage and the
* promotion percentage stacked additively instead of compounding. Tracked as
* Saleor GitHub issue #17453, which also reported non-deterministic totals on
* otherwise-identical orders.
*
* There is no safe auto-fix for a finalized order: Saleor has no mutation
* that overwrites a stored total or discount directly, and orderUpdate does
* not accept one. This is detect and report, run in DRY_RUN mode by default,
* for finance and support to review before any correction is made by hand.
*
* Guide: https://www.allanninal.dev/saleor/entire-order-percentage-voucher-miscalculated/
*/
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";
const TOLERANCE = 0.01;
export function round2(value) {
return Math.round((value + 1e-9) * 100) / 100;
}
export function computeExpectedEntireOrderPercentageDiscount(
subtotalAmount,
voucherDiscountValue,
applyOncePerOrder,
cheapestLineUnitPrice
) {
// Pure decision logic, no I/O.
// subtotalAmount MUST already reflect any catalogue-promotion line discounts,
// never the undiscounted total, per the documented ENTIRE_ORDER semantics.
let discount;
if (applyOncePerOrder) {
const base = cheapestLineUnitPrice || 0;
discount = round2(base * (voucherDiscountValue / 100));
} else {
discount = round2(subtotalAmount * (voucherDiscountValue / 100));
}
return Math.min(discount, subtotalAmount);
}
export function actualVoucherDiscount(order) {
const voucherAmounts = (order.discounts || [])
.filter((d) => d.type === "VOUCHER")
.map((d) => d.amount.amount);
if (voucherAmounts.length) return voucherAmounts.reduce((a, b) => a + b, 0);
const undiscounted = order.undiscountedTotal.gross.amount;
const total = order.total.gross.amount;
return round2(undiscounted - total);
}
export function hasStackedPromotionAndVoucher(order) {
const lines = order.lines || [];
return lines.some((line) => (line.unitDiscountAmount || 0) > 0);
}
export function flagOrder(order, applyOncePerOrder = false, cheapestLineUnitPrice) {
const channelSlug = order.channel.slug;
const listing = (order.voucher.channelListings || []).find(
(c) => c.channel.slug === channelSlug
);
if (!listing) return null;
const subtotal = order.subtotal.gross.amount;
const expected = computeExpectedEntireOrderPercentageDiscount(
subtotal, listing.discountValue, applyOncePerOrder, cheapestLineUnitPrice
);
const actual = actualVoucherDiscount(order);
const delta = round2(actual - expected);
if (Math.abs(delta) <= TOLERANCE) return null;
return {
orderId: order.id,
orderNumber: order.number,
expectedDiscount: expected,
actualDiscount: actual,
delta,
channel: channelSlug,
voucherCode: order.voucher.id,
stackedWithPromotion: hasStackedPromotionAndVoucher(order),
};
}
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) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
subtotal { gross { amount } }
undiscountedTotal { gross { amount } }
total { gross { amount } }
channel { slug }
voucher {
id
type
discountValueType
channelListings { channel { slug } discountValue }
}
discounts { type value valueType amount { amount } }
lines {
id
unitDiscountAmount
unitDiscountType
undiscountedUnitPrice { gross { amount } }
}
}
}
}
}`;
async function* entireOrderVoucherOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) {
const node = edge.node;
const voucher = node.voucher;
if (voucher && voucher.type === "ENTIRE_ORDER" && voucher.discountValueType === "PERCENTAGE") {
yield node;
}
}
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
export async function run() {
const mode = DRY_RUN ? "dry run" : "live";
console.log(`Scanning orders for entire order percentage voucher mismatches (${mode})`);
let flagged = 0;
for await (const order of entireOrderVoucherOrders()) {
const finding = flagOrder(order);
if (!finding) continue;
flagged++;
console.warn(
`Mismatch on order=${finding.orderNumber} expected=${finding.expectedDiscount.toFixed(2)} actual=${finding.actualDiscount.toFixed(2)} delta=${finding.delta.toFixed(2)} channel=${finding.channel} stacked=${finding.stackedWithPromotion}`
);
}
console.log(`Done. ${flagged} order(s) flagged for finance review.`);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The formula is the part most worth testing, because it decides which orders get flagged for finance review. Because computeExpectedEntireOrderPercentageDiscount is pure, the test needs no network and no Saleor store. It just feeds in fixed numbers and checks the answer, including the exact ten-unit, forty percent promotion, seventy percent voucher scenario from issue #17453.
from flag_entire_order_voucher_mismatch import (
compute_expected_entire_order_percentage_discount,
actual_voucher_discount,
has_stacked_promotion_and_voucher,
flag_order,
)
def test_simple_percentage_off_subtotal():
# 100 subtotal, 10% voucher, no promotion involved
assert compute_expected_entire_order_percentage_discount(100.0, 10, False) == 10.0
def test_issue_17453_scenario_is_non_zero():
# 10 units at 10 each = 100 undiscounted, a 40% catalogue promotion already
# brought the subtotal down to 60, then a 70% entire order voucher applies
# to that already-discounted subtotal, not to the original 100.
subtotal_after_promotion = 60.0
expected = compute_expected_entire_order_percentage_discount(subtotal_after_promotion, 70, False)
assert expected == 42.0
assert expected != 0.0
def test_discount_never_exceeds_subtotal():
assert compute_expected_entire_order_percentage_discount(50.0, 150, False) == 50.0
def test_apply_once_per_order_uses_cheapest_unit():
expected = compute_expected_entire_order_percentage_discount(
100.0, 20, True, cheapest_line_unit_price=15.0
)
assert expected == 3.0
def order(**over):
base = {
"id": "T3JkZXI6MQ==",
"number": "1001",
"subtotal": {"gross": {"amount": 60.0}},
"undiscountedTotal": {"gross": {"amount": 100.0}},
"total": {"gross": {"amount": 60.0}},
"channel": {"slug": "default-channel"},
"voucher": {
"id": "Vm91Y2hlcjox",
"type": "ENTIRE_ORDER",
"discountValueType": "PERCENTAGE",
"channelListings": [{"channel": {"slug": "default-channel"}, "discountValue": 70}],
},
"discounts": [{"type": "VOUCHER", "value": 70, "valueType": "PERCENTAGE", "amount": {"amount": 42.0}}],
"lines": [{"id": "TGluZTox", "unitDiscountAmount": 4.0, "unitDiscountType": "PERCENTAGE",
"undiscountedUnitPrice": {"gross": {"amount": 10.0}}}],
}
base.update(over)
return base
def test_matching_order_is_not_flagged():
assert flag_order(order()) is None
def test_mismatched_order_is_flagged_with_details():
bad = order(discounts=[{"type": "VOUCHER", "value": 70, "valueType": "PERCENTAGE", "amount": {"amount": 70.0}}])
finding = flag_order(bad)
assert finding is not None
assert finding["order_number"] == "1001"
assert finding["expected_discount"] == 42.0
assert finding["actual_discount"] == 70.0
assert round(finding["delta"], 2) == 28.0
assert finding["stacked_with_promotion"] is True
def test_no_matching_channel_listing_is_skipped():
o = order(channel={"slug": "other-channel"})
assert flag_order(o) is None
def test_actual_voucher_discount_falls_back_to_total_gap():
o = order(discounts=[])
assert actual_voucher_discount(o) == 40.0
def test_has_stacked_promotion_and_voucher_detects_line_discount():
assert has_stacked_promotion_and_voucher(order()) is True
assert has_stacked_promotion_and_voucher(order(lines=[])) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import {
computeExpectedEntireOrderPercentageDiscount,
actualVoucherDiscount,
hasStackedPromotionAndVoucher,
flagOrder,
} from "./flag-entire-order-voucher-mismatch.js";
test("simple percentage off subtotal", () => {
assert.equal(computeExpectedEntireOrderPercentageDiscount(100.0, 10, false), 10.0);
});
test("issue 17453 scenario is non zero", () => {
// 40% catalogue promotion already brought the subtotal to 60, then a 70%
// entire order voucher applies to that already-discounted subtotal.
const subtotalAfterPromotion = 60.0;
const expected = computeExpectedEntireOrderPercentageDiscount(subtotalAfterPromotion, 70, false);
assert.equal(expected, 42.0);
assert.notEqual(expected, 0.0);
});
test("discount never exceeds subtotal", () => {
assert.equal(computeExpectedEntireOrderPercentageDiscount(50.0, 150, false), 50.0);
});
test("apply once per order uses cheapest unit", () => {
const expected = computeExpectedEntireOrderPercentageDiscount(100.0, 20, true, 15.0);
assert.equal(expected, 3.0);
});
const order = (over = {}) => ({
id: "T3JkZXI6MQ==",
number: "1001",
subtotal: { gross: { amount: 60.0 } },
undiscountedTotal: { gross: { amount: 100.0 } },
total: { gross: { amount: 60.0 } },
channel: { slug: "default-channel" },
voucher: {
id: "Vm91Y2hlcjox",
type: "ENTIRE_ORDER",
discountValueType: "PERCENTAGE",
channelListings: [{ channel: { slug: "default-channel" }, discountValue: 70 }],
},
discounts: [{ type: "VOUCHER", value: 70, valueType: "PERCENTAGE", amount: { amount: 42.0 } }],
lines: [{ id: "TGluZTox", unitDiscountAmount: 4.0, unitDiscountType: "PERCENTAGE",
undiscountedUnitPrice: { gross: { amount: 10.0 } } }],
...over,
});
test("matching order is not flagged", () => {
assert.equal(flagOrder(order()), null);
});
test("mismatched order is flagged with details", () => {
const bad = order({ discounts: [{ type: "VOUCHER", value: 70, valueType: "PERCENTAGE", amount: { amount: 70.0 } }] });
const finding = flagOrder(bad);
assert.notEqual(finding, null);
assert.equal(finding.orderNumber, "1001");
assert.equal(finding.expectedDiscount, 42.0);
assert.equal(finding.actualDiscount, 70.0);
assert.equal(Math.round(finding.delta * 100) / 100, 28.0);
assert.equal(finding.stackedWithPromotion, true);
});
test("no matching channel listing is skipped", () => {
const o = order({ channel: { slug: "other-channel" } });
assert.equal(flagOrder(o), null);
});
test("actual voucher discount falls back to total gap", () => {
const o = order({ discounts: [] });
assert.equal(actualVoucherDiscount(o), 40.0);
});
test("hasStackedPromotionAndVoucher detects line discount", () => {
assert.equal(hasStackedPromotionAndVoucher(order()), true);
assert.equal(hasStackedPromotionAndVoucher(order({ lines: [] })), false);
});
Case studies
A ten unit line that should never have totaled zero
A homeware store ran a forty percent off seasonal promotion on a bulk item and, on top of that, offered a seventy percent entire-order code to newsletter subscribers during a flash sale. A handful of orders that combined both came back with a total of exactly zero, which finance flagged immediately since a paid order cannot legitimately cost nothing.
Running the diagnostic against the order history found every affected order had the same shape: an ENTIRE_ORDER percentage voucher, a line with a non-zero unitDiscountAmount from the promotion, and an actual discount that matched the undiscounted total rather than the subtotal. The exact ten-unit, forty percent, seventy percent shape lined up with issue #17453, which made the report easy to hand to support with a citation attached.
Two customers, the same cart, two different totals
A fashion retailer noticed that two customers who built what looked like an identical cart, same items, same quantities, same voucher code, ended up with slightly different order totals. Support initially suspected a caching bug on the storefront, since nothing about the checkout flow looked different between the two sessions.
The diagnostic's delta output showed both orders were flagged, but by different amounts, consistent with the non-deterministic recalculation timing documented in issue #17453 rather than a storefront issue. That distinction mattered: instead of chasing a frontend caching bug that did not exist, the team reported both orders to finance with their expected and actual discounts and watched for the fix to land upstream.
After this runs against the order history, a voucher and promotion that stacked incorrectly is a report with an order number, an expected discount, an actual discount, and a delta, not a customer complaint or a quiet gap in reconciled revenue. Nothing about a finalized order gets rewritten automatically. Finance decides the correction for a paid order, and a still-open order only gets recalculated after a human approves it.
FAQ
Why is my ENTIRE_ORDER percentage voucher discount wrong when a product is also on sale?
Saleor documents an ENTIRE_ORDER voucher discount as applying to the subtotal, meaning the sum of line prices after any catalogue promotion has already been subtracted. In affected versions, the order-discount calculation pipeline instead sourced its base amount from the undiscounted total. So when a line also carried an active catalogue Promotion, the voucher percentage was computed against the pre-promotion price, and the two percentage discounts stacked additively instead of compounding on top of each other, understating the true combined discount or producing an inconsistent total.
Can I safely auto-correct an order once I find this discount mismatch?
Not automatically. Saleor has no mutation that lets you overwrite a finalized order's stored total or discount amount directly, and orderUpdate does not accept an arbitrary total override. Rewriting a paid or fulfilled order's totals also risks breaking reconciliation with a payment gateway or an accounting system. The safe pattern is to run the diagnostic in DRY_RUN mode, report every mismatched order to finance, and only apply a correction, such as an orderDiscountAdd adjustment or a recalculation trigger on a still-open order, after a human has reviewed and approved it.
How do I detect an order where the entire order voucher and a catalogue promotion stacked incorrectly?
Query orders whose voucher has type ENTIRE_ORDER and discountValueType PERCENTAGE, along with subtotal, undiscountedTotal, total, the voucher's channel discount value, and each line's unitDiscountAmount and undiscountedUnitPrice. Recompute the expected discount as the order subtotal, which already reflects any catalogue promotion, multiplied by the voucher percentage, and compare it to the discount Saleor actually applied. A difference larger than a small rounding tolerance, especially on an order whose lines also carry a promotion discount, indicates the stacking bug described in Saleor GitHub issue #17453.
Related field notes
Citations
On the problem:
- Bug: Percentage vouchers (ENTIRE_ORDER) discount calculated incorrectly. github.com/saleor/saleor/issues/17453
- Saleor Commerce Documentation: Vouchers. docs.saleor.io/developer/discounts/vouchers
- Base price fields in Checkout and Order should contain only catalogue promotion discount. github.com/saleor/saleor/issues/15334
On the solution:
- Saleor Commerce Documentation: Price Calculation. docs.saleor.io/developer/price-calculation
- Saleor Commerce Documentation: the Voucher object. docs.saleor.io/api-reference/discounts/objects/voucher
- 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 miscalculated voucher 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