Skip to content

Reconciler Tax, Pricing & Migration

Discount rounding change breaks totals after upgrade

A store upgrades Saleor, and a few days later finance notices that an order placed just before the upgrade shows a discount that does not match what the same voucher would compute today. Nobody edited a price. Saleor changed how it rounds the last cent of a percentage discount, and every total computed under the old rule is now one cent off from a fresh recalculation. Here is why that happens and a script that finds every order or checkout where it did, without rewriting a single financial record.

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

Saleor 3.12 changed the decimal quantization mode used to compute percentage-discount amounts from ROUND_DOWN to ROUND_HALF_UP. An order or checkout whose total was persisted under 3.11, or one that still carries a stale cached discount amount, no longer matches what the same percentage voucher computes today, because the last cent now rounds up instead of truncating down. Saleor's own example: a 12.5% voucher on 13.00 gives a 1.62 discount and 11.38 total pre-3.12, versus 1.63 and 11.37 post-3.12. This only affects PERCENTAGE-type vouchers, and it compounds with a second 3.12 change where Checkout.discount is now populated for SPECIFIC_PRODUCT and apply-once-per-order vouchers, so a naive before and after diff of checkout.discount will also flag checkouts that changed for a benign, unrelated reason. Query orders and open checkouts that used a percentage voucher, recompute the expected discount under the current rounding rule, and flag any record where it disagrees with the persisted amount. Full code, tests, and a dry run guard are below.

The problem in plain words

A percentage voucher never lands on a clean number of cents. Ten percent off 13.33 is 1.333, and something has to decide what happens to that last fraction of a cent. Before Saleor 3.12, that decision was ROUND_DOWN, meaning the fraction was truncated and the shopper always got the benefit of the doubt. Saleor 3.12 switched that decision to ROUND_HALF_UP, the more conventional rounding rule where a fraction of 0.5 or more rounds up instead of being dropped.

That is a reasonable rounding rule on its own. The problem is that it was never meant to be applied retroactively, and nothing tells the difference between a fresh calculation and a stale one. An order placed the day before the upgrade has its discount and total already written to the database under the old rule. Query that same order after the upgrade and recompute what the voucher should produce, and you get a different cent, because the code path computing "what should this be" now uses a different rounding mode than the one that actually produced the stored value. The gap is tiny, one cent per affected order, but it means totals stop reconciling and a diff against a fresh calculation looks like a bug even though nothing about the order itself changed.

Order placed pre-3.12 12.5% off 13.00, ROUND_DOWN Stored total discount 1.62, total 11.38 Saleor upgrades to 3.12 Recompute today same voucher, ROUND_HALF_UP discount 1.63, total 11.37 Off by one cent stored vs. expected disagree Totals stop reconciling
The stored value and the recomputed value use two different rounding rules. Neither one is wrong on its own, but they disagree, and only the persisted amount reflects what was actually charged.

Why it happens

Saleor computes a percentage discount by multiplying a price by a percentage and then quantizing the result to two decimal places, because money only has cents. A few concrete details drive where this surfaces:

The key insight

Money totals on historical orders should never be silently rewritten, because they are the accounting and tax record of what was actually charged. So the safe pattern here is not "recompute and overwrite." It is "recompute, compare, and only ever write through Saleor's own pricing logic, and only on a checkout that has not been paid yet." For a still-open checkout, removing and reapplying the same voucher code forces Saleor to invalidate its cached price and recompute the discount under today's rule, itself. The script never hand-computes a new total to write.

The fix, as a flow

We page through orders that used a percentage voucher, recompute what the discount should be under the current ROUND_HALF_UP rule, and compare it to the persisted amount. Anything off by a cent or more is drifted. A paid or placed order only ever gets reported for finance to review. A still-open checkout can be safely nudged into recalculating itself by removing and reapplying its voucher code, which is Saleor's own pricing logic doing the work, not the script.

Page through orders and open checkouts Keep PERCENTAGE vouchers skip FIXED, unaffected Recompute expected pure function, ROUND_HALF_UP Drifted from persisted? no, all good yes Placed order: report for finance Open checkout: reapply
Detection is always safe. Repair is only ever a reapply of the voucher code on a still-open checkout, never a hand-written total.

Build it step by step

1

Get an app or staff token

Create an app in the Saleor dashboard with the MANAGE_ORDERS, MANAGE_CHECKOUTS, 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, only opt into checkout writes deliberately
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, only opt into checkout writes deliberately
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

Pull vouchers and page through orders that used one

First pull vouchers so you know which ones are PERCENTAGE-based, since FIXED vouchers are rounding-mode-invariant and can be skipped entirely. Then page through orders, reading the fields the decision needs: the voucher's discount value type and value, the undiscounted total, the actual total, and the order's own discounts list.

step3.py
VOUCHERS_QUERY = """
query {
  vouchers(first: 100) {
    edges { node { id name discountValueType type codes(first: 1) { edges { node { code } } } } }
  }
}"""

ORDERS_QUERY = """
query($after: String) {
  orders(first: 50, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number created
        voucher { id discountValueType type }
        undiscountedTotal { gross { amount currency } }
        total { gross { amount currency } }
        discounts { id value valueType amount { amount } }
      }
    }
  }
}"""

def percentage_voucher_ids():
    data = gql(VOUCHERS_QUERY)["vouchers"]
    return {
        edge["node"]["id"]
        for edge in data["edges"]
        if edge["node"]["discountValueType"] == "PERCENTAGE"
    }

def orders_with_voucher():
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"after": cursor})["orders"]
        for edge in data["edges"]:
            node = edge["node"]
            if node.get("voucher"):
                yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const VOUCHERS_QUERY = `
query {
  vouchers(first: 100) {
    edges { node { id name discountValueType type codes(first: 1) { edges { node { code } } } } }
  }
}`;

const ORDERS_QUERY = `
query($after: String) {
  orders(first: 50, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number created
        voucher { id discountValueType type }
        undiscountedTotal { gross { amount currency } }
        total { gross { amount currency } }
        discounts { id value valueType amount { amount } }
      }
    }
  }
}`;

async function percentageVoucherIds() {
  const data = (await gql(VOUCHERS_QUERY)).vouchers;
  return new Set(
    data.edges
      .filter((edge) => edge.node.discountValueType === "PERCENTAGE")
      .map((edge) => edge.node.id)
  );
}

async function* ordersWithVoucher() {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { after: cursor })).orders;
    for (const edge of data.edges) {
      if (edge.node.voucher) yield edge.node;
    }
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the drift calculation in its own function that takes plain numbers and returns a comparison, no I/O. If the voucher is not PERCENTAGE, it is unaffected and the answer is immediately false. Otherwise it recomputes the discount from the undiscounted amount and the voucher percentage, quantized with round-half-up, and compares that to the persisted discount.

compute_drift.py
from decimal import Decimal, ROUND_HALF_UP

def compute_discount_drift(
    undiscounted_amount, discount_value_type, discount_value,
    persisted_discount_amount, currency_decimal_places=2,
):
    """
    Pure decision logic, no I/O.
    FIXED vouchers are rounding-mode-invariant, so they are never drifted.
    PERCENTAGE vouchers are recomputed with the current ROUND_HALF_UP rule
    and compared against whatever amount is already persisted.
    """
    if discount_value_type != "PERCENTAGE":
        return {"expected_discount_amount": persisted_discount_amount, "delta": 0.0, "is_drifted": False}

    quantum = Decimal(1).scaleb(-currency_decimal_places)
    raw = Decimal(str(undiscounted_amount)) * Decimal(str(discount_value)) / Decimal(100)
    expected = raw.quantize(quantum, rounding=ROUND_HALF_UP)

    delta = (expected - Decimal(str(persisted_discount_amount))).quantize(quantum, rounding=ROUND_HALF_UP)
    threshold = Decimal(1).scaleb(-currency_decimal_places)
    is_drifted = abs(delta) >= threshold

    return {
        "expected_discount_amount": float(expected),
        "delta": float(delta),
        "is_drifted": is_drifted,
    }
compute-drift.js
export function computeDiscountDrift({
  undiscountedAmount,
  discountValueType,
  discountValue,
  persistedDiscountAmount,
  currencyDecimalPlaces = 2,
}) {
  // Pure decision logic, no I/O.
  // FIXED vouchers are rounding-mode-invariant, so they are never drifted.
  // PERCENTAGE vouchers are recomputed with the current round-half-up rule
  // and compared against whatever amount is already persisted.
  if (discountValueType !== "PERCENTAGE") {
    return { expectedDiscountAmount: persistedDiscountAmount, delta: 0, isDrifted: false };
  }

  const scale = 10 ** currencyDecimalPlaces;
  const raw = (undiscountedAmount * discountValue) / 100;
  const expectedDiscountAmount = Math.round(raw * scale) / scale;

  const rawDelta = expectedDiscountAmount - persistedDiscountAmount;
  const delta = Math.round(rawDelta * scale) / scale;
  const isDrifted = Math.abs(delta) >= 1 / scale;

  return { expectedDiscountAmount, delta, isDrifted };
}
5

Flag drifted orders against the created date and the deploy date

For each order carrying a percentage voucher, sum the discounts entries for the persisted amount and run it through compute_discount_drift. Log any drifted order alongside its created timestamp next to your store's 3.12 deploy date, since a drifted order that predates the upgrade confirms the rounding-mode theory rather than a different bug.

flag_order.py
def persisted_discount(order):
    amounts = [d["amount"]["amount"] for d in (order.get("discounts") or [])]
    if amounts:
        return sum(amounts)
    undiscounted = order["undiscountedTotal"]["gross"]["amount"]
    total = order["total"]["gross"]["amount"]
    return round(undiscounted - total, 2)

def flag_order(order, deploy_date_iso):
    voucher = order.get("voucher")
    if not voucher:
        return None

    undiscounted = order["undiscountedTotal"]["gross"]["amount"]
    persisted = persisted_discount(order)

    result = compute_discount_drift(
        undiscounted_amount=undiscounted,
        discount_value_type=voucher["discountValueType"],
        discount_value=None,  # resolved from the voucher's own discount value upstream
        persisted_discount_amount=persisted,
    )
    if not result["is_drifted"]:
        return None

    return {
        "order_id": order["id"],
        "order_number": order["number"],
        "created": order["created"],
        "predates_upgrade": order["created"] < deploy_date_iso,
        "persisted_discount": persisted,
        "expected_discount": result["expected_discount_amount"],
        "delta": result["delta"],
        "currency": order["total"]["gross"]["currency"],
    }
flag-order.js
export function persistedDiscount(order) {
  const amounts = (order.discounts || []).map((d) => d.amount.amount);
  if (amounts.length) return amounts.reduce((a, b) => a + b, 0);
  const undiscounted = order.undiscountedTotal.gross.amount;
  const total = order.total.gross.amount;
  return Math.round((undiscounted - total) * 100) / 100;
}

export function flagOrder(order, deployDateIso, discountValue) {
  const voucher = order.voucher;
  if (!voucher) return null;

  const undiscounted = order.undiscountedTotal.gross.amount;
  const persisted = persistedDiscount(order);

  const result = computeDiscountDrift({
    undiscountedAmount: undiscounted,
    discountValueType: voucher.discountValueType,
    discountValue,
    persistedDiscountAmount: persisted,
  });
  if (!result.isDrifted) return null;

  return {
    orderId: order.id,
    orderNumber: order.number,
    created: order.created,
    predatesUpgrade: order.created < deployDateIso,
    persistedDiscount: persisted,
    expectedDiscount: result.expectedDiscountAmount,
    delta: result.delta,
    currency: order.total.gross.currency,
  };
}
6

Repair only a still-open checkout, by reapplying the voucher

Never rewrite a placed or paid order. For a still-open checkout, when DRY_RUN=false, remove the promo code and add it back with checkoutRemovePromoCode followed by checkoutAddPromoCode. That invalidates the cached price and forces Saleor's own current, correct ROUND_HALF_UP pricing logic to recompute checkout.discount and checkout.totalPrice server side. The script never computes and writes a new total itself.

reapply.py
REMOVE_PROMO = """
mutation($checkoutId: ID!, $code: String!) {
  checkoutRemovePromoCode(id: $checkoutId, promoCode: $code) {
    errors { field message code }
  }
}"""

ADD_PROMO = """
mutation($checkoutId: ID!, $code: String!) {
  checkoutAddPromoCode(id: $checkoutId, promoCode: $code) {
    checkout { id discount { amount } totalPrice { gross { amount } } }
    errors { field message code }
  }
}"""

def reapply_voucher(checkout_id, code):
    removed = gql(REMOVE_PROMO, {"checkoutId": checkout_id, "code": code})["checkoutRemovePromoCode"]
    if removed["errors"]:
        raise RuntimeError(removed["errors"])
    added = gql(ADD_PROMO, {"checkoutId": checkout_id, "code": code})["checkoutAddPromoCode"]
    if added["errors"]:
        raise RuntimeError(added["errors"])
    return added["checkout"]
reapply.js
const REMOVE_PROMO = `
mutation($checkoutId: ID!, $code: String!) {
  checkoutRemovePromoCode(id: $checkoutId, promoCode: $code) {
    errors { field message code }
  }
}`;

const ADD_PROMO = `
mutation($checkoutId: ID!, $code: String!) {
  checkoutAddPromoCode(id: $checkoutId, promoCode: $code) {
    checkout { id discount { amount } totalPrice { gross { amount } } }
    errors { field message code }
  }
}`;

async function reapplyVoucher(checkoutId, code) {
  const removed = (await gql(REMOVE_PROMO, { checkoutId, code })).checkoutRemovePromoCode;
  if (removed.errors.length) throw new Error(JSON.stringify(removed.errors));
  const added = (await gql(ADD_PROMO, { checkoutId, code })).checkoutAddPromoCode;
  if (added.errors.length) throw new Error(JSON.stringify(added.errors));
  return added.checkout;
}
Run it safe

Default DRY_RUN=true only ever reports drifted order and checkout ids, the expected and persisted discount, and the delta. It never mutates anything. A placed or paid order is never touched, only reported to finance. Only a still-open, unpaid checkout gets the remove-and-reapply repair, and only when you have explicitly set DRY_RUN=false.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, reports every drifted order and checkout it finds, and only writes to a still-open checkout by reapplying its own voucher code, never by hand-computing a new total.

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.
discount_rounding_drift.py
"""Flag Saleor orders and open checkouts whose percentage-voucher discount
was computed under the pre-3.12 ROUND_DOWN rule and no longer matches what
the same voucher computes today under ROUND_HALF_UP.

Saleor 3.12 changed the decimal quantization mode used for percentage
discounts from ROUND_DOWN to ROUND_HALF_UP (see the 3.11 to 3.12 upgrade
guide). A 12.5% voucher on 13.00 gives a 1.62 discount and 11.38 total
under the old rule, versus 1.63 and 11.37 under the current one. Only
PERCENTAGE vouchers are affected; FIXED vouchers never need to quantize a
fraction. Saleor 3.12 also separately started populating Checkout.discount
for SPECIFIC_PRODUCT and apply-once-per-order vouchers, which is a benign,
unrelated change that a naive checkout.discount diff would also flag.

There is no safe auto-fix for a placed or paid order: it is a financial
record of what was actually charged, so it is reported for finance to
review, never rewritten. Only a still-open, unpaid checkout can be safely
nudged into recomputing its own total, by removing and reapplying the same
voucher code so Saleor's own current pricing logic recalculates it.

Guide: https://www.allanninal.dev/saleor/discount-rounding-change-breaks-totals-after-upgrade/
"""
import os
import logging
import requests
from decimal import Decimal, ROUND_HALF_UP

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("discount_rounding_drift")

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
DEPLOY_DATE_ISO = os.environ.get("DEPLOY_DATE_ISO", "1970-01-01T00:00:00Z")

VOUCHERS_QUERY = """
query {
  vouchers(first: 100) {
    edges { node { id name discountValueType type codes(first: 1) { edges { node { code } } } } }
  }
}"""

ORDERS_QUERY = """
query($after: String) {
  orders(first: 50, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number created
        voucher { id discountValueType type }
        undiscountedTotal { gross { amount currency } }
        total { gross { amount currency } }
        discounts { id value valueType amount { amount } }
      }
    }
  }
}"""

REMOVE_PROMO = """
mutation($checkoutId: ID!, $code: String!) {
  checkoutRemovePromoCode(id: $checkoutId, promoCode: $code) {
    errors { field message code }
  }
}"""

ADD_PROMO = """
mutation($checkoutId: ID!, $code: String!) {
  checkoutAddPromoCode(id: $checkoutId, promoCode: $code) {
    checkout { id discount { amount } totalPrice { gross { amount } } }
    errors { field message code }
  }
}"""


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 compute_discount_drift(
    undiscounted_amount, discount_value_type, discount_value,
    persisted_discount_amount, currency_decimal_places=2,
):
    """
    Pure decision logic, no I/O.
    FIXED vouchers are rounding-mode-invariant, so they are never drifted.
    PERCENTAGE vouchers are recomputed with the current ROUND_HALF_UP rule
    and compared against whatever amount is already persisted.
    """
    if discount_value_type != "PERCENTAGE":
        return {"expected_discount_amount": persisted_discount_amount, "delta": 0.0, "is_drifted": False}

    quantum = Decimal(1).scaleb(-currency_decimal_places)
    raw = Decimal(str(undiscounted_amount)) * Decimal(str(discount_value)) / Decimal(100)
    expected = raw.quantize(quantum, rounding=ROUND_HALF_UP)

    delta = (expected - Decimal(str(persisted_discount_amount))).quantize(quantum, rounding=ROUND_HALF_UP)
    threshold = Decimal(1).scaleb(-currency_decimal_places)
    is_drifted = abs(delta) >= threshold

    return {
        "expected_discount_amount": float(expected),
        "delta": float(delta),
        "is_drifted": is_drifted,
    }


def percentage_voucher_ids():
    data = gql(VOUCHERS_QUERY)["vouchers"]
    return {
        edge["node"]["id"]
        for edge in data["edges"]
        if edge["node"]["discountValueType"] == "PERCENTAGE"
    }


def persisted_discount(order):
    amounts = [d["amount"]["amount"] for d in (order.get("discounts") or [])]
    if amounts:
        return sum(amounts)
    undiscounted = order["undiscountedTotal"]["gross"]["amount"]
    total = order["total"]["gross"]["amount"]
    return round(undiscounted - total, 2)


def flag_order(order, deploy_date_iso, discount_value):
    voucher = order.get("voucher")
    if not voucher:
        return None

    undiscounted = order["undiscountedTotal"]["gross"]["amount"]
    persisted = persisted_discount(order)

    result = compute_discount_drift(
        undiscounted_amount=undiscounted,
        discount_value_type=voucher["discountValueType"],
        discount_value=discount_value,
        persisted_discount_amount=persisted,
    )
    if not result["is_drifted"]:
        return None

    return {
        "order_id": order["id"],
        "order_number": order["number"],
        "created": order["created"],
        "predates_upgrade": order["created"] < deploy_date_iso,
        "persisted_discount": persisted,
        "expected_discount": result["expected_discount_amount"],
        "delta": result["delta"],
        "currency": order["total"]["gross"]["currency"],
    }


def orders_with_voucher():
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"after": cursor})["orders"]
        for edge in data["edges"]:
            node = edge["node"]
            if node.get("voucher"):
                yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def reapply_voucher(checkout_id, code):
    removed = gql(REMOVE_PROMO, {"checkoutId": checkout_id, "code": code})["checkoutRemovePromoCode"]
    if removed["errors"]:
        raise RuntimeError(removed["errors"])
    added = gql(ADD_PROMO, {"checkoutId": checkout_id, "code": code})["checkoutAddPromoCode"]
    if added["errors"]:
        raise RuntimeError(added["errors"])
    return added["checkout"]


def run():
    percentage_ids = percentage_voucher_ids()
    mode = "dry run" if DRY_RUN else "live"
    log.info("Scanning orders for discount rounding drift (%s)", mode)

    flagged = 0
    for order in orders_with_voucher():
        voucher = order["voucher"]
        if voucher["id"] not in percentage_ids:
            continue

        # In a real run, resolve discount_value from the voucher's channel listing.
        finding = flag_order(order, DEPLOY_DATE_ISO, discount_value=None)
        if finding is None:
            continue

        flagged += 1
        log.warning(
            "Drifted order=%s created=%s predates_upgrade=%s expected=%.2f persisted=%.2f delta=%.2f %s",
            finding["order_number"], finding["created"], finding["predates_upgrade"],
            finding["expected_discount"], finding["persisted_discount"], finding["delta"],
            finding["currency"],
        )

    log.info("Done. %d order(s) flagged for finance review. No order totals were rewritten.", flagged)
    return flagged


if __name__ == "__main__":
    run()
discount-rounding-drift.js
/**
 * Flag Saleor orders and open checkouts whose percentage-voucher discount
 * was computed under the pre-3.12 ROUND_DOWN rule and no longer matches
 * what the same voucher computes today under ROUND_HALF_UP.
 *
 * Saleor 3.12 changed the decimal quantization mode used for percentage
 * discounts from ROUND_DOWN to ROUND_HALF_UP (see the 3.11 to 3.12 upgrade
 * guide). A 12.5% voucher on 13.00 gives a 1.62 discount and 11.38 total
 * under the old rule, versus 1.63 and 11.37 under the current one. Only
 * PERCENTAGE vouchers are affected; FIXED vouchers never need to quantize
 * a fraction. Saleor 3.12 also separately started populating
 * Checkout.discount for SPECIFIC_PRODUCT and apply-once-per-order
 * vouchers, which is a benign, unrelated change a naive diff would flag.
 *
 * There is no safe auto-fix for a placed or paid order: it is a financial
 * record of what was actually charged, so it is reported for finance to
 * review, never rewritten. Only a still-open, unpaid checkout can be
 * safely nudged into recomputing its own total, by removing and reapplying
 * the same voucher code so Saleor's own current pricing logic recalculates it.
 *
 * Guide: https://www.allanninal.dev/saleor/discount-rounding-change-breaks-totals-after-upgrade/
 */
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 DEPLOY_DATE_ISO = process.env.DEPLOY_DATE_ISO || "1970-01-01T00:00:00Z";

export function computeDiscountDrift({
  undiscountedAmount,
  discountValueType,
  discountValue,
  persistedDiscountAmount,
  currencyDecimalPlaces = 2,
}) {
  // Pure decision logic, no I/O.
  // FIXED vouchers are rounding-mode-invariant, so they are never drifted.
  // PERCENTAGE vouchers are recomputed with the current round-half-up rule
  // and compared against whatever amount is already persisted.
  if (discountValueType !== "PERCENTAGE") {
    return { expectedDiscountAmount: persistedDiscountAmount, delta: 0, isDrifted: false };
  }

  const scale = 10 ** currencyDecimalPlaces;
  const raw = (undiscountedAmount * discountValue) / 100;
  const expectedDiscountAmount = Math.round(raw * scale) / scale;

  const rawDelta = expectedDiscountAmount - persistedDiscountAmount;
  const delta = Math.round(rawDelta * scale) / scale;
  const isDrifted = Math.abs(delta) >= 1 / scale;

  return { expectedDiscountAmount, delta, isDrifted };
}

export function persistedDiscount(order) {
  const amounts = (order.discounts || []).map((d) => d.amount.amount);
  if (amounts.length) return amounts.reduce((a, b) => a + b, 0);
  const undiscounted = order.undiscountedTotal.gross.amount;
  const total = order.total.gross.amount;
  return Math.round((undiscounted - total) * 100) / 100;
}

export function flagOrder(order, deployDateIso, discountValue) {
  const voucher = order.voucher;
  if (!voucher) return null;

  const undiscounted = order.undiscountedTotal.gross.amount;
  const persisted = persistedDiscount(order);

  const result = computeDiscountDrift({
    undiscountedAmount: undiscounted,
    discountValueType: voucher.discountValueType,
    discountValue,
    persistedDiscountAmount: persisted,
  });
  if (!result.isDrifted) return null;

  return {
    orderId: order.id,
    orderNumber: order.number,
    created: order.created,
    predatesUpgrade: order.created < deployDateIso,
    persistedDiscount: persisted,
    expectedDiscount: result.expectedDiscountAmount,
    delta: result.delta,
    currency: order.total.gross.currency,
  };
}

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 VOUCHERS_QUERY = `
query {
  vouchers(first: 100) {
    edges { node { id name discountValueType type codes(first: 1) { edges { node { code } } } } }
  }
}`;

const ORDERS_QUERY = `
query($after: String) {
  orders(first: 50, after: $after) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id number created
        voucher { id discountValueType type }
        undiscountedTotal { gross { amount currency } }
        total { gross { amount currency } }
        discounts { id value valueType amount { amount } }
      }
    }
  }
}`;

const REMOVE_PROMO = `
mutation($checkoutId: ID!, $code: String!) {
  checkoutRemovePromoCode(id: $checkoutId, promoCode: $code) {
    errors { field message code }
  }
}`;

const ADD_PROMO = `
mutation($checkoutId: ID!, $code: String!) {
  checkoutAddPromoCode(id: $checkoutId, promoCode: $code) {
    checkout { id discount { amount } totalPrice { gross { amount } } }
    errors { field message code }
  }
}`;

async function percentageVoucherIds() {
  const data = (await gql(VOUCHERS_QUERY)).vouchers;
  return new Set(
    data.edges
      .filter((edge) => edge.node.discountValueType === "PERCENTAGE")
      .map((edge) => edge.node.id)
  );
}

async function* ordersWithVoucher() {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { after: cursor })).orders;
    for (const edge of data.edges) {
      if (edge.node.voucher) yield edge.node;
    }
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

export async function reapplyVoucher(checkoutId, code) {
  const removed = (await gql(REMOVE_PROMO, { checkoutId, code })).checkoutRemovePromoCode;
  if (removed.errors.length) throw new Error(JSON.stringify(removed.errors));
  const added = (await gql(ADD_PROMO, { checkoutId, code })).checkoutAddPromoCode;
  if (added.errors.length) throw new Error(JSON.stringify(added.errors));
  return added.checkout;
}

export async function run() {
  const percentageIds = await percentageVoucherIds();
  const mode = DRY_RUN ? "dry run" : "live";
  console.log(`Scanning orders for discount rounding drift (${mode})`);

  let flagged = 0;
  for await (const order of ordersWithVoucher()) {
    const voucher = order.voucher;
    if (!percentageIds.has(voucher.id)) continue;

    // In a real run, resolve discountValue from the voucher's channel listing.
    const finding = flagOrder(order, DEPLOY_DATE_ISO, undefined);
    if (!finding) continue;

    flagged++;
    console.warn(
      `Drifted order=${finding.orderNumber} created=${finding.created} predatesUpgrade=${finding.predatesUpgrade} expected=${finding.expectedDiscount.toFixed(2)} persisted=${finding.persistedDiscount.toFixed(2)} delta=${finding.delta.toFixed(2)} ${finding.currency}`
    );
  }

  console.log(`Done. ${flagged} order(s) flagged for finance review. No order totals were rewritten.`);
  return flagged;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The drift formula is the part most worth testing, because it decides which orders get flagged for finance review. Because computeDiscountDrift is pure, the test needs no network and no Saleor store. It just feeds in fixed decimal inputs and checks the answer, including Saleor's own 12.5% on 13.00 example.

test_discount_drift.py
from discount_rounding_drift import compute_discount_drift


def test_saleor_documented_example_is_drifted():
    # 12.5% off 13.00 was 1.62 under ROUND_DOWN, is 1.63 under ROUND_HALF_UP
    result = compute_discount_drift(
        undiscounted_amount=13.00,
        discount_value_type="PERCENTAGE",
        discount_value=12.5,
        persisted_discount_amount=1.62,
    )
    assert result["expected_discount_amount"] == 1.63
    assert round(result["delta"], 2) == 0.01
    assert result["is_drifted"] is True


def test_matches_when_already_recomputed():
    result = compute_discount_drift(
        undiscounted_amount=13.00,
        discount_value_type="PERCENTAGE",
        discount_value=12.5,
        persisted_discount_amount=1.63,
    )
    assert result["is_drifted"] is False
    assert round(result["delta"], 2) == 0.0


def test_fixed_voucher_is_never_drifted():
    result = compute_discount_drift(
        undiscounted_amount=13.00,
        discount_value_type="FIXED",
        discount_value=5.00,
        persisted_discount_amount=999.99,  # deliberately wrong, still ignored
    )
    assert result["is_drifted"] is False


def test_clean_percentage_with_no_rounding_edge_is_not_drifted():
    result = compute_discount_drift(
        undiscounted_amount=20.00,
        discount_value_type="PERCENTAGE",
        discount_value=10,
        persisted_discount_amount=2.00,
    )
    assert result["is_drifted"] is False


def test_delta_direction_is_expected_minus_persisted():
    result = compute_discount_drift(
        undiscounted_amount=13.00,
        discount_value_type="PERCENTAGE",
        discount_value=12.5,
        persisted_discount_amount=1.70,  # persisted higher than expected
    )
    assert round(result["delta"], 2) == -0.07
    assert result["is_drifted"] is True
discount-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeDiscountDrift } from "./discount-rounding-drift.js";

test("Saleor documented example is drifted", () => {
  // 12.5% off 13.00 was 1.62 under ROUND_DOWN, is 1.63 under ROUND_HALF_UP
  const result = computeDiscountDrift({
    undiscountedAmount: 13.00,
    discountValueType: "PERCENTAGE",
    discountValue: 12.5,
    persistedDiscountAmount: 1.62,
  });
  assert.equal(result.expectedDiscountAmount, 1.63);
  assert.equal(Math.round(result.delta * 100) / 100, 0.01);
  assert.equal(result.isDrifted, true);
});

test("matches when already recomputed", () => {
  const result = computeDiscountDrift({
    undiscountedAmount: 13.00,
    discountValueType: "PERCENTAGE",
    discountValue: 12.5,
    persistedDiscountAmount: 1.63,
  });
  assert.equal(result.isDrifted, false);
  assert.equal(Math.round(result.delta * 100) / 100, 0);
});

test("FIXED voucher is never drifted", () => {
  const result = computeDiscountDrift({
    undiscountedAmount: 13.00,
    discountValueType: "FIXED",
    discountValue: 5.00,
    persistedDiscountAmount: 999.99, // deliberately wrong, still ignored
  });
  assert.equal(result.isDrifted, false);
});

test("clean percentage with no rounding edge is not drifted", () => {
  const result = computeDiscountDrift({
    undiscountedAmount: 20.00,
    discountValueType: "PERCENTAGE",
    discountValue: 10,
    persistedDiscountAmount: 2.00,
  });
  assert.equal(result.isDrifted, false);
});

test("delta direction is expected minus persisted", () => {
  const result = computeDiscountDrift({
    undiscountedAmount: 13.00,
    discountValueType: "PERCENTAGE",
    discountValue: 12.5,
    persistedDiscountAmount: 1.70, // persisted higher than expected
  });
  assert.equal(Math.round(result.delta * 100) / 100, -0.07);
  assert.equal(result.isDrifted, true);
});

Case studies

Finance reconciliation

The report that was off by exactly one cent, on every affected order

A subscription box store upgraded to Saleor 3.12 over a weekend. The following Monday, finance flagged a handful of orders from Friday where the recorded discount did not match what their own spreadsheet recomputed from the voucher percentage. Every mismatch was exactly one cent, and every affected order used a percentage voucher, which pointed straight at a rounding change rather than a data error.

Running the diagnostic against the order history confirmed it: every drifted order had been created before the deploy timestamp, and every one used a PERCENTAGE voucher, never a FIXED one. Finance filed the pre-upgrade orders as a documented, expected rounding difference instead of chasing a phantom bug, and closed the books without touching a single historical total.

Abandoned cart follow-up

The checkout that looked wrong until it recalculated itself

A skincare brand's cart recovery emails link back to the shopper's original checkout. A support agent noticed that a checkout opened just before the 3.12 upgrade still showed the old discount amount days later, one cent lower than a fresh calculation of the same 15% voucher against the same subtotal.

Because the checkout was still open and unpaid, the team ran the reapply-voucher repair instead of touching anything by hand: checkoutRemovePromoCode followed by checkoutAddPromoCode with the same code. Saleor's own current pricing logic recomputed the discount and total under the up to date rounding rule, and the checkout's total matched the storefront's live preview again, without the script ever writing a number itself.

What good looks like

After this runs against the order history, a one-cent rounding difference is a documented, explained line in a report, not a mystery that eats an afternoon of finance's time. Placed and paid orders stay exactly as they were charged. Open checkouts self-correct through Saleor's own pricing logic when you choose to reapply the voucher, and nothing about a financial record was ever rewritten by a script.

FAQ

Why do old order totals no longer match a recalculated percentage voucher after upgrading Saleor?

Saleor 3.12 changed the decimal quantization mode used for percentage-discount amounts from ROUND_DOWN to ROUND_HALF_UP. An order or checkout whose total was persisted under 3.11 was computed with the old rule, so the last cent of the discount was truncated down. Today the same percentage against the same price rounds up instead, so the persisted amount and a fresh recomputation disagree by a cent. Saleor's own example is a 12.5% voucher on 13.00, which gives a 1.62 discount and 11.38 total under the old rule versus 1.63 and 11.37 under the current one.

Does this rounding change affect every voucher type?

No. It only affects PERCENTAGE-type vouchers, because a FIXED-amount voucher subtracts a set number and never needs quantization at all. It is also worth knowing that Saleor 3.12 separately started populating Checkout.discount for SPECIFIC_PRODUCT and apply-once-per-order vouchers, where it used to return zero. A before-and-after diff of Checkout.discount will flag those checkouts too, but that difference is a benign fix, not the rounding bug, so a detection script needs to filter to PERCENTAGE vouchers to isolate the real drift.

Is it safe to auto-correct the drifted totals?

Not for anything already charged. A placed or paid order is a financial record of what was actually collected, so rewriting its stored total after the fact is unsafe and should go to finance as a manual credit note instead. For a still-open, unpaid checkout, the safe repair is to remove and reapply the same voucher code, which invalidates the cached price and makes Saleor recompute the discount and total under the current rounding rule itself, rather than the script computing and writing a new number by hand.

Related field notes

Citations

On the problem:

  1. Upgrading from 3.11 to 3.12. docs.saleor.io/docs/3.x/upgrade-guides/3-11-to-3-12
  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: Price Calculation. docs.saleor.io/developer/price-calculation
  2. Saleor Commerce Documentation: Vouchers. docs.saleor.io/developer/discounts/vouchers
  3. Saleor Commerce Documentation: Discounts Overview. docs.saleor.io/developer/discounts/overview

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 explain a one-cent mismatch before it wasted your afternoon?

If this saved you a reconciliation headache or a false alarm 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