Diagnostic Orders & Fulfillment
Manual order line discount deleted on recalculation
A staff member applies a manual discount to an order line with orderLineDiscountUpdate, the unit price drops, and everyone moves on. Then something else touches the same draft order, a new line, a shipping address, a voucher, and the price recalculates. The manual discount that was there a minute ago is gone, the line is back to its normal price, and nothing in the response ever said so. Here is why Saleor can silently drop a manual discount during recalculation and a script that catches it before the order ships at the wrong price.
Saleor recalculates draft and unconfirmed order prices lazily, through fetch_order_prices_if_expired. Any mutation that touches the order, orderLinesCreate, orderLineUpdate, an orderUpdate that changes the shipping or billing address, an orderShippingMethodUpdate, or applying a voucher, can trigger that pass, which re-derives each line's unit price from the undiscounted price plus whatever catalogue promotions and vouchers currently apply. A manually applied line discount, stored as unitDiscountType, unitDiscountValue, and unitDiscountReason on the line, is supposed to take precedence over that. But if the manual flag was not correctly carried through the update, for example because the mutation rebuilt the line or ran before the address the recalculation keys off of existed, the pass falls back to standard pricing and clears the manual discount without any error. See saleor/saleor#4675. Run a small Python or Node.js script that snapshots every open order's lines before and after these mutations and flags any line where the manual discount silently disappeared. Full code, tests, and a human-confirmed restore guard are below.
The problem in plain words
A draft or unconfirmed order in Saleor is not a fixed price sheet. Every line's unit price is recomputed on demand, from the undiscounted price plus whatever catalogue promotions and vouchers currently apply to that channel and product. That recomputation only runs when something asks for it, which is any mutation that touches the order.
A manual discount applied through orderLineDiscountUpdate is meant to be the exception. It records a valueType, a value, and a reason directly on the line, and that should win over catalogue and voucher pricing every time the line is recalculated. In practice, if the mutation that follows rebuilds the line, changes the shipping context the recalculation depends on, or simply runs before the manual discount fields have fully settled, the recalculation pass falls back to standard pricing. The line's unitDiscountValue goes back to zero, unitDiscountReason goes back to null, and the unit price quietly returns to whatever catalogue and vouchers say it should be. Nothing in the mutation response mentions the line ever had a manual discount.
Why it happens
- Draft and unconfirmed order prices are lazy.
fetch_order_prices_if_expiredre-derives each line's unit price from the undiscounted price plus whatever catalogue promotions and vouchers currently apply, and it can run as a side effect of almost any order mutation, not only ones you would expect to touch pricing. - A manual discount is supposed to take precedence over catalogue and voucher pricing during that recalculation, but the precedence depends on the manual flag and reason being correctly carried through whatever mutation triggered the pass. If the mutation rebuilt the line object, or changed context the recalculation keys off of, like the shipping address, before the manual discount fields were consistently attached, the fallback path wins instead.
- None of this surfaces an error. The mutation that triggered the recalculation,
orderLinesCreate,orderLineUpdate,orderUpdate,orderShippingMethodUpdate, or a voucher application, returns successfully. The only symptom is the line'sunitDiscountValueandunitDiscountReasonquietly reverting, see saleor/saleor#4675. - The same lazy pricing model has caused related confusion elsewhere, such as catalogue discounts staying applied after a sale is deleted (discussion #14617) and the broader checkout price recalculation refactor tracked in issue #11887. Recalculation timing is a known sharp edge across the pricing pipeline, not a one-off bug in a single mutation.
A merchant sees a discounted line at checkout confirmation time, or worse, after the order ships, and the order total no longer matches what the customer was promised. Support has to explain a price the store itself removed by accident.
Saleor gives you no supported mutation to "restore" a wiped discount, because it has no memory of what the discount used to be once the fields are cleared. That means the only safe fix is to keep your own snapshot from before the mutation ran, detect the loss by diffing it against a fresh read afterward, and never re-apply that old value automatically. A product price could have legitimately changed in between, and blindly restoring a stale discount could be just as wrong as losing it. Flag it, show a human the before and after, and only re-apply with their say-so.
The fix, as a flow
The script snapshots every open order's lines before you run any of the mutations known to trigger recalculation, then re-queries the same lines afterward. It compares the two: if a line had a manual discount before and does not have one after, that is a loss, not an intentional edit, and it gets flagged. Restoring the discount is never automatic. It only happens under an explicit dry-run-off flag, using the exact value captured in the snapshot, and only after a human has looked at the before and after and confirmed nothing else about the order changed in between.
Build it step by step
Get an app token with order read and write access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read and manage orders. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" # start safe, this script never blind-restores a discount
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" // start safe, this script never blind-restores a discount
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
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 every open order's lines
Page through open (draft or unconfirmed) orders with the orders connection, and for each one read every line's unitDiscount, unitDiscountType, unitDiscountValue, unitDiscountReason, unitPrice, and undiscountedUnitPrice. Keep this snapshot before you run any mutation that might trigger a recalculation, then take a second snapshot afterward with the same query, fresh from the API.
ORDER_LINES_QUERY = """
query($id: ID!) {
order(id: $id) {
id
status
lines {
id
productName
unitDiscount { amount currency }
unitDiscountType
unitDiscountValue
unitDiscountReason
undiscountedUnitPrice { gross { amount } }
unitPrice { gross { amount } }
isPriceOverridden
}
}
}"""
def snapshot_order_lines(order_id):
order = gql(ORDER_LINES_QUERY, {"id": order_id})["order"]
return {
line["id"]: {
"productName": line["productName"],
"unitDiscountType": line["unitDiscountType"],
"unitDiscountValue": line["unitDiscountValue"] or 0,
"unitDiscountReason": line["unitDiscountReason"],
"unitPriceGrossAmount": line["unitPrice"]["gross"]["amount"],
"undiscountedUnitPriceGrossAmount": line["undiscountedUnitPrice"]["gross"]["amount"],
}
for line in order["lines"]
}
const ORDER_LINES_QUERY = `
query($id: ID!) {
order(id: $id) {
id
status
lines {
id
productName
unitDiscount { amount currency }
unitDiscountType
unitDiscountValue
unitDiscountReason
undiscountedUnitPrice { gross { amount } }
unitPrice { gross { amount } }
isPriceOverridden
}
}
}`;
async function snapshotOrderLines(orderId) {
const order = (await gql(ORDER_LINES_QUERY, { id: orderId })).order;
const snapshot = {};
for (const line of order.lines) {
snapshot[line.id] = {
productName: line.productName,
unitDiscountType: line.unitDiscountType,
unitDiscountValue: line.unitDiscountValue || 0,
unitDiscountReason: line.unitDiscountReason,
unitPriceGrossAmount: line.unitPrice.gross.amount,
undiscountedUnitPriceGrossAmount: line.undiscountedUnitPrice.gross.amount,
};
}
return snapshot;
}
Decide, with one pure function
Keep the decision in its own function that takes a line's before and after snapshot and returns whether the discount was lost, whether to flag it, and, only when lost, the exact input needed to restore it. It is pure: no network, no database, just two plain objects in and a decision record out, which makes it trivial to test.
def decide_discount_loss(before, after):
had_discount = before["unitDiscountValue"] > 0 or bool(before["unitDiscountReason"])
lost_value = after["unitDiscountValue"] == 0
lost_reason = not after["unitDiscountReason"]
lost = had_discount and lost_value and lost_reason
if not lost:
return {"lost": False, "shouldFlag": False, "restoreInput": None}
restore_input = {
"valueType": before["unitDiscountType"],
"value": before["unitDiscountValue"],
"reason": before["unitDiscountReason"],
}
return {"lost": True, "shouldFlag": True, "restoreInput": restore_input}
export function decideDiscountLoss(before, after) {
const hadDiscount = before.unitDiscountValue > 0 || Boolean(before.unitDiscountReason);
const lostValue = after.unitDiscountValue === 0;
const lostReason = !after.unitDiscountReason;
const lost = hadDiscount && lostValue && lostReason;
if (!lost) {
return { lost: false, shouldFlag: false, restoreInput: null };
}
const restoreInput = {
valueType: before.unitDiscountType,
value: before.unitDiscountValue,
reason: before.unitDiscountReason,
};
return { lost: true, shouldFlag: true, restoreInput };
}
Run the recalculation-triggering mutation, then re-snapshot
Call whatever mutation you need, orderLinesCreate, orderLineUpdate, an address change through orderUpdate, orderShippingMethodUpdate, or a voucher application, and immediately re-query the same order's lines. Compare every line id that existed in the before snapshot against its entry in the after snapshot using decide_discount_loss.
def flag_losses(order_id, before, after):
flagged = []
for line_id, before_line in before.items():
after_line = after.get(line_id)
if after_line is None:
continue
decision = decide_discount_loss(before_line, after_line)
if decision["shouldFlag"]:
flagged.append({
"orderId": order_id,
"lineId": line_id,
"productName": before_line["productName"],
"before": before_line,
"after": after_line,
"restoreInput": decision["restoreInput"],
})
return flagged
function flagLosses(orderId, before, after) {
const flagged = [];
for (const [lineId, beforeLine] of Object.entries(before)) {
const afterLine = after[lineId];
if (!afterLine) continue;
const decision = decideDiscountLoss(beforeLine, afterLine);
if (decision.shouldFlag) {
flagged.push({
orderId,
lineId,
productName: beforeLine.productName,
before: beforeLine,
after: afterLine,
restoreInput: decision.restoreInput,
});
}
}
return flagged;
}
Restore only under a human-confirmed, dry-run-off path
Under DRY_RUN=true, the default, the script only reports the flagged order and line ids with their before and after discount values. It never calls a write mutation. When DRY_RUN=false and a human has looked at the report and confirmed nothing else legitimately changed the price in between, it re-applies the captured discount with orderLineDiscountUpdate using the exact valueType, value, and reason from the snapshot.
Never auto-restore a discount the moment you detect it missing. A product price could have legitimately changed between your two snapshots, and re-applying a stale value could create a new, wrong discount rather than fixing anything. Flag it, show the before and after to a person, and only run the restore mutation with DRY_RUN=false once they say to.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, snapshots order lines before and after a recalculation-triggering mutation, flags any line whose manual discount silently disappeared, and only restores it, using the pure decision function's captured value, when a human has explicitly authorized the write.
"""Detect Saleor order lines whose manual discount silently disappears when
the order's prices recalculate (saleor/saleor#4675).
Draft and unconfirmed order prices are lazy: any mutation that touches the
order, adding a line, updating a line, changing the shipping address or
method, or applying a voucher, can trigger fetch_order_prices_if_expired,
which re-derives each line's unit price from the undiscounted price plus
whatever catalogue promotions and vouchers currently apply. A manual line
discount applied through orderLineDiscountUpdate is supposed to take
precedence over that, but if its flag was not carried through correctly,
the recalculation falls back to standard pricing and clears
unit_discount_value and unit_discount_reason without any error.
This script never blind-restores a discount. Under DRY_RUN=true (the
default) it only reports flagged order and line ids with their before and
after values. When DRY_RUN=false and a human has confirmed the loss is a
regression and not a legitimate price change, it re-applies the exact
captured discount with orderLineDiscountUpdate. Safe to run again and
again, since a line with no detected loss is never touched.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_discount_loss")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDER_LINES_QUERY = """
query($id: ID!) {
order(id: $id) {
id
status
lines {
id
productName
unitDiscount { amount currency }
unitDiscountType
unitDiscountValue
unitDiscountReason
undiscountedUnitPrice { gross { amount } }
unitPrice { gross { amount } }
isPriceOverridden
}
}
}"""
RESTORE_DISCOUNT_MUTATION = """
mutation($lineId: ID!, $input: OrderDiscountCommonInput!) {
orderLineDiscountUpdate(orderLineId: $lineId, input: $input) {
orderLine { id unitDiscountValue unitDiscountReason }
errors { field code message }
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def decide_discount_loss(before, after):
had_discount = before["unitDiscountValue"] > 0 or bool(before["unitDiscountReason"])
lost_value = after["unitDiscountValue"] == 0
lost_reason = not after["unitDiscountReason"]
lost = had_discount and lost_value and lost_reason
if not lost:
return {"lost": False, "shouldFlag": False, "restoreInput": None}
restore_input = {
"valueType": before["unitDiscountType"],
"value": before["unitDiscountValue"],
"reason": before["unitDiscountReason"],
}
return {"lost": True, "shouldFlag": True, "restoreInput": restore_input}
def snapshot_order_lines(order_id):
order = gql(ORDER_LINES_QUERY, {"id": order_id})["order"]
return {
line["id"]: {
"productName": line["productName"],
"unitDiscountType": line["unitDiscountType"],
"unitDiscountValue": line["unitDiscountValue"] or 0,
"unitDiscountReason": line["unitDiscountReason"],
"unitPriceGrossAmount": line["unitPrice"]["gross"]["amount"],
"undiscountedUnitPriceGrossAmount": line["undiscountedUnitPrice"]["gross"]["amount"],
}
for line in order["lines"]
}
def flag_losses(order_id, before, after):
flagged = []
for line_id, before_line in before.items():
after_line = after.get(line_id)
if after_line is None:
continue
decision = decide_discount_loss(before_line, after_line)
if decision["shouldFlag"]:
flagged.append({
"orderId": order_id,
"lineId": line_id,
"productName": before_line["productName"],
"before": before_line,
"after": after_line,
"restoreInput": decision["restoreInput"],
})
return flagged
def restore_discount(line_id, restore_input):
result = gql(RESTORE_DISCOUNT_MUTATION, {"lineId": line_id, "input": restore_input})["orderLineDiscountUpdate"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["orderLine"]
def run(order_id, mutate_fn):
"""mutate_fn is the caller-supplied function that performs the mutation
suspected of triggering recalculation, for example orderLinesCreate or
orderUpdate. It receives no arguments and its return value is ignored.
"""
before = snapshot_order_lines(order_id)
mutate_fn()
after = snapshot_order_lines(order_id)
flagged = flag_losses(order_id, before, after)
for item in flagged:
log.warning(
"Order %s line %s (%s) lost its manual discount. before=%s after=%s",
item["orderId"], item["lineId"], item["productName"],
item["before"]["unitDiscountValue"], item["after"]["unitDiscountValue"],
)
if not DRY_RUN:
restore_discount(item["lineId"], item["restoreInput"])
log.info("Restored discount on line %s.", item["lineId"])
log.info("Done. %d line(s) flagged for a lost manual discount.", len(flagged))
return flagged
if __name__ == "__main__":
run(order_id=os.environ.get("ORDER_ID", ""), mutate_fn=lambda: None)
/**
* Detect Saleor order lines whose manual discount silently disappears when
* the order's prices recalculate (saleor/saleor#4675).
*
* Draft and unconfirmed order prices are lazy: any mutation that touches
* the order, adding a line, updating a line, changing the shipping address
* or method, or applying a voucher, can trigger fetch_order_prices_if_expired,
* which re-derives each line's unit price from the undiscounted price plus
* whatever catalogue promotions and vouchers currently apply. A manual line
* discount applied through orderLineDiscountUpdate is supposed to take
* precedence over that, but if its flag was not carried through correctly,
* the recalculation falls back to standard pricing and clears
* unitDiscountValue and unitDiscountReason without any error.
*
* This script never blind-restores a discount. Under DRY_RUN=true (the
* default) it only reports flagged order and line ids with their before and
* after values. When DRY_RUN=false and a human has confirmed the loss is a
* regression and not a legitimate price change, it re-applies the exact
* captured discount with orderLineDiscountUpdate. Run on demand around any
* mutation you suspect of triggering recalculation.
*
* Guide: https://www.allanninal.dev/saleor/manual-line-discount-deleted-on-recalculation/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideDiscountLoss(before, after) {
const hadDiscount = before.unitDiscountValue > 0 || Boolean(before.unitDiscountReason);
const lostValue = after.unitDiscountValue === 0;
const lostReason = !after.unitDiscountReason;
const lost = hadDiscount && lostValue && lostReason;
if (!lost) {
return { lost: false, shouldFlag: false, restoreInput: null };
}
const restoreInput = {
valueType: before.unitDiscountType,
value: before.unitDiscountValue,
reason: before.unitDiscountReason,
};
return { lost: true, shouldFlag: true, restoreInput };
}
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_LINES_QUERY = `
query($id: ID!) {
order(id: $id) {
id
status
lines {
id
productName
unitDiscount { amount currency }
unitDiscountType
unitDiscountValue
unitDiscountReason
undiscountedUnitPrice { gross { amount } }
unitPrice { gross { amount } }
isPriceOverridden
}
}
}`;
const RESTORE_DISCOUNT_MUTATION = `
mutation($lineId: ID!, $input: OrderDiscountCommonInput!) {
orderLineDiscountUpdate(orderLineId: $lineId, input: $input) {
orderLine { id unitDiscountValue unitDiscountReason }
errors { field code message }
}
}`;
async function snapshotOrderLines(orderId) {
const order = (await gql(ORDER_LINES_QUERY, { id: orderId })).order;
const snapshot = {};
for (const line of order.lines) {
snapshot[line.id] = {
productName: line.productName,
unitDiscountType: line.unitDiscountType,
unitDiscountValue: line.unitDiscountValue || 0,
unitDiscountReason: line.unitDiscountReason,
unitPriceGrossAmount: line.unitPrice.gross.amount,
undiscountedUnitPriceGrossAmount: line.undiscountedUnitPrice.gross.amount,
};
}
return snapshot;
}
function flagLosses(orderId, before, after) {
const flagged = [];
for (const [lineId, beforeLine] of Object.entries(before)) {
const afterLine = after[lineId];
if (!afterLine) continue;
const decision = decideDiscountLoss(beforeLine, afterLine);
if (decision.shouldFlag) {
flagged.push({
orderId,
lineId,
productName: beforeLine.productName,
before: beforeLine,
after: afterLine,
restoreInput: decision.restoreInput,
});
}
}
return flagged;
}
async function restoreDiscount(lineId, restoreInput) {
const result = (await gql(RESTORE_DISCOUNT_MUTATION, { lineId, input: restoreInput })).orderLineDiscountUpdate;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.orderLine;
}
/**
* mutateFn is the caller-supplied function that performs the mutation
* suspected of triggering recalculation, for example orderLinesCreate or
* orderUpdate. It receives no arguments and its return value is ignored.
*/
export async function run(orderId, mutateFn) {
const before = await snapshotOrderLines(orderId);
await mutateFn();
const after = await snapshotOrderLines(orderId);
const flagged = flagLosses(orderId, before, after);
for (const item of flagged) {
console.warn(
`Order ${item.orderId} line ${item.lineId} (${item.productName}) lost its manual discount. before=${item.before.unitDiscountValue} after=${item.after.unitDiscountValue}`
);
if (!DRY_RUN) {
await restoreDiscount(item.lineId, item.restoreInput);
console.log(`Restored discount on line ${item.lineId}.`);
}
}
console.log(`Done. ${flagged.length} line(s) flagged for a lost manual discount.`);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run(process.env.ORDER_ID || "", async () => {}).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which lines get flagged and what value a human-authorized restore would use. Because decide_discount_loss is pure, the test needs no network and no Saleor account. It just feeds in plain before and after line snapshots and checks the answer.
from detect_discount_loss import decide_discount_loss
def before_line(**over):
base = {
"unitDiscountType": "FIXED",
"unitDiscountValue": 5.0,
"unitDiscountReason": "Loyalty discount",
"unitPriceGrossAmount": 15.0,
}
base.update(over)
return base
def after_line(**over):
base = {
"unitDiscountType": "FIXED",
"unitDiscountValue": 5.0,
"unitDiscountReason": "Loyalty discount",
"unitPriceGrossAmount": 15.0,
"undiscountedUnitPriceGrossAmount": 20.0,
}
base.update(over)
return base
def test_no_loss_when_discount_unchanged():
decision = decide_discount_loss(before_line(), after_line())
assert decision == {"lost": False, "shouldFlag": False, "restoreInput": None}
def test_loss_when_value_and_reason_both_cleared():
after = after_line(unitDiscountValue=0, unitDiscountReason=None, unitPriceGrossAmount=20.0)
decision = decide_discount_loss(before_line(), after)
assert decision["lost"] is True
assert decision["shouldFlag"] is True
assert decision["restoreInput"] == {
"valueType": "FIXED",
"value": 5.0,
"reason": "Loyalty discount",
}
def test_no_loss_when_line_never_had_a_manual_discount():
before = before_line(unitDiscountValue=0, unitDiscountReason=None)
after = after_line(unitDiscountValue=0, unitDiscountReason=None, unitPriceGrossAmount=20.0)
decision = decide_discount_loss(before, after)
assert decision["lost"] is False
assert decision["restoreInput"] is None
def test_no_loss_when_value_present_but_reason_still_set():
after = after_line(unitDiscountValue=0, unitDiscountReason="Loyalty discount")
decision = decide_discount_loss(before_line(), after)
assert decision["lost"] is False
def test_restore_input_uses_percentage_type_from_before():
before = before_line(unitDiscountType="PERCENTAGE", unitDiscountValue=10.0)
after = after_line(unitDiscountValue=0, unitDiscountReason=None, unitPriceGrossAmount=20.0)
decision = decide_discount_loss(before, after)
assert decision["restoreInput"]["valueType"] == "PERCENTAGE"
assert decision["restoreInput"]["value"] == 10.0
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideDiscountLoss } from "./detect-discount-loss.js";
const beforeLine = (over = {}) => ({
unitDiscountType: "FIXED",
unitDiscountValue: 5.0,
unitDiscountReason: "Loyalty discount",
unitPriceGrossAmount: 15.0,
...over,
});
const afterLine = (over = {}) => ({
unitDiscountType: "FIXED",
unitDiscountValue: 5.0,
unitDiscountReason: "Loyalty discount",
unitPriceGrossAmount: 15.0,
undiscountedUnitPriceGrossAmount: 20.0,
...over,
});
test("no loss when discount unchanged", () => {
const decision = decideDiscountLoss(beforeLine(), afterLine());
assert.deepEqual(decision, { lost: false, shouldFlag: false, restoreInput: null });
});
test("loss when value and reason both cleared", () => {
const after = afterLine({ unitDiscountValue: 0, unitDiscountReason: null, unitPriceGrossAmount: 20.0 });
const decision = decideDiscountLoss(beforeLine(), after);
assert.equal(decision.lost, true);
assert.equal(decision.shouldFlag, true);
assert.deepEqual(decision.restoreInput, { valueType: "FIXED", value: 5.0, reason: "Loyalty discount" });
});
test("no loss when line never had a manual discount", () => {
const before = beforeLine({ unitDiscountValue: 0, unitDiscountReason: null });
const after = afterLine({ unitDiscountValue: 0, unitDiscountReason: null, unitPriceGrossAmount: 20.0 });
const decision = decideDiscountLoss(before, after);
assert.equal(decision.lost, false);
assert.equal(decision.restoreInput, null);
});
test("no loss when value present but reason still set", () => {
const after = afterLine({ unitDiscountValue: 0, unitDiscountReason: "Loyalty discount" });
const decision = decideDiscountLoss(beforeLine(), after);
assert.equal(decision.lost, false);
});
test("restore input uses percentage type from before", () => {
const before = beforeLine({ unitDiscountType: "PERCENTAGE", unitDiscountValue: 10.0 });
const after = afterLine({ unitDiscountValue: 0, unitDiscountReason: null, unitPriceGrossAmount: 20.0 });
const decision = decideDiscountLoss(before, after);
assert.equal(decision.restoreInput.valueType, "PERCENTAGE");
assert.equal(decision.restoreInput.value, 10.0);
});
Case studies
A goodwill discount vanished after a shipping address fix
A support agent applied a manual ten percent discount to a line as a goodwill gesture on a delayed order, using orderLineDiscountUpdate with a clear reason. The customer then asked to change the delivery address before the order shipped, so a second agent ran an orderUpdate to correct it. That update triggered a recalculation, and the line's discount silently reverted to full price, no error, no warning.
Running the detector around address change mutations caught the exact line and order, with the original ten percent and reason preserved in the snapshot. A supervisor reviewed the before and after, confirmed nothing else about the product price had changed, and authorized the restore. The customer's invoice matched what they were promised.
Adding a line reset an earlier manual price override
A wholesale team builds draft orders by adding lines one at a time as a buyer finalizes their list, and applies a manual discount to a bundle line early in that process. Adding a later line with orderLinesCreate triggered a full price recalculation across the order, and the earlier bundle line's discount came back cleared even though nothing about the bundle line itself was touched.
The team started snapshotting lines before and after every orderLinesCreate call during draft assembly. The detector flagged the bundle line within the same session, before the draft was ever completed, so staff could re-apply the discount immediately instead of a customer noticing a higher total at final review.
After this runs around every mutation that can trigger recalculation, a manual discount that gets silently dropped is caught within the same session, not weeks later in a customer complaint. The team gets the exact order, line, and the discount value that used to be there, and any restore stays a deliberate, human-confirmed decision using the captured value, never a script guessing at what the price should be.
FAQ
Why did my Saleor manual order line discount disappear?
Saleor recalculates draft and unconfirmed order prices lazily. Any mutation that touches the order, adding a line, updating a line, changing the shipping address or method, or applying a voucher, triggers a recalculation pass that re-derives each line's unit price from the undiscounted price plus whatever catalogue promotions and vouchers currently apply. A manually applied discount is supposed to override that, but if its flag or reason was not correctly carried through the update, the recalculation falls back to standard pricing and silently clears unit_discount_value and unit_discount_reason, with no error surfaced.
Can I just restore the discount value I saw before the mutation ran?
Only with a human confirming it first. Blind auto-restore is unsafe because a legitimate price change, such as a product price update between your before and after snapshots, could make the old discount value wrong even though it looks like the same regression. Treat every detected loss as a flagged order and line to review, and only call orderLineDiscountUpdate again with the prior value once a person has confirmed nothing else changed in between.
How do I detect that a manual line discount was silently deleted?
Snapshot every open order's lines before and after any mutation that can trigger recalculation, reading unitDiscount, unitDiscountType, unitDiscountValue, unitDiscountReason, unitPrice, and undiscountedUnitPrice. If a line had a positive unitDiscountValue or a non-null unitDiscountReason before and both are zero or null afterward, while unitPrice has moved back toward undiscountedUnitPrice, that is a silent deletion, not an intentional edit, and the order and line should be flagged.
Related field notes
Citations
On the problem:
- Manual order: Discount get's deleted. github.com/saleor/saleor/issues/4675
- [RFC] Refactor sales price calculations in the checkout flow. github.com/saleor/saleor/issues/11887
- Checkout discount still applied on product/s when sale is deleted or the catalogue is empty. github.com/saleor/saleor/discussions/14617
On the solution:
- Saleor Commerce Documentation: Manual Discounts. docs.saleor.io/developer/discounts/manual-discounts
- Saleor Commerce Documentation: OrderLine Object. docs.saleor.io/api-reference/orders/objects/order-line
- Saleor Commerce Documentation: Price Calculation. docs.saleor.io/developer/price-calculation
Stuck on a tricky one?
If you have a problem in Saleor checkout, orders, channels, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a lost discount for you?
If this saved a customer from an unexpected total, or gave your support team the report they needed, 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