Skip to content

Diagnostic Orders & Fulfillment

Draft order taxes reset to zero on completion

The draft order looks right. Tax shows up on the total, on every line, even on shipping. Then someone calls draftOrderComplete, the order flips to confirmed, and the tax figures that were there a second ago come back zero. Gross and net still hold their value, so nobody notices at a glance, until reconciliation or a customer invoice comes up short. Here is why Saleor drops tax on that transition and a script that catches it before it reaches accounting.

Python and Node.js Saleor GraphQL API Report only (no auto writes)
A calculator next to a laptop
Photo by Mehdi Mirzaie on Unsplash
The short answer

Saleor computes order and order-line tax fields, total.tax, undiscountedTotal.tax, and each line's unitPrice.tax and totalPrice.tax, lazily. They are only trustworthy right after a full recalculation, and any line, quantity, shipping, address, or voucher change on a draft order invalidates the previous values without forcing an eager recompute. draftOrderComplete transitions the draft into a confirmed order and triggers its own recalculation pass, through flat rates or the ORDER_CALCULATE_TAXES sync tax webhook. If that pass runs against an order that has not settled, or the configured tax app fails or returns nothing during completion, the confirmed order's tax fields come back zero or blank even though the draft displayed non-zero tax moments before. This is a known, reported gap, see saleor/saleor#8461 and discussion #8460. Draft order tax values are never guaranteed to survive completion untouched, they are recomputed. Run a small Python or Node.js script that snapshots tax before and after completion and flags the orders where it silently dropped. Full code, tests, and a report-only repair guard are below.

The problem in plain words

A draft order in Saleor is not a finished financial record. It is a working document that staff can keep editing, adding lines, changing quantities, swapping the shipping method, applying a voucher, right up until someone is ready to check out. Every one of those edits can change what tax is owed, so Saleor does not eagerly recompute tax on every keystroke. Instead the tax fields you read back, total.tax, undiscountedTotal.tax, and each line's unitPrice.tax and totalPrice.tax, are only reliable immediately after a full recalculation. Touch the draft again and those numbers can go stale without any error telling you so.

draftOrderComplete is the one-way move from draft to confirmed order. Stock gets allocated, the order becomes a real financial record, and Saleor runs its own recalculation as part of that transition, either the built in flat rate engine or, if the channel is configured for it, the ORDER_CALCULATE_TAXES synchronous webhook out to a tax app like Avalara or TaxJar. If that recalculation runs against an order whose state has not fully settled since the last edit, or the tax app times out, errors, or simply returns nothing useful during that narrow window, the confirmed order comes back with tax at zero. Gross and net, the numbers a cashier or a customer actually looks at, usually still carry their correct value, so the order looks fine on the surface while the tax component silently vanished underneath.

Draft order total.tax > 0 draftOrderComplete recalculates tax flat rate or ORDER_CALCULATE_TAXES stale state, or tax app fails Confirmed order total.tax = 0 gross and net unchanged Books short by the tax
The draft carried real tax. Completion recomputes it from scratch, and if that pass has a bad run, the confirmed order keeps its gross and net but loses the tax underneath.

Why it happens

None of this throws a visible error. The order completes, stock allocates, gross and net look normal, and only the tax line quietly reads zero. The gap surfaces later, when a finance report does not add up or a customer asks why their invoice shows no tax on a taxable sale.

The key insight

Tax on a Saleor order is always server-computed, never something to hand-write back in. Once draftOrderComplete runs, the order is a confirmed financial record with stock already allocated, so the right move is never to patch total.tax directly. The right move is to snapshot tax before completion, compare it against a fresh, non-cached read after completion, and treat any drop from positive to zero as a signal to investigate the channel's tax configuration, not a number to overwrite.

The fix, as a flow

The script runs around the moment of completion, or afterward against a list of recently completed orders. For each candidate it reads the draft's tax figures, runs or observes draftOrderComplete, then re-queries the resulting order fresh. If a tax figure that was positive before completion reads zero or null afterward while gross stayed non-zero, it flags the order rather than guessing at a fix. Only with a human authorization does it attempt a legitimate recalculation by touching the order again, and if that still comes back zero, it reports the order for manual review in the dashboard.

Snapshot draft tax before completion Run draftOrderComplete re-query order fresh Compare before and after tax figures Tax dropped to zero, gross > 0? yes no, tax intact Check config, retry recompute, or report never hand-write tax fields
The script never writes a tax number directly. It flags the regression, checks the channel's tax configuration, tries a legitimate recompute, and reports what is left for a human to review.

Build it step by step

1

Get an app token with order read and write access

Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read and manage orders and to read 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.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true"   # start safe, this script never writes tax fields directly
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true"   // start safe, this script never writes tax fields directly
2

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.

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

Snapshot the draft, then complete it

Before completion, read the draft's total.tax, undiscountedTotal.tax, every line's totalPrice.tax, and shippingPrice.tax, and record the amounts. Then call draftOrderComplete and immediately re-query the resulting order with the same field set, fresh from the API rather than from any local cache, so the comparison is honest.

step3.py
ORDER_TAX_QUERY = """
query($id: ID!) {
  order(id: $id) {
    id
    status
    channel { slug }
    total { tax { amount currency } gross { amount } net { amount } }
    undiscountedTotal { tax { amount } }
    shippingPrice { tax { amount } }
    lines { id totalPrice { tax { amount } } }
  }
}"""

COMPLETE_MUTATION = """
mutation($id: ID!) {
  draftOrderComplete(id: $id) {
    order { id status }
    errors { field code message }
  }
}"""

def snapshot(order_id):
    order = gql(ORDER_TAX_QUERY, {"id": order_id})["order"]
    return {
        "totalTax": order["total"]["tax"]["amount"],
        "totalGross": order["total"]["gross"]["amount"],
        "shippingTax": order["shippingPrice"]["tax"]["amount"],
        "lineTaxes": [line["totalPrice"]["tax"]["amount"] for line in order["lines"]],
        "channel": order["channel"]["slug"],
    }

def complete_draft_order(order_id):
    result = gql(COMPLETE_MUTATION, {"id": order_id})["draftOrderComplete"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["order"]["id"]
step3.js
const ORDER_TAX_QUERY = `
query($id: ID!) {
  order(id: $id) {
    id
    status
    channel { slug }
    total { tax { amount currency } gross { amount } net { amount } }
    undiscountedTotal { tax { amount } }
    shippingPrice { tax { amount } }
    lines { id totalPrice { tax { amount } } }
  }
}`;

const COMPLETE_MUTATION = `
mutation($id: ID!) {
  draftOrderComplete(id: $id) {
    order { id status }
    errors { field code message }
  }
}`;

async function snapshot(orderId) {
  const order = (await gql(ORDER_TAX_QUERY, { id: orderId })).order;
  return {
    totalTax: order.total.tax.amount,
    totalGross: order.total.gross.amount,
    shippingTax: order.shippingPrice.tax.amount,
    lineTaxes: order.lines.map((line) => line.totalPrice.tax.amount),
    channel: order.channel.slug,
  };
}

async function completeDraftOrder(orderId) {
  const result = (await gql(COMPLETE_MUTATION, { id: orderId })).draftOrderComplete;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.order.id;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the before and after snapshots and returns a boolean. A pure function like this is easy to read and easy to test, which we do later. It flags a regression only when the order still has monetary value after completion, so a legitimately empty or cancelled order does not get flagged, and a tax figure that was positive before reads zero after, on the total, on any line, or on shipping.

decide.py
def has_tax_regression(before, after):
    if after["totalGross"] <= 0:
        return False

    if before["totalTax"] > 0 and after["totalTax"] == 0:
        return True

    for before_tax, after_tax in zip(before["lineTaxes"], after["lineTaxes"]):
        if before_tax > 0 and after_tax == 0:
            return True

    if before["shippingTax"] > 0 and after["shippingTax"] == 0:
        return True

    return False
decide.js
export function hasTaxRegression(before, after) {
  if (after.totalGross <= 0) return false;

  if (before.totalTax > 0 && after.totalTax === 0) return true;

  for (let i = 0; i < before.lineTaxes.length; i++) {
    if (before.lineTaxes[i] > 0 && after.lineTaxes[i] === 0) return true;
  }

  if (before.shippingTax > 0 && after.shippingTax === 0) return true;

  return false;
}
5

Confirm the channel's tax configuration before touching anything

Before attempting any repair, check that the affected order's channel has a working tax setup. Read taxConfiguration for the channel to see the calculation strategy and whether taxes are charged at all. If the channel relies on a sync tax webhook, this is also where you would confirm the subscriber app is active and responding, outside of Saleor's own API.

check_config.py
CHANNEL_TAX_CONFIG_QUERY = """
query($slug: String!) {
  channel(slug: $slug) {
    slug
    taxConfiguration { taxCalculationStrategy chargeTaxes }
  }
}"""

def channel_tax_config(channel_slug):
    channel = gql(CHANNEL_TAX_CONFIG_QUERY, {"slug": channel_slug})["channel"]
    return channel["taxConfiguration"]
check-config.js
const CHANNEL_TAX_CONFIG_QUERY = `
query($slug: String!) {
  channel(slug: $slug) {
    slug
    taxConfiguration { taxCalculationStrategy chargeTaxes }
  }
}`;

async function channelTaxConfig(channelSlug) {
  const channel = (await gql(CHANNEL_TAX_CONFIG_QUERY, { slug: channelSlug })).channel;
  return channel.taxConfiguration;
}
6

Attempt a legitimate recompute, then report what is left

Under DRY_RUN=true, the default, the script only reports flagged orders, it never writes anything. When DRY_RUN=false and a fix is authorized, touching the order through a normal update invalidates the stale tax and forces Saleor to recompute on the next read, so the script does a no-op line update and re-fetches total.tax. If it is still zero after that, it stops trying and emits a report row, order id, channel, expected tax range, actual zero, for a staff member to resolve by hand in the dashboard. It never writes a tax amount directly onto the order.

Run it safe

Never hand-write total.tax or any line's tax field. Those are always server-computed. The only legitimate way to fix a dropped tax value is to make Saleor recompute it, through a normal order edit or a working tax app, and confirm the result before moving on. If it still comes back zero, that is a case for a person, not a script.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, snapshots draft tax before completion, re-checks after completion, flags any order whose tax silently dropped, confirms the channel's tax configuration, and attempts a safe recompute only when authorized, otherwise it reports the order for manual review.

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.
detect_tax_regression.py
"""Detect Saleor draft orders whose tax fields silently reset to zero
after draftOrderComplete (saleor/saleor#8461, discussion #8460).

Order and line tax fields are computed lazily and only trustworthy right
after a full recalculation. draftOrderComplete triggers its own pass, and
if that pass runs against unsettled state or a failing tax app, the
confirmed order can come back with tax at zero while gross stays intact.

This script never hand-writes tax fields. Under DRY_RUN=true (the
default) it only reports the regression. When DRY_RUN=false and a fix is
authorized, it checks the channel's tax configuration, attempts a
legitimate recompute through a no-op order touch, and re-checks. If tax
is still zero, it emits a report row for staff review instead of
mutating financial fields directly. 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("detect_tax_regression")

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ORDER_TAX_QUERY = """
query($id: ID!) {
  order(id: $id) {
    id
    status
    channel { slug }
    total { tax { amount currency } gross { amount } net { amount } }
    undiscountedTotal { tax { amount } }
    shippingPrice { tax { amount } }
    lines { id totalPrice { tax { amount } } }
  }
}"""

COMPLETE_MUTATION = """
mutation($id: ID!) {
  draftOrderComplete(id: $id) {
    order { id status }
    errors { field code message }
  }
}"""

CHANNEL_TAX_CONFIG_QUERY = """
query($slug: String!) {
  channel(slug: $slug) {
    slug
    taxConfiguration { taxCalculationStrategy chargeTaxes }
  }
}"""

ORDER_UPDATE_TOUCH = """
mutation($id: ID!) {
  orderUpdate(id: $id, input: {}) {
    order { id }
    errors { field code message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def has_tax_regression(before, after):
    if after["totalGross"] <= 0:
        return False

    if before["totalTax"] > 0 and after["totalTax"] == 0:
        return True

    for before_tax, after_tax in zip(before["lineTaxes"], after["lineTaxes"]):
        if before_tax > 0 and after_tax == 0:
            return True

    if before["shippingTax"] > 0 and after["shippingTax"] == 0:
        return True

    return False


def snapshot(order_id):
    order = gql(ORDER_TAX_QUERY, {"id": order_id})["order"]
    return {
        "totalTax": order["total"]["tax"]["amount"],
        "totalGross": order["total"]["gross"]["amount"],
        "shippingTax": order["shippingPrice"]["tax"]["amount"],
        "lineTaxes": [line["totalPrice"]["tax"]["amount"] for line in order["lines"]],
        "channel": order["channel"]["slug"],
    }


def complete_draft_order(order_id):
    result = gql(COMPLETE_MUTATION, {"id": order_id})["draftOrderComplete"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["order"]["id"]


def channel_tax_config(channel_slug):
    channel = gql(CHANNEL_TAX_CONFIG_QUERY, {"slug": channel_slug})["channel"]
    return channel["taxConfiguration"]


def touch_order(order_id):
    result = gql(ORDER_UPDATE_TOUCH, {"id": order_id})["orderUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])


def run(draft_order_ids):
    reports = []
    for order_id in draft_order_ids:
        before = snapshot(order_id)
        completed_id = complete_draft_order(order_id)
        after = snapshot(completed_id)

        if not has_tax_regression(before, after):
            log.info("Order %s tax intact after completion.", completed_id)
            continue

        log.warning("Order %s tax dropped on completion. before=%s after=%s",
                    completed_id, before["totalTax"], after["totalTax"])

        if DRY_RUN:
            reports.append({"orderId": completed_id, "channel": after["channel"],
                             "expectedTax": before["totalTax"], "actualTax": after["totalTax"]})
            continue

        config = channel_tax_config(after["channel"])
        log.info("Channel %s tax config: %s", after["channel"], config)

        touch_order(completed_id)
        recomputed = snapshot(completed_id)
        if recomputed["totalTax"] > 0:
            log.info("Order %s recovered tax after recompute: %s", completed_id, recomputed["totalTax"])
            continue

        reports.append({"orderId": completed_id, "channel": after["channel"],
                         "expectedTax": before["totalTax"], "actualTax": recomputed["totalTax"]})

    log.info("Done. %d order(s) flagged for review.", len(reports))
    return reports


if __name__ == "__main__":
    run([])
detect-tax-regression.js
/**
 * Detect Saleor draft orders whose tax fields silently reset to zero
 * after draftOrderComplete (saleor/saleor#8461, discussion #8460).
 *
 * Order and line tax fields are computed lazily and only trustworthy
 * right after a full recalculation. draftOrderComplete triggers its own
 * pass, and if that pass runs against unsettled state or a failing tax
 * app, the confirmed order can come back with tax at zero while gross
 * stays intact.
 *
 * This script never hand-writes tax fields. Under DRY_RUN=true (the
 * default) it only reports the regression. When DRY_RUN=false and a fix
 * is authorized, it checks the channel's tax configuration, attempts a
 * legitimate recompute through a no-op order touch, and re-checks. If
 * tax is still zero, it emits a report row for staff review instead of
 * mutating financial fields directly. Run on demand or on a schedule.
 *
 * Guide: https://www.allanninal.dev/saleor/draft-order-taxes-reset-on-completion/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function hasTaxRegression(before, after) {
  if (after.totalGross <= 0) return false;

  if (before.totalTax > 0 && after.totalTax === 0) return true;

  for (let i = 0; i < before.lineTaxes.length; i++) {
    if (before.lineTaxes[i] > 0 && after.lineTaxes[i] === 0) return true;
  }

  if (before.shippingTax > 0 && after.shippingTax === 0) return true;

  return false;
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const ORDER_TAX_QUERY = `
query($id: ID!) {
  order(id: $id) {
    id
    status
    channel { slug }
    total { tax { amount currency } gross { amount } net { amount } }
    undiscountedTotal { tax { amount } }
    shippingPrice { tax { amount } }
    lines { id totalPrice { tax { amount } } }
  }
}`;

const COMPLETE_MUTATION = `
mutation($id: ID!) {
  draftOrderComplete(id: $id) {
    order { id status }
    errors { field code message }
  }
}`;

const CHANNEL_TAX_CONFIG_QUERY = `
query($slug: String!) {
  channel(slug: $slug) {
    slug
    taxConfiguration { taxCalculationStrategy chargeTaxes }
  }
}`;

const ORDER_UPDATE_TOUCH = `
mutation($id: ID!) {
  orderUpdate(id: $id, input: {}) {
    order { id }
    errors { field code message }
  }
}`;

async function snapshot(orderId) {
  const order = (await gql(ORDER_TAX_QUERY, { id: orderId })).order;
  return {
    totalTax: order.total.tax.amount,
    totalGross: order.total.gross.amount,
    shippingTax: order.shippingPrice.tax.amount,
    lineTaxes: order.lines.map((line) => line.totalPrice.tax.amount),
    channel: order.channel.slug,
  };
}

async function completeDraftOrder(orderId) {
  const result = (await gql(COMPLETE_MUTATION, { id: orderId })).draftOrderComplete;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.order.id;
}

async function channelTaxConfig(channelSlug) {
  const channel = (await gql(CHANNEL_TAX_CONFIG_QUERY, { slug: channelSlug })).channel;
  return channel.taxConfiguration;
}

async function touchOrder(orderId) {
  const result = (await gql(ORDER_UPDATE_TOUCH, { id: orderId })).orderUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
}

export async function run(draftOrderIds) {
  const reports = [];
  for (const orderId of draftOrderIds) {
    const before = await snapshot(orderId);
    const completedId = await completeDraftOrder(orderId);
    const after = await snapshot(completedId);

    if (!hasTaxRegression(before, after)) {
      console.log(`Order ${completedId} tax intact after completion.`);
      continue;
    }

    console.warn(`Order ${completedId} tax dropped on completion. before=${before.totalTax} after=${after.totalTax}`);

    if (DRY_RUN) {
      reports.push({ orderId: completedId, channel: after.channel, expectedTax: before.totalTax, actualTax: after.totalTax });
      continue;
    }

    const config = await channelTaxConfig(after.channel);
    console.log(`Channel ${after.channel} tax config:`, config);

    await touchOrder(completedId);
    const recomputed = await snapshot(completedId);
    if (recomputed.totalTax > 0) {
      console.log(`Order ${completedId} recovered tax after recompute: ${recomputed.totalTax}`);
      continue;
    }

    reports.push({ orderId: completedId, channel: after.channel, expectedTax: before.totalTax, actualTax: recomputed.totalTax });
  }

  console.log(`Done. ${reports.length} order(s) flagged for review.`);
  return reports;
}

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 completed orders get flagged for review. Because has_tax_regression is pure, the test needs no network and no Saleor account. It just feeds in plain before and after snapshots and checks the answer.

test_draft_tax_regression.py
from detect_tax_regression import has_tax_regression


def snap(**over):
    base = {"totalTax": 5.0, "lineTaxes": [3.0, 2.0], "shippingTax": 1.0, "totalGross": 60.0}
    base.update(over)
    return base


def test_no_regression_when_tax_unchanged():
    assert has_tax_regression(snap(), snap()) is False


def test_regression_when_total_tax_drops_to_zero():
    after = snap(totalTax=0.0)
    assert has_tax_regression(snap(), after) is True


def test_regression_when_a_line_tax_drops_to_zero():
    after = snap(lineTaxes=[3.0, 0.0])
    assert has_tax_regression(snap(), after) is True


def test_regression_when_shipping_tax_drops_to_zero():
    after = snap(shippingTax=0.0)
    assert has_tax_regression(snap(), after) is True


def test_no_flag_when_order_has_no_gross_value():
    before = snap()
    after = snap(totalTax=0.0, totalGross=0.0)
    assert has_tax_regression(before, after) is False


def test_no_flag_when_tax_was_already_zero_before():
    before = snap(totalTax=0.0, lineTaxes=[0.0, 0.0], shippingTax=0.0)
    after = snap(totalTax=0.0, lineTaxes=[0.0, 0.0], shippingTax=0.0)
    assert has_tax_regression(before, after) is False
tax-regression.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { hasTaxRegression } from "./detect-tax-regression.js";

const snap = (over = {}) => ({ totalTax: 5.0, lineTaxes: [3.0, 2.0], shippingTax: 1.0, totalGross: 60.0, ...over });

test("no regression when tax unchanged", () => {
  assert.equal(hasTaxRegression(snap(), snap()), false);
});

test("regression when total tax drops to zero", () => {
  assert.equal(hasTaxRegression(snap(), snap({ totalTax: 0.0 })), true);
});

test("regression when a line tax drops to zero", () => {
  assert.equal(hasTaxRegression(snap(), snap({ lineTaxes: [3.0, 0.0] })), true);
});

test("regression when shipping tax drops to zero", () => {
  assert.equal(hasTaxRegression(snap(), snap({ shippingTax: 0.0 })), true);
});

test("no flag when order has no gross value", () => {
  const before = snap();
  const after = snap({ totalTax: 0.0, totalGross: 0.0 });
  assert.equal(hasTaxRegression(before, after), false);
});

test("no flag when tax was already zero before", () => {
  const before = snap({ totalTax: 0.0, lineTaxes: [0.0, 0.0], shippingTax: 0.0 });
  const after = snap({ totalTax: 0.0, lineTaxes: [0.0, 0.0], shippingTax: 0.0 });
  assert.equal(hasTaxRegression(before, after), false);
});

Case studies

Sync tax webhook

A tax app timeout during a batch of completions

A furniture retailer used a third party tax app wired through ORDER_CALCULATE_TAXES to handle multi-state rates. During a busy morning, staff completed a batch of draft orders back to back, and the tax app's response time crept past its usual window on a handful of them. Those orders completed fine, gross and net looked normal, but total.tax came back zero on exactly the ones where the webhook was slow.

Running the detector against that morning's completed orders caught six with a tax regression, all pointing at the same narrow timing window. The team flagged them for manual review instead of finding out from a state tax filing that some invoices had shipped with no tax collected.

Stale draft state

A shipping method swap right before completion

A wholesale team edits draft orders heavily right up until the buyer confirms, sometimes swapping the shipping method minutes before calling draftOrderComplete. On one order, the shipping change did not settle before completion fired, and the confirmed order's shippingPrice.tax and one line's tax came back zero even though the draft had shown tax on both a minute earlier.

The before and after snapshot caught it immediately: totalGross was still correct, but two tax fields had gone from positive to zero. Staff re-touched the order to force a recompute, confirmed the tax came back, and closed it out the same day instead of it surfacing in a monthly reconciliation.

What good looks like

After this runs against completions, a dropped tax value gets caught within minutes instead of surfacing in a reconciliation report weeks later or, worse, on a customer's tax authority audit. The team gets the exact order, channel, expected tax, and actual tax to work from, and the correction stays a legitimate Saleor recompute or a human decision in the dashboard, never a script overwriting a financial field.

FAQ

Why does a Saleor draft order lose its tax after draftOrderComplete?

Saleor does not carry tax fields across the draft to order transition. draftOrderComplete triggers its own recalculation pass, either flat rates or the ORDER_CALCULATE_TAXES sync webhook. If that pass runs against an order that has not settled since the last edit, or the configured tax app fails or returns nothing, the confirmed order comes back with total.tax and line tax fields at zero even though the draft showed non-zero tax moments earlier.

Can I just copy the draft order tax values onto the completed order?

No. Tax fields on a Saleor order are always server-computed, never something a script should hand-write. Once draftOrderComplete runs, stock is already allocated and the order is a confirmed financial record, so the safe response is to detect the drop, confirm the channel tax configuration, and either trigger a legitimate recalculation through a normal order update or flag the order for a staff member to review in the dashboard.

How do I detect that a completed order actually lost its tax?

Snapshot the draft order's total.tax, line tax, and shipping tax amounts before calling draftOrderComplete, then re-query the resulting order fresh, not from a cache. If gross and net are still non-zero but a tax figure that was positive before completion reads zero or null afterward, that delta signals a dropped calculation rather than a legitimately tax-free order, and the order should be flagged.

Related field notes

Citations

On the problem:

  1. Creating an order programmatically, TAX reset. github.com/saleor/saleor/issues/8461
  2. Creating an order programmatically, TAX reset (discussion). github.com/saleor/saleor/discussions/8460
  3. draftOrderComplete removes voucher. github.com/saleor/saleor/issues/7541

On the solution:

  1. Saleor Commerce Documentation: Price Calculation. docs.saleor.io/developer/price-calculation
  2. Saleor Commerce Documentation: Tax events (synchronous webhooks). docs.saleor.io/developer/extending/webhooks/synchronous-events/tax
  3. Saleor Commerce Documentation: draftOrderComplete Mutation. docs.saleor.io/docs/3.x/api-reference/orders/mutations/draft-order-complete

Stuck on a tricky one?

If you have a problem in Saleor checkout, orders, channels, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a tax regression for you?

If this saved a completed order from shipping with silently dropped tax, or gave your finance team the report they needed, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Saleor field notes