Diagnostic Tax, Pricing & Migration
Order subtotal uses gross instead of net price
Finance pulls an order out of Saleor, reads the subtotal, and it does not match what the ERP or the accounting export expects. Nothing errored. No mutation failed. The order's subtotal is a TaxedMoney object that carries a gross amount and a net amount side by side, and somewhere a script, a report, or a migration read the gross figure while the rest of the pipeline was built around net. Here is why Saleor keeps both numbers on purpose and a small script that finds every order where the wrong one was read, without touching Saleor's own correct data.
Saleor's Order.subtotal, Order.total, and every line's unitPrice and totalPrice resolve to a TaxedMoney object that always carries both a gross amount and a net amount together, computed per line from the channel's taxConfiguration (pricesEnteredWithTax, displayGrossPrices, chargeTaxes) or from a custom ORDER_CALCULATE_TAXES tax app webhook. The mismatch shows up when a script, report, or migration reads subtotal.gross.amount as if it were the only subtotal, while the store's channel is configured with pricesEnteredWithTax and the ERP or accounting export on the other end expects the net figure. Because each order line can carry its own tax rate, the total discrepancy equals the sum of per-line tax, not a fixed percentage. This is not corrupted Saleor data, since Saleor stores and returns both figures correctly. Run a small Python or Node.js script that recomputes the expected net and gross subtotal from the lines, compares each order's recorded figure against what the channel's tax config says it should be, and reports every mismatch for a human to resolve. Full code, tests, and a dry run guard are below.
The problem in plain words
Every money value that comes back from a Saleor order is not a single number. It is a TaxedMoney object, and it always hands you two figures at once: gross, the amount including tax, and net, the amount before tax. Saleor computes both from the same underlying line data, so neither one is more correct than the other. They are simply different views of the same order.
The trouble starts on the consuming side. A script that exports orders to an ERP, a finance report, or a data migration has to pick one of those two fields, and it is easy to pick the wrong one without noticing, because both fields return a plausible-looking number. If the channel's taxConfiguration.pricesEnteredWithTax is true, the store's prices, and its downstream reconciliation, are built around the net figure. A script that instead reads subtotal.gross.amount will report a subtotal that is too high by exactly the tax on that order, and every order can be off by a different amount, since each line can carry its own tax rate through a TaxClass or a tax app.
Why it happens
Order.subtotal,Order.total, and eachOrderLine.unitPriceandtotalPriceresolve to aTaxedMoneyobject, computed per line from the channel'sTaxConfigurationfields (pricesEnteredWithTax,displayGrossPrices,chargeTaxes) or from a customORDER_CALCULATE_TAXEStax app webhook, so Saleor always exposes both a gross and a net figure at once.- A script, report, or migration reads
subtotal.gross.amountas if it were the canonical or only subtotal, while the store's channel is configured withpricesEnteredWithTaxand the reconciliation target, an ERP, an accounting export, or a previous Saleor version's field, expects the net figure instead. - Because each order line can carry its own tax rate through a
TaxClassor a tax app, the aggregate discrepancy equals the sum of per-line tax amounts, not a fixed percentage, so it cannot be corrected with a flat multiplier. - This is the exact net-versus-gross subtotal confusion the Saleor team has acknowledged: North American stores commonly default to displaying a net subtotal, European stores to gross, and the confusion is worsened when code still checks the deprecated
Shop.includeTaxesInPricesorChannel.displayGrossPricesfields instead of the currentChannel.taxConfiguration.
None of this throws an error or fails a mutation. The order simply carries two correct numbers, and the wrong one made it into a spreadsheet or an API integration. See the citations at the end for the exact discussion thread and the tax documentation this is based on.
This is a read and derivation defect in the consuming code, not corrupt data inside Saleor. Saleor itself stores and exposes both net and gross correctly on every order, line, and total. So the safe action is never to mutate the Saleor order. It is to recompute what the expected subtotal should be, per the channel's taxConfiguration.pricesEnteredWithTax convention, compare it against what was recorded downstream, and flag the delta for a human to confirm which figure is contractually correct.
The fix, as a flow
The script pages through orders along with each order's channel taxConfiguration and every line's totalPrice.net and totalPrice.gross, sums the lines to get the expected net and gross subtotal, and compares that against the figure a downstream consumer recorded. When the recorded figure matches the wrong basis, gross when net was expected, or the reverse, it emits a report row with both amounts and the delta. Nothing in Saleor is ever rewritten, since there is nothing broken to repair there.
Build it step by step
Get an app token with order and channel read access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and channels. 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 with lines and the channel's tax configuration
Ask for orders(first, after) and read back each order's id, number, its channel's taxConfiguration.pricesEnteredWithTax, the order's own subtotal.net and subtotal.gross, and every line's totalPrice.net and totalPrice.gross. 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: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
channel { id slug taxConfiguration { pricesEnteredWithTax displayGrossPrices chargeTaxes } }
subtotal { gross { amount currency } net { amount currency } tax { amount } }
lines {
id
quantity
unitPrice { gross { amount } net { amount } }
totalPrice { gross { amount } net { amount } }
}
}
}
}
}"""
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: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
channel { id slug taxConfiguration { pricesEnteredWithTax displayGrossPrices chargeTaxes } }
subtotal { gross { amount currency } net { amount currency } tax { amount } }
lines {
id
quantity
unitPrice { gross { amount } net { amount } }
totalPrice { gross { amount } net { amount } }
}
}
}
}
}`;
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 the order's own net and gross subtotal, the recorded figure a downstream consumer used, and the channel's pricesEnteredWithTax flag, and returns whether the recorded figure is a real mismatch. A pure function like this is easy to read and test, which we do later. It computes the expected basis, net when pricesEnteredWithTax is true, gross otherwise, per your store's policy, and compares the recorded figure to that expected value with a small rounding epsilon.
def decide_subtotal_mismatch(order, tax_config, recorded_subtotal, epsilon=0.01):
"""Pure decision function. No I/O.
order: {"subtotalNet": float, "subtotalGross": float,
"lines": [{"totalPriceNet": float, "totalPriceGross": float}, ...]}
tax_config: {"pricesEnteredWithTax": bool}
recorded_subtotal: float, the figure a downstream consumer recorded for this order.
Returns {"isMismatch": bool, "expected": float, "recorded": float,
"delta": float, "expectedBasis": "net" | "gross"}.
"""
expected_basis = "net" if tax_config.get("pricesEnteredWithTax") else "gross"
key = "totalPriceNet" if expected_basis == "net" else "totalPriceGross"
expected = sum(line[key] for line in order["lines"])
delta = abs(expected - recorded_subtotal)
is_mismatch = delta > epsilon
return {
"isMismatch": is_mismatch,
"expected": expected,
"recorded": recorded_subtotal,
"delta": delta,
"expectedBasis": expected_basis,
}
/**
* Pure decision function. No I/O.
*
* order: { subtotalNet: number, subtotalGross: number,
* lines: Array<{ totalPriceNet: number, totalPriceGross: number }> }
* taxConfig: { pricesEnteredWithTax: boolean }
* recordedSubtotal: number, the figure a downstream consumer recorded for this order.
*
* Returns { isMismatch, expected, recorded, delta, expectedBasis }.
*/
export function decideSubtotalMismatch(order, taxConfig, recordedSubtotal, epsilon = 0.01) {
const expectedBasis = taxConfig.pricesEnteredWithTax ? "net" : "gross";
const key = expectedBasis === "net" ? "totalPriceNet" : "totalPriceGross";
const expected = order.lines.reduce((sum, line) => sum + line[key], 0);
const delta = Math.abs(expected - recordedSubtotal);
const isMismatch = delta > epsilon;
return { isMismatch, expected, recorded: recordedSubtotal, delta, expectedBasis };
}
Build the report, never a mutation
For each order, pull the plain numbers out of the GraphQL response, run decide_subtotal_mismatch, and when it comes back a mismatch, collect the order's id, number, channel slug, tax configuration, both subtotal figures, and the delta. This is the entire output. There is no mutation, because Saleor's own net and gross fields are already correct. Only a human deciding which figure the downstream ledger truly needs, followed by re-pulling that field explicitly, resolves the mismatch.
def to_plain_order(node):
lines = [
{
"totalPriceNet": line["totalPrice"]["net"]["amount"],
"totalPriceGross": line["totalPrice"]["gross"]["amount"],
}
for line in node["lines"]
]
return {
"id": node["id"],
"number": node["number"],
"channelSlug": node["channel"]["slug"],
"taxConfig": node["channel"]["taxConfiguration"],
"subtotalNet": node["subtotal"]["net"]["amount"],
"subtotalGross": node["subtotal"]["gross"]["amount"],
"lines": lines,
}
def build_report_row(order, recorded_subtotal):
decision = decide_subtotal_mismatch(order, order["taxConfig"], recorded_subtotal)
if not decision["isMismatch"]:
return None
return {
"orderId": order["id"],
"orderNumber": order["number"],
"channelSlug": order["channelSlug"],
"pricesEnteredWithTax": order["taxConfig"].get("pricesEnteredWithTax"),
"subtotalNet": order["subtotalNet"],
"subtotalGross": order["subtotalGross"],
"recordedSubtotal": recorded_subtotal,
"expectedBasis": decision["expectedBasis"],
"delta": round(decision["delta"], 2),
}
function toPlainOrder(node) {
const lines = node.lines.map((line) => ({
totalPriceNet: line.totalPrice.net.amount,
totalPriceGross: line.totalPrice.gross.amount,
}));
return {
id: node.id,
number: node.number,
channelSlug: node.channel.slug,
taxConfig: node.channel.taxConfiguration,
subtotalNet: node.subtotal.net.amount,
subtotalGross: node.subtotal.gross.amount,
lines,
};
}
function buildReportRow(order, recordedSubtotal) {
const decision = decideSubtotalMismatch(order, order.taxConfig, recordedSubtotal);
if (!decision.isMismatch) return null;
return {
orderId: order.id,
orderNumber: order.number,
channelSlug: order.channelSlug,
pricesEnteredWithTax: order.taxConfig.pricesEnteredWithTax,
subtotalNet: order.subtotalNet,
subtotalGross: order.subtotalGross,
recordedSubtotal,
expectedBasis: decision.expectedBasis,
delta: Math.round(decision.delta * 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 report row for every order where the recorded subtotal disagrees with the channel's expected basis. It never writes to Saleor. If the discrepancy stems from a custom tax app, re-run the same comparison against the ORDER_CALCULATE_TAXES webhook payload before assuming Saleor's default calculation is at fault. Only once a human confirms which figure the ledger actually needs, and only with DRY_RUN=false, should the downstream export be regenerated by re-pulling the correct field.
Always start with DRY_RUN=true and read the report before acting. This script never mutates a Saleor order, since Saleor's own record already holds correct net and gross figures. Regenerating a downstream ledger or CSV export is the only corrective action, and that should only happen after a human has confirmed which figure is contractually correct, with DRY_RUN=false set deliberately.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through orders with their channel tax configuration, reconciles each with the pure function, and always logs a report row for anything flagged. It never writes to Saleor.
"""Flag Saleor orders where a downstream consumer recorded the wrong
subtotal basis, gross instead of net, or the reverse.
Order.subtotal, Order.total, and every OrderLine.unitPrice and totalPrice
resolve to a TaxedMoney object that always carries both a gross and a net
amount together, computed per line from the channel's TaxConfiguration
(pricesEnteredWithTax, displayGrossPrices, chargeTaxes) or a custom
ORDER_CALCULATE_TAXES tax app webhook. The bug is not in Saleor's stored
data, both figures it returns are correct, it is in a script, report, or
migration that read subtotal.gross.amount as the only subtotal while the
channel's pricesEnteredWithTax convention and the downstream ledger expect
net (or the reverse). Because each line can carry its own tax rate, the
discrepancy equals the sum of per-line tax, not a fixed percentage.
Under DRY_RUN=true (the default) this script only reports flagged orders,
it never writes anything. There is nothing to repair inside Saleor itself,
so the only ever corrective action is to regenerate a downstream export
after a human confirms which figure is contractually correct. Run on a
schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("audit_subtotal_basis")
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"
SUBTOTAL_EPSILON = float(os.environ.get("SUBTOTAL_EPSILON", "0.01"))
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
channel { id slug taxConfiguration { pricesEnteredWithTax displayGrossPrices chargeTaxes } }
subtotal { gross { amount currency } net { amount currency } tax { amount } }
lines {
id
quantity
unitPrice { gross { amount } net { amount } }
totalPrice { gross { amount } net { amount } }
}
}
}
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def decide_subtotal_mismatch(order, tax_config, recorded_subtotal, epsilon=0.01):
"""Pure decision function. No I/O.
order: {"subtotalNet": float, "subtotalGross": float,
"lines": [{"totalPriceNet": float, "totalPriceGross": float}, ...]}
tax_config: {"pricesEnteredWithTax": bool}
recorded_subtotal: float, the figure a downstream consumer recorded for this order.
Returns {"isMismatch": bool, "expected": float, "recorded": float,
"delta": float, "expectedBasis": "net" | "gross"}.
"""
expected_basis = "net" if tax_config.get("pricesEnteredWithTax") else "gross"
key = "totalPriceNet" if expected_basis == "net" else "totalPriceGross"
expected = sum(line[key] for line in order["lines"])
delta = abs(expected - recorded_subtotal)
is_mismatch = delta > epsilon
return {
"isMismatch": is_mismatch,
"expected": expected,
"recorded": recorded_subtotal,
"delta": delta,
"expectedBasis": expected_basis,
}
def to_plain_order(node):
lines = [
{
"totalPriceNet": line["totalPrice"]["net"]["amount"],
"totalPriceGross": line["totalPrice"]["gross"]["amount"],
}
for line in node["lines"]
]
return {
"id": node["id"],
"number": node["number"],
"channelSlug": node["channel"]["slug"],
"taxConfig": node["channel"]["taxConfiguration"],
"subtotalNet": node["subtotal"]["net"]["amount"],
"subtotalGross": node["subtotal"]["gross"]["amount"],
"lines": lines,
}
def build_report_row(order, recorded_subtotal):
decision = decide_subtotal_mismatch(order, order["taxConfig"], recorded_subtotal, SUBTOTAL_EPSILON)
if not decision["isMismatch"]:
return None
return {
"orderId": order["id"],
"orderNumber": order["number"],
"channelSlug": order["channelSlug"],
"pricesEnteredWithTax": order["taxConfig"].get("pricesEnteredWithTax"),
"subtotalNet": order["subtotalNet"],
"subtotalGross": order["subtotalGross"],
"recordedSubtotal": recorded_subtotal,
"expectedBasis": decision["expectedBasis"],
"delta": round(decision["delta"], 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 recorded_subtotal_for(order):
"""Stand-in for however your own pipeline recorded a subtotal downstream,
for example a prior CSV export, an ERP sync log, or a cached report row.
Wire this up to your real source. Left here it mirrors gross, which is
exactly the class of bug this script is built to catch."""
return order["subtotalGross"]
def run():
flagged = 0
for node in all_orders():
order = to_plain_order(node)
recorded = recorded_subtotal_for(order)
row = build_report_row(order, recorded)
if row is None:
continue
log.warning("Subtotal basis mismatch found. %s", row)
flagged += 1
log.info("Done. %d order(s) flagged for review.%s", flagged,
" (dry run)" if DRY_RUN else "")
if __name__ == "__main__":
run()
/**
* Flag Saleor orders where a downstream consumer recorded the wrong
* subtotal basis, gross instead of net, or the reverse.
*
* Order.subtotal, Order.total, and every OrderLine.unitPrice and
* totalPrice resolve to a TaxedMoney object that always carries both a
* gross and a net amount together, computed per line from the channel's
* TaxConfiguration (pricesEnteredWithTax, displayGrossPrices, chargeTaxes)
* or a custom ORDER_CALCULATE_TAXES tax app webhook. The bug is not in
* Saleor's stored data, both figures it returns are correct, it is in a
* script, report, or migration that read subtotal.gross.amount as the
* only subtotal while the channel's pricesEnteredWithTax convention and
* the downstream ledger expect net (or the reverse). Because each line
* can carry its own tax rate, the discrepancy equals the sum of per-line
* tax, not a fixed percentage.
*
* Under DRY_RUN=true (the default) this script only reports flagged
* orders, it never writes anything. There is nothing to repair inside
* Saleor itself, so the only ever corrective action is to regenerate a
* downstream export after a human confirms which figure is contractually
* correct. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/order-subtotal-gross-instead-of-net/
*/
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 SUBTOTAL_EPSILON = Number(process.env.SUBTOTAL_EPSILON || 0.01);
/**
* Pure decision function. No I/O.
*
* order: { subtotalNet: number, subtotalGross: number,
* lines: Array<{ totalPriceNet: number, totalPriceGross: number }> }
* taxConfig: { pricesEnteredWithTax: boolean }
* recordedSubtotal: number, the figure a downstream consumer recorded for this order.
*
* Returns { isMismatch, expected, recorded, delta, expectedBasis }.
*/
export function decideSubtotalMismatch(order, taxConfig, recordedSubtotal, epsilon = 0.01) {
const expectedBasis = taxConfig.pricesEnteredWithTax ? "net" : "gross";
const key = expectedBasis === "net" ? "totalPriceNet" : "totalPriceGross";
const expected = order.lines.reduce((sum, line) => sum + line[key], 0);
const delta = Math.abs(expected - recordedSubtotal);
const isMismatch = delta > epsilon;
return { isMismatch, expected, recorded: recordedSubtotal, delta, expectedBasis };
}
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: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
number
channel { id slug taxConfiguration { pricesEnteredWithTax displayGrossPrices chargeTaxes } }
subtotal { gross { amount currency } net { amount currency } tax { amount } }
lines {
id
quantity
unitPrice { gross { amount } net { amount } }
totalPrice { gross { amount } net { amount } }
}
}
}
}
}`;
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;
}
}
function toPlainOrder(node) {
const lines = node.lines.map((line) => ({
totalPriceNet: line.totalPrice.net.amount,
totalPriceGross: line.totalPrice.gross.amount,
}));
return {
id: node.id,
number: node.number,
channelSlug: node.channel.slug,
taxConfig: node.channel.taxConfiguration,
subtotalNet: node.subtotal.net.amount,
subtotalGross: node.subtotal.gross.amount,
lines,
};
}
function buildReportRow(order, recordedSubtotal) {
const decision = decideSubtotalMismatch(order, order.taxConfig, recordedSubtotal, SUBTOTAL_EPSILON);
if (!decision.isMismatch) return null;
return {
orderId: order.id,
orderNumber: order.number,
channelSlug: order.channelSlug,
pricesEnteredWithTax: order.taxConfig.pricesEnteredWithTax,
subtotalNet: order.subtotalNet,
subtotalGross: order.subtotalGross,
recordedSubtotal,
expectedBasis: decision.expectedBasis,
delta: Math.round(decision.delta * 100) / 100,
};
}
/**
* Stand-in for however your own pipeline recorded a subtotal downstream,
* for example a prior CSV export, an ERP sync log, or a cached report row.
* Wire this up to your real source. Left here it mirrors gross, which is
* exactly the class of bug this script is built to catch.
*/
function recordedSubtotalFor(order) {
return order.subtotalGross;
}
export async function run() {
let flagged = 0;
for await (const node of allOrders()) {
const order = toPlainOrder(node);
const recorded = recordedSubtotalFor(order);
const row = buildReportRow(order, recorded);
if (row === null) continue;
console.warn("Subtotal basis mismatch found.", row);
flagged++;
}
console.log(`Done. ${flagged} order(s) flagged for review.${DRY_RUN ? " (dry run)" : ""}`);
}
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 follow the channel's own pricesEnteredWithTax convention rather than assume net or gross by default. Because decide_subtotal_mismatch is pure, taking plain numbers and a plain flag, the test needs no network and no Saleor account.
from audit_subtotal_basis import decide_subtotal_mismatch
def order(**over):
base = {
"subtotalNet": 100.0,
"subtotalGross": 122.0,
"lines": [{"totalPriceNet": 100.0, "totalPriceGross": 122.0}],
}
base.update(over)
return base
def test_net_expected_and_gross_recorded_is_a_mismatch():
result = decide_subtotal_mismatch(order(), {"pricesEnteredWithTax": True}, recorded_subtotal=122.0)
assert result["isMismatch"] is True
assert result["expectedBasis"] == "net"
assert result["expected"] == 100.0
assert round(result["delta"], 2) == 22.0
def test_net_expected_and_net_recorded_is_not_a_mismatch():
result = decide_subtotal_mismatch(order(), {"pricesEnteredWithTax": True}, recorded_subtotal=100.0)
assert result["isMismatch"] is False
def test_gross_expected_and_net_recorded_is_a_mismatch():
result = decide_subtotal_mismatch(order(), {"pricesEnteredWithTax": False}, recorded_subtotal=100.0)
assert result["isMismatch"] is True
assert result["expectedBasis"] == "gross"
assert result["expected"] == 122.0
def test_gross_expected_and_gross_recorded_is_not_a_mismatch():
result = decide_subtotal_mismatch(order(), {"pricesEnteredWithTax": False}, recorded_subtotal=122.0)
assert result["isMismatch"] is False
def test_within_epsilon_is_not_a_mismatch():
result = decide_subtotal_mismatch(order(), {"pricesEnteredWithTax": True}, recorded_subtotal=100.005, epsilon=0.01)
assert result["isMismatch"] is False
def test_multi_line_order_sums_all_lines_for_expected():
multi = order(lines=[
{"totalPriceNet": 40.0, "totalPriceGross": 48.8},
{"totalPriceNet": 60.0, "totalPriceGross": 73.2},
])
result = decide_subtotal_mismatch(multi, {"pricesEnteredWithTax": True}, recorded_subtotal=100.0)
assert result["isMismatch"] is False
assert result["expected"] == 100.0
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideSubtotalMismatch } from "./audit-subtotal-basis.js";
const order = (over = {}) => ({
subtotalNet: 100.0,
subtotalGross: 122.0,
lines: [{ totalPriceNet: 100.0, totalPriceGross: 122.0 }],
...over,
});
test("net expected and gross recorded is a mismatch", () => {
const result = decideSubtotalMismatch(order(), { pricesEnteredWithTax: true }, 122.0);
assert.equal(result.isMismatch, true);
assert.equal(result.expectedBasis, "net");
assert.equal(result.expected, 100.0);
assert.equal(Math.round(result.delta * 100) / 100, 22.0);
});
test("net expected and net recorded is not a mismatch", () => {
const result = decideSubtotalMismatch(order(), { pricesEnteredWithTax: true }, 100.0);
assert.equal(result.isMismatch, false);
});
test("gross expected and net recorded is a mismatch", () => {
const result = decideSubtotalMismatch(order(), { pricesEnteredWithTax: false }, 100.0);
assert.equal(result.isMismatch, true);
assert.equal(result.expectedBasis, "gross");
assert.equal(result.expected, 122.0);
});
test("gross expected and gross recorded is not a mismatch", () => {
const result = decideSubtotalMismatch(order(), { pricesEnteredWithTax: false }, 122.0);
assert.equal(result.isMismatch, false);
});
test("within epsilon is not a mismatch", () => {
const result = decideSubtotalMismatch(order(), { pricesEnteredWithTax: true }, 100.005, 0.01);
assert.equal(result.isMismatch, false);
});
test("multi line order sums all lines for expected", () => {
const multi = order({
lines: [
{ totalPriceNet: 40.0, totalPriceGross: 48.8 },
{ totalPriceNet: 60.0, totalPriceGross: 73.2 },
],
});
const result = decideSubtotalMismatch(multi, { pricesEnteredWithTax: true }, 100.0);
assert.equal(result.isMismatch, false);
assert.equal(result.expected, 100.0);
});
Case studies
The European store that migrated its accounting export
A store running a European channel with pricesEnteredWithTax true had its own accounting integration built years earlier by a contractor who read subtotal.gross.amount for every order. It matched fine at first, since the earlier tax setup happened to keep gross and net close together, and the drift only became visible after new tax classes were added per product category.
Finance ran the audit script in dry run, and it flagged every order where the recorded gross figure disagreed with the net figure the ERP was actually built to reconcile against. Once someone confirmed net was contractually correct, the export job was pointed at subtotal.net.amount and the monthly reconciliation stopped drifting.
The marketplace with a per-region tax app
A marketplace used a custom tax app answering the ORDER_CALCULATE_TAXES webhook to apply different rates by buyer region. A dashboard built on top of orders assumed every channel used the same pricesEnteredWithTax convention and read the same field for all of them, which was wrong for a handful of channels configured the opposite way.
The script's report grouped mismatches by channel slug, which made the pattern obvious immediately: only the channels with the flag flipped were ever flagged. The dashboard's export logic was updated to check each order's own channel configuration instead of assuming one global convention, and the false mismatches disappeared.
After this runs on a schedule, every order where a downstream consumer read the wrong subtotal basis shows up in a report with both figures and the exact delta, grouped by channel so a systemic misconfiguration is easy to spot. Saleor's own order record is never touched, since it was never wrong. Only the consuming code, or the export it feeds, needs to change, and only once a human has confirmed which figure the ledger actually needs.
FAQ
Why does my Saleor order subtotal look like the wrong number compared to my ERP?
Saleor's order.subtotal is a TaxedMoney object that always carries a gross amount and a net amount side by side, computed from the channel's taxConfiguration. If your script or export reads subtotal.gross.amount while your ERP or accounting export expects the net figure, the two will disagree by the exact tax amount on that order. Both numbers Saleor returns are correct. The mismatch is in which one the consuming code chose to read.
Is this a bug in Saleor, or in my own code?
It is almost always the consuming code. Saleor stores and exposes both the net and gross amount correctly on every order, line, and total. The confusion comes from reading Order.subtotal.gross.amount as if it were the only or canonical subtotal, or from code still checking the deprecated Shop.includeTaxesInPrices or Channel.displayGrossPrices fields instead of the channel's current taxConfiguration.pricesEnteredWithTax.
Should a script automatically rewrite the mismatched subtotal once it finds one?
No. Saleor's order record already holds correct net and gross figures together, so there is nothing corrupt to repair inside Saleor. The safe action is to flag the order with both figures and the delta for a human to review, and only after DRY_RUN is turned off and someone has confirmed which figure the downstream ledger or CSV export needs should that export be regenerated by re-pulling the correct field. Saleor's own order is never rewritten.
Related field notes
Citations
On the problem:
- GitHub Discussion: current implementation of Order.subtotal in Saleor. github.com/saleor/saleor/discussions/12395
- Saleor Docs: Taxes, TaxedMoney, and channel tax configuration. docs.saleor.io/developer/taxes
- Saleor Docs: the ORDER_CALCULATE_TAXES synchronous webhook event. docs.saleor.io/developer/extending/webhooks/synchronous-events/tax
On the solution:
- Saleor Docs: Price Calculation, how net and gross are derived per line. docs.saleor.io/developer/price-calculation
- Saleor API Reference: the Order object, including subtotal and total as TaxedMoney. docs.saleor.io/api-reference/orders/objects/order
- Saleor API Reference: the Channel object and its taxConfiguration. docs.saleor.io/api-reference/channels/objects/channel
Stuck on a tricky one?
If you have a problem in Saleor orders, taxes, channels, or migrations 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 untangle your subtotal mismatch?
If this saved you a wrong reconciliation report or a confusing migration, 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