Diagnostic Tax, Pricing & Migration
Tax calculation off by rounding cents on totals
Finance pulls an order, multiplies the tax rate by the subtotal, and gets a number that is a cent or two away from what Saleor actually charged. Nothing failed. No mutation errored. Saleor rounded the tax on every order line to the cent, one line at a time, then added up those already-rounded amounts to build the order total. That is not the same arithmetic as taking the rate times the whole subtotal in one shot, and the two can legitimately disagree by a few cents. Here is why that gap exists and a script that audits it the way Saleor itself does the math, instead of flagging every honest cent of rounding as a bug.
With Saleor's flat-rate tax strategy, tax is computed and rounded to two decimal places independently on each order line, and those already-rounded per-line amounts are summed to produce order.total and order.subtotal, rather than summing exact unrounded values first and rounding once. Because line.unitPrice is itself derived by dividing the rounded line.totalPrice by quantity, high quantity or low unit price lines amplify the per-unit rounding remainder, so the sum of correctly rounded lines can legitimately differ from rate times subtotal by one or more cents. This is documented, longstanding Saleor behavior, reported in saleor/saleor#6720, not a transient bug. Run a small Python or Node.js script that recomputes expected tax per line using Saleor's own rounding rule, flags only real mismatches with a tolerance for legitimate compounding, and reports them, since blind-writing totals risks corrupting an order that Saleor actually got right. Full code, tests, and a dry run guard are below.
The problem in plain words
You would expect an order's total tax to be a simple multiplication: take the subtotal, multiply by the tax rate, round once. That is not what Saleor does. Saleor calculates tax on each order line separately, and rounds that line's tax to two decimal places as soon as it is computed. Then it adds up every line's already-rounded tax, plus shipping's already-rounded tax, to get the order's total tax. The rounding happens many times, once per line, instead of once at the end.
That difference in order of operations is where the cents go missing, or extra. A single rounding step on a large number spreads its error across the whole amount. Many small rounding steps, one per line, can each nudge a fraction of a cent the same direction, and those nudges add up. A line with a high quantity and a low unit price is the worst case, because line.unitPrice is derived by dividing the already-rounded line.totalPrice by the quantity, so the per-unit remainder gets amplified back out when you multiply it by quantity again elsewhere.
Why it happens
- Saleor's flat-rate tax strategy computes and rounds tax to two decimal places on each order line independently, then sums those already-rounded per-line amounts, plus shipping, to get
order.totalandorder.subtotal. It never sums exact unrounded values first and rounds once at the end. line.unitPriceis derived by dividing the roundedline.totalPriceby quantity, not the other way around. For example, 0.05 divided by 1.22 is 0.0098..., which rounds up to 0.01, and 112 units at 0.01 diverges from 112 times 0.05 over 1.22 by about 12 cents.- High quantity, low unit price lines amplify this the most, because the per-unit rounding remainder gets multiplied back out by the quantity when anyone recomputes the line a different way.
- This is a documented, longstanding Saleor behavior, reported in
saleor/saleor#6720, rather than a transient bug that a retry or a cache clear would fix.
None of this throws an error or shows up as a failed mutation. Finance just sees a number that does not reconcile against a simple rate times subtotal spreadsheet formula, and assumes something is broken. See the citations at the end for the exact GitHub issue and the discussion threads on Saleor's tax rounding model.
A naive audit that recomputes rate * subtotal and compares it to order.total.tax will generate false positives on every order, because that is simply not the arithmetic Saleor performs. The audit has to compare against Saleor's own per-line rounding semantics instead: recompute each line's expected tax from that line's own rounded totalPrice.net.amount and taxRate, and only flag a real problem when order.total.tax disagrees with the sum of the lines and shipping tax that Saleor itself already reports, since that disagreement points at a caching or denormalization bug, not rounding.
The fix, as a flow
The script pages through orders with their lines and tax rates, recomputes each line's expected tax the way Saleor computes it, and flags a line only when it drifts from Saleor's own arithmetic by more than a tolerance sized for how many lines the order has. Separately, it checks whether order.total.tax actually equals the sum of the line and shipping tax Saleor reports, which is the real bug signature. Because rounding drift is expected data, not corrupted data, the default action is always a reconciliation report, never a direct write to order totals.
Build it step by step
Get an app token with order read access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and tax classes. 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 only reports by default
// 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 only reports by default
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;
}
Page through orders and read the line and tax fields the decision needs
Ask for orders(first, after) and read back each order's total, subtotal, shippingPrice, and every line's quantity, unitPrice, totalPrice, and taxRate. Page with a cursor so the job handles a large order history without loading it all at once.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
total { tax { amount } net { amount } gross { amount } }
subtotal { net { amount } gross { amount } }
shippingPrice { tax { amount } net { amount } gross { amount } }
lines {
id
quantity
unitPrice { tax { amount } net { amount } gross { amount } }
totalPrice { tax { amount } net { amount } gross { amount } }
taxRate
}
}
}
}
}"""
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["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
total { tax { amount } net { amount } gross { amount } }
subtotal { net { amount } gross { amount } }
shippingPrice { tax { amount } net { amount } gross { amount } }
lines {
id
quantity
unitPrice { tax { amount } net { amount } gross { amount } }
totalPrice { tax { amount } net { amount } gross { amount } }
taxRate
}
}
}
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a line's net total, tax rate, and actual tax amount, and returns whether it is a real mismatch. A pure function like this is easy to read and test, which we do later. It recomputes the expected tax the way Saleor does, rounding to the currency's minor unit with ROUND_HALF_UP, then compares to the actual tax with a tolerance, since a single cent of drift on one line is expected arithmetic, not a bug.
from decimal import Decimal, ROUND_HALF_UP
def check_line_tax(total_net_amount, tax_rate, actual_tax_amount,
currency_exponent=2, tolerance_cents=1):
quantum = Decimal(10) ** -currency_exponent
expected_tax = (Decimal(str(total_net_amount)) * Decimal(str(tax_rate))).quantize(
quantum, rounding=ROUND_HALF_UP
)
delta = abs(Decimal(str(actual_tax_amount)) - expected_tax)
is_mismatch = delta > quantum * tolerance_cents
return is_mismatch, expected_tax, delta
export function checkLineTax(totalNetAmount, taxRate, actualTaxAmount,
currencyExponent = 2, toleranceCents = 1) {
const quantum = Math.pow(10, -currencyExponent);
const expectedTax = roundHalfUp(totalNetAmount * taxRate, currencyExponent);
const delta = round2(Math.abs(actualTaxAmount - expectedTax));
const isMismatch = delta > round2(quantum * toleranceCents) + 1e-9;
return { isMismatch, expectedTax, delta };
}
function roundHalfUp(value, exponent) {
const factor = Math.pow(10, exponent);
return Math.round((value * factor) + Number.EPSILON * Math.sign(value)) / factor;
}
function round2(value) {
return Math.round(value * 1e8) / 1e8;
}
Reconcile the order, and only report
For each line, run check_line_tax and collect the ones that are real mismatches. Separately, recompute expected_order_tax as the sum of every line's own already-rounded tax plus shipping tax, and compare that to order.total.tax. If those disagree, that is the real aggregation bug, since Saleor's own model is that order.total is just the sum of already-rounded line and shipping components. Either way, the output is a reconciliation report, never a direct write.
def reconcile_order(order):
line_mismatches = []
for line in order["lines"]:
total_net = line["totalPrice"]["net"]["amount"]
actual_tax = line["totalPrice"]["tax"]["amount"]
is_mismatch, expected_tax, delta = check_line_tax(
total_net, line["taxRate"], actual_tax,
tolerance_cents=max(1, len(order["lines"])),
)
if is_mismatch:
line_mismatches.append({
"lineId": line["id"], "actual": actual_tax,
"expected": float(expected_tax), "delta": float(delta),
})
expected_order_tax = sum(l["totalPrice"]["tax"]["amount"] for l in order["lines"])
expected_order_tax += order["shippingPrice"]["tax"]["amount"]
actual_order_tax = order["total"]["tax"]["amount"]
aggregation_bug = round(abs(actual_order_tax - expected_order_tax), 2) > 0.0
return {
"orderId": order["id"],
"orderNumber": order["number"],
"lineMismatches": line_mismatches,
"aggregationBug": aggregation_bug,
"actualOrderTax": actual_order_tax,
"expectedOrderTax": round(expected_order_tax, 2),
}
function reconcileOrder(order) {
const lineMismatches = [];
for (const line of order.lines) {
const totalNet = line.totalPrice.net.amount;
const actualTax = line.totalPrice.tax.amount;
const { isMismatch, expectedTax, delta } = checkLineTax(
totalNet, line.taxRate, actualTax,
2, Math.max(1, order.lines.length)
);
if (isMismatch) {
lineMismatches.push({ lineId: line.id, actual: actualTax, expected: expectedTax, delta });
}
}
let expectedOrderTax = order.lines.reduce((sum, l) => sum + l.totalPrice.tax.amount, 0);
expectedOrderTax += order.shippingPrice.tax.amount;
const actualOrderTax = order.total.tax.amount;
const aggregationBug = Math.round(Math.abs(actualOrderTax - expectedOrderTax) * 100) / 100 > 0;
return {
orderId: order.id,
orderNumber: order.number,
lineMismatches,
aggregationBug,
actualOrderTax,
expectedOrderTax: Math.round(expectedOrderTax * 100) / 100,
};
}
Wire it together with a dry run guard
The loop ties every piece together. Under DRY_RUN=true, the default, the script only logs a reconciliation report for every order with a real line mismatch or an aggregation bug. It never writes anything. If a genuine aggregation bug is confirmed, and the delta is below a small configured threshold, a gated correction can touch the order with a no-op line update to force Saleor's own recompute pipeline to run again, rather than writing amounts directly. Anything above that threshold is left for manual finance review.
Always start with DRY_RUN=true and read the report before authorizing anything. A one or two cent difference on a line, within tolerance for the order's line count, is normal Saleor rounding, not a bug, so never write order totals directly. Only a confirmed aggregation mismatch, where order.total.tax disagrees with the sum of Saleor's own already-rounded line and shipping tax, is worth acting on, and even then only through a forced recompute below a cent-delta threshold, never a raw overwrite.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through orders, reconciles each with the pure functions, always logs a report row for anything flagged, and only ever considers a gated recompute under an explicit DRY_RUN=false for a small, confirmed aggregation delta.
"""Audit Saleor orders for tax calculation mismatches, using Saleor's own
per-line rounding rule rather than a naive rate times subtotal recomputation.
With the flat-rate tax strategy, Saleor rounds tax to the cent on each order
line independently, then sums those already-rounded line amounts, plus
shipping, into order.total and order.subtotal. It never sums exact unrounded
values first and rounds once at the end. Because line.unitPrice is derived
by dividing the rounded line.totalPrice by quantity, high quantity, low
unit price lines amplify the per-unit rounding remainder, so the sum of
correctly rounded lines can legitimately differ from rate times subtotal by
one or more cents. This is documented, longstanding behavior (see
saleor/saleor#6720), not a bug, so this script never flags ordinary
per-line rounding drift.
Under DRY_RUN=true (the default) this script only reports flagged orders,
it never writes anything. A real aggregation bug looks different: the sum
of a line's own already-rounded tax plus shipping tax disagreeing with
order.total.tax, which points at cache or denormalization drift. Even
then, the safe corrective action is a gated no-op line update to force
Saleor's own recompute pipeline, limited to small confirmed deltas, never
a direct write to order totals. Run on a schedule. Safe to run again and
again.
"""
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("audit_tax_rounding")
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"
AGGREGATION_FIX_THRESHOLD_CENTS = float(os.environ.get("AGGREGATION_FIX_THRESHOLD_CENTS", "5"))
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
total { tax { amount } net { amount } gross { amount } }
subtotal { net { amount } gross { amount } }
shippingPrice { tax { amount } net { amount } gross { amount } }
lines {
id
quantity
unitPrice { tax { amount } net { amount } gross { amount } }
totalPrice { tax { amount } net { amount } gross { amount } }
taxRate
}
}
}
}
}"""
# A no-op line update (same quantity) forces Saleor's TaxedMoney recalculation
# pipeline to run again, without writing any amount directly.
FORCE_RECALC_MUTATION = """
mutation($orderId: ID!, $lineId: ID!, $quantity: Int!) {
orderLineUpdate(id: $lineId, input: { quantity: $quantity }) {
orderLine { id }
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 check_line_tax(total_net_amount, tax_rate, actual_tax_amount,
currency_exponent=2, tolerance_cents=1):
quantum = Decimal(10) ** -currency_exponent
expected_tax = (Decimal(str(total_net_amount)) * Decimal(str(tax_rate))).quantize(
quantum, rounding=ROUND_HALF_UP
)
delta = abs(Decimal(str(actual_tax_amount)) - expected_tax)
is_mismatch = delta > quantum * tolerance_cents
return is_mismatch, expected_tax, delta
def reconcile_order(order):
line_mismatches = []
for line in order["lines"]:
total_net = line["totalPrice"]["net"]["amount"]
actual_tax = line["totalPrice"]["tax"]["amount"]
is_mismatch, expected_tax, delta = check_line_tax(
total_net, line["taxRate"], actual_tax,
tolerance_cents=max(1, len(order["lines"])),
)
if is_mismatch:
line_mismatches.append({
"lineId": line["id"], "actual": actual_tax,
"expected": float(expected_tax), "delta": float(delta),
})
expected_order_tax = sum(l["totalPrice"]["tax"]["amount"] for l in order["lines"])
expected_order_tax += order["shippingPrice"]["tax"]["amount"]
actual_order_tax = order["total"]["tax"]["amount"]
aggregation_delta = round(abs(actual_order_tax - expected_order_tax), 2)
aggregation_bug = aggregation_delta > 0.0
return {
"orderId": order["id"],
"orderNumber": order["number"],
"lineMismatches": line_mismatches,
"aggregationBug": aggregation_bug,
"aggregationDelta": aggregation_delta,
"actualOrderTax": actual_order_tax,
"expectedOrderTax": round(expected_order_tax, 2),
}
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def force_recalculate(order_id, line_id, quantity):
result = gql(FORCE_RECALC_MUTATION, {
"orderId": order_id, "lineId": line_id, "quantity": quantity,
})["orderLineUpdate"]
if result["errors"]:
raise RuntimeError(result["errors"])
def run():
flagged = 0
fixed = 0
for order in all_orders():
result = reconcile_order(order)
if not result["lineMismatches"] and not result["aggregationBug"]:
continue
log.warning("Tax reconciliation flagged order %s: %s", order["number"], result)
flagged += 1
# Only a confirmed aggregation bug below the threshold is ever a
# candidate for a gated, forced recompute. Line-level rounding drift
# is expected arithmetic and is never auto-corrected.
if (
not DRY_RUN
and result["aggregationBug"]
and result["aggregationDelta"] <= AGGREGATION_FIX_THRESHOLD_CENTS / 100
and order["lines"]
):
first_line = order["lines"][0]
log.info(
"Forcing recompute on order %s via no-op line update (delta %.2f).",
order["number"], result["aggregationDelta"],
)
force_recalculate(order["id"], first_line["id"], first_line["quantity"])
fixed += 1
elif result["aggregationBug"]:
log.error(
"Order %s aggregation delta %.2f exceeds threshold. Escalating to manual finance review.",
order["number"], result["aggregationDelta"],
)
log.info("Done. %d order(s) flagged, %d recompute(s) triggered.", flagged, fixed)
if __name__ == "__main__":
run()
/**
* Audit Saleor orders for tax calculation mismatches, using Saleor's own
* per-line rounding rule rather than a naive rate times subtotal
* recomputation.
*
* With the flat-rate tax strategy, Saleor rounds tax to the cent on each
* order line independently, then sums those already-rounded line amounts,
* plus shipping, into order.total and order.subtotal. It never sums exact
* unrounded values first and rounds once at the end. Because
* line.unitPrice is derived by dividing the rounded line.totalPrice by
* quantity, high quantity, low unit price lines amplify the per-unit
* rounding remainder, so the sum of correctly rounded lines can
* legitimately differ from rate times subtotal by one or more cents. This
* is documented, longstanding behavior (see saleor/saleor#6720), not a
* bug, so this script never flags ordinary per-line rounding drift.
*
* Under DRY_RUN=true (the default) this script only reports flagged
* orders, it never writes anything. A real aggregation bug looks
* different: the sum of a line's own already-rounded tax plus shipping
* tax disagreeing with order.total.tax, which points at cache or
* denormalization drift. Even then, the safe corrective action is a
* gated no-op line update to force Saleor's own recompute pipeline,
* limited to small confirmed deltas, never a direct write to order
* totals. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/tax-calculation-rounding-mismatch/
*/
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";
const AGGREGATION_FIX_THRESHOLD_CENTS = Number(process.env.AGGREGATION_FIX_THRESHOLD_CENTS || 5);
export function checkLineTax(totalNetAmount, taxRate, actualTaxAmount,
currencyExponent = 2, toleranceCents = 1) {
const quantum = Math.pow(10, -currencyExponent);
const expectedTax = roundHalfUp(totalNetAmount * taxRate, currencyExponent);
const delta = round2(Math.abs(actualTaxAmount - expectedTax));
const isMismatch = delta > round2(quantum * toleranceCents) + 1e-9;
return { isMismatch, expectedTax, delta };
}
function roundHalfUp(value, exponent) {
const factor = Math.pow(10, exponent);
return Math.round((value * factor) + Number.EPSILON * Math.sign(value)) / factor;
}
function round2(value) {
return Math.round(value * 1e8) / 1e8;
}
export function reconcileOrder(order) {
const lineMismatches = [];
for (const line of order.lines) {
const totalNet = line.totalPrice.net.amount;
const actualTax = line.totalPrice.tax.amount;
const { isMismatch, expectedTax, delta } = checkLineTax(
totalNet, line.taxRate, actualTax,
2, Math.max(1, order.lines.length)
);
if (isMismatch) {
lineMismatches.push({ lineId: line.id, actual: actualTax, expected: expectedTax, delta });
}
}
let expectedOrderTax = order.lines.reduce((sum, l) => sum + l.totalPrice.tax.amount, 0);
expectedOrderTax += order.shippingPrice.tax.amount;
const actualOrderTax = order.total.tax.amount;
const aggregationDelta = Math.round(Math.abs(actualOrderTax - expectedOrderTax) * 100) / 100;
const aggregationBug = aggregationDelta > 0;
return {
orderId: order.id,
orderNumber: order.number,
lineMismatches,
aggregationBug,
aggregationDelta,
actualOrderTax,
expectedOrderTax: Math.round(expectedOrderTax * 100) / 100,
};
}
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
total { tax { amount } net { amount } gross { amount } }
subtotal { net { amount } gross { amount } }
shippingPrice { tax { amount } net { amount } gross { amount } }
lines {
id
quantity
unitPrice { tax { amount } net { amount } gross { amount } }
totalPrice { tax { amount } net { amount } gross { amount } }
taxRate
}
}
}
}
}`;
// A no-op line update (same quantity) forces Saleor's TaxedMoney
// recalculation pipeline to run again, without writing any amount directly.
const FORCE_RECALC_MUTATION = `
mutation($orderId: ID!, $lineId: ID!, $quantity: Int!) {
orderLineUpdate(id: $lineId, input: { quantity: $quantity }) {
orderLine { id }
errors { field message code }
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function forceRecalculate(orderId, lineId, quantity) {
const result = (await gql(FORCE_RECALC_MUTATION, { orderId, lineId, quantity })).orderLineUpdate;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
}
export async function run() {
let flagged = 0;
let fixed = 0;
for await (const order of allOrders()) {
const result = reconcileOrder(order);
if (!result.lineMismatches.length && !result.aggregationBug) continue;
console.warn(`Tax reconciliation flagged order ${order.number}:`, result);
flagged++;
// Only a confirmed aggregation bug below the threshold is ever a
// candidate for a gated, forced recompute. Line-level rounding drift is
// expected arithmetic and is never auto-corrected.
if (
!DRY_RUN &&
result.aggregationBug &&
result.aggregationDelta <= AGGREGATION_FIX_THRESHOLD_CENTS / 100 &&
order.lines.length
) {
const firstLine = order.lines[0];
console.log(
`Forcing recompute on order ${order.number} via no-op line update (delta ${result.aggregationDelta.toFixed(2)}).`
);
await forceRecalculate(order.id, firstLine.id, firstLine.quantity);
fixed++;
} else if (result.aggregationBug) {
console.error(
`Order ${order.number} aggregation delta ${result.aggregationDelta.toFixed(2)} exceeds threshold. Escalating to manual finance review.`
);
}
}
console.log(`Done. ${flagged} order(s) flagged, ${fixed} recompute(s) triggered.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders get flagged, and it has to correctly leave Saleor's own documented rounding drift alone. Because check_line_tax is pure, taking plain numbers, the test needs no network and no Saleor account. It reproduces the documented 12 cent drift from saleor/saleor#6720 and confirms that case is not flagged at the line level, then checks that an injected corrupted line is flagged.
from decimal import Decimal
from audit_tax_rounding import check_line_tax, reconcile_order
def test_exact_match_is_not_a_mismatch():
is_mismatch, expected, delta = check_line_tax(
total_net_amount=100.0, tax_rate=0.22, actual_tax_amount=22.0,
)
assert is_mismatch is False
assert expected == Decimal("22.00")
assert delta == Decimal("0.00")
def test_documented_112_unit_drift_is_not_flagged_at_tolerance():
# 0.05 / 1.22 = 0.0098..., rounds to 0.01 per unit, 112 units diverges
# from 112 * 0.05 / 1.22 by about 12 cents. With a tolerance sized for
# the order's line count this must not be treated as a mismatch.
unit_net = round(0.05 / 1.22, 2) # 0.01, Saleor's own rounded unit price
line_total_net = round(unit_net * 112, 2) # 1.12
actual_tax = round(unit_net * 0.22, 2) * 112 # tax computed per unit then summed
is_mismatch, expected, delta = check_line_tax(
total_net_amount=line_total_net, tax_rate=0.22,
actual_tax_amount=actual_tax, tolerance_cents=12,
)
assert is_mismatch is False
def test_injected_corrupted_line_is_flagged():
is_mismatch, expected, delta = check_line_tax(
total_net_amount=100.0, tax_rate=0.22, actual_tax_amount=30.0,
)
assert is_mismatch is True
assert expected == Decimal("22.00")
assert delta == Decimal("8.00")
def test_one_cent_drift_within_tolerance_is_not_a_mismatch():
is_mismatch, expected, delta = check_line_tax(
total_net_amount=50.0, tax_rate=0.2, actual_tax_amount=10.01,
tolerance_cents=1,
)
assert is_mismatch is False
def test_reconcile_order_flags_real_aggregation_bug():
order = {
"id": "T3JkZXI6MQ==",
"number": "1001",
"total": {"tax": {"amount": 99.0}},
"shippingPrice": {"tax": {"amount": 1.0}},
"lines": [
{
"id": "T3JkZXJMaW5lOjE=", "quantity": 1, "taxRate": 0.2,
"totalPrice": {"net": {"amount": 100.0}, "tax": {"amount": 20.0}},
},
],
}
result = reconcile_order(order)
assert result["aggregationBug"] is True
assert result["expectedOrderTax"] == 21.0
assert result["actualOrderTax"] == 99.0
def test_reconcile_order_ok_when_totals_match_saleor_own_sum():
order = {
"id": "T3JkZXI6Mg==",
"number": "1002",
"total": {"tax": {"amount": 21.0}},
"shippingPrice": {"tax": {"amount": 1.0}},
"lines": [
{
"id": "T3JkZXJMaW5lOjI=", "quantity": 1, "taxRate": 0.2,
"totalPrice": {"net": {"amount": 100.0}, "tax": {"amount": 20.0}},
},
],
}
result = reconcile_order(order)
assert result["aggregationBug"] is False
assert result["lineMismatches"] == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkLineTax, reconcileOrder } from "./audit-tax-rounding.js";
test("exact match is not a mismatch", () => {
const { isMismatch, expectedTax, delta } = checkLineTax(100.0, 0.22, 22.0);
assert.equal(isMismatch, false);
assert.equal(expectedTax, 22.0);
assert.equal(delta, 0);
});
test("documented 112 unit drift is not flagged at tolerance", () => {
// 0.05 / 1.22 = 0.0098..., rounds to 0.01 per unit, 112 units diverges
// from 112 * 0.05 / 1.22 by about 12 cents. Must not be flagged when the
// tolerance is sized for the order's line count.
const unitNet = Math.round((0.05 / 1.22) * 100) / 100; // 0.01
const lineTotalNet = Math.round(unitNet * 112 * 100) / 100; // 1.12
const actualTax = Math.round(unitNet * 0.22 * 100) / 100 * 112;
const { isMismatch } = checkLineTax(lineTotalNet, 0.22, actualTax, 2, 12);
assert.equal(isMismatch, false);
});
test("injected corrupted line is flagged", () => {
const { isMismatch, expectedTax, delta } = checkLineTax(100.0, 0.22, 30.0);
assert.equal(isMismatch, true);
assert.equal(expectedTax, 22.0);
assert.equal(delta, 8.0);
});
test("one cent drift within tolerance is not a mismatch", () => {
const { isMismatch } = checkLineTax(50.0, 0.2, 10.01, 2, 1);
assert.equal(isMismatch, false);
});
test("reconcileOrder flags real aggregation bug", () => {
const order = {
id: "T3JkZXI6MQ==",
number: "1001",
total: { tax: { amount: 99.0 } },
shippingPrice: { tax: { amount: 1.0 } },
lines: [
{
id: "T3JkZXJMaW5lOjE=", quantity: 1, taxRate: 0.2,
totalPrice: { net: { amount: 100.0 }, tax: { amount: 20.0 } },
},
],
};
const result = reconcileOrder(order);
assert.equal(result.aggregationBug, true);
assert.equal(result.expectedOrderTax, 21.0);
assert.equal(result.actualOrderTax, 99.0);
});
test("reconcileOrder OK when totals match Saleor's own sum", () => {
const order = {
id: "T3JkZXI6Mg==",
number: "1002",
total: { tax: { amount: 21.0 } },
shippingPrice: { tax: { amount: 1.0 } },
lines: [
{
id: "T3JkZXJMaW5lOjI=", quantity: 1, taxRate: 0.2,
totalPrice: { net: { amount: 100.0 }, tax: { amount: 20.0 } },
},
],
};
const result = reconcileOrder(order);
assert.equal(result.aggregationBug, false);
assert.deepEqual(result.lineMismatches, []);
});
Case studies
The spreadsheet that never agreed with Saleor
A finance team built a simple reconciliation spreadsheet that multiplied each order's subtotal by its tax rate and compared the result to order.total.tax. It flagged nearly every order in the store as wrong, usually by one to three cents, and the team spent a week convinced Saleor's tax engine was broken before escalating it as a data integrity incident.
Running the audit script showed that every one of those flagged orders passed the per-line check cleanly. The drift was exactly the documented behavior from saleor/saleor#6720, rounding tax per line before summing, not per-order rate multiplication. The team retired the naive spreadsheet formula and now runs the script's reconciliation report instead, which has flagged zero false positives since.
The one order where the totals actually disagreed with themselves
Among thousands of orders with normal per-line rounding drift, the audit surfaced a single order where order.total.tax did not even match the sum of that order's own line and shipping tax fields, a different and much rarer signal than ordinary rounding. That pointed at a stale cached total rather than a rounding artifact.
Because the aggregation delta was small and below the configured threshold, the gated recompute path issued a no-op line update to force Saleor to recalculate the order's totals server side. A follow-up query confirmed the order's total tax now matched the sum of its lines, and the order was cleared without anyone writing a dollar amount by hand.
After this runs on a schedule, a cent of rounding drift stops looking like a crisis. Finance gets a reconciliation report that separates Saleor's own expected per-line rounding from genuine aggregation bugs, and the only automated correction is a small, gated recompute that forces Saleor to redo its own math, never a direct rewrite of an order's tax amounts.
FAQ
Why does Saleor's order total tax not equal rate times subtotal exactly?
Saleor computes tax separately on each order line, rounds each line's tax to two decimal places, and then sums those already-rounded line amounts to get order.total and order.subtotal. It does not sum exact unrounded amounts first and round once at the end. Summing numbers that were each rounded on their own can legitimately land a cent or more away from multiplying the rate by the subtotal in one shot, especially with high quantity or low unit price lines.
Is a one or two cent tax difference on a Saleor order a bug?
Usually not. This is documented, longstanding Saleor behavior, reported in saleor/saleor#6720, that comes from rounding tax per line before summing rather than rounding once at the end. A real bug looks different: order.total.tax not matching the sum of every line's already-rounded tax plus shipping tax, which points at cached or stale data rather than normal per-line rounding.
Should a script automatically rewrite order totals when it finds a tax mismatch?
No. Per-line rounding drift of a few cents is expected arithmetic, not corrupted data, so writing amounts directly risks making a correct order wrong. The safe pattern is to report flagged orders for review, and only for a confirmed aggregation bug, force Saleor to recompute totals server side, such as a no-op line update, behind a DRY_RUN flag and a cent-delta threshold that escalates larger deltas to manual finance review.
Related field notes
Citations
On the problem:
- The calculation of the tax is not accurate. github.com/saleor/saleor/issues/6720
- Flat rate tax plugin. github.com/saleor/saleor/discussions/7942
- Surprising behavior with dynamic taxes sync webhook. github.com/saleor/saleor/discussions/10732
On the solution:
- Saleor Commerce Documentation: price calculation. docs.saleor.io/developer/price-calculation
- Saleor Commerce Documentation: taxes. docs.saleor.io/developer/taxes
- Saleor API Reference: the OrderLine object. docs.saleor.io/docs/3.x/api-reference/objects/order-line
Stuck on a tricky one?
If you have a problem in Saleor checkout, payments, stock, 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 settle a tax reconciliation headache?
If this saved your finance team from chasing a phantom rounding bug, 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