Reconciler WooCommerce core: tax, totals, and analytics
Order tax off by a cent (frontend vs API)
A buyer looks at the tax line during checkout, and it says one number. A day later, someone pulls the order through the REST API for accounting, and the tax total is a cent or two different. Nobody changed anything. Both numbers were rounded correctly, just at a different point in the math. Here is why that happens and a small script that finds every order where the drift is real and fixes it, or flags it when the gap is too big to guess at.
The frontend rounds tax per line item as the cart updates live, and the order that gets saved can round the same numbers at a slightly different point, so total_tax can end up a cent or two away from what the buyer saw. Run a small Python or Node.js reconciler on a schedule that reads recent orders from the WooCommerce REST API, recomputes the tax by re-adding each line item's own tax in whole cents, and corrects total_tax when the drift is small, or flags the order for a human when it is not. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce calculates tax more than once for the same order. It happens live in the cart and at checkout so the buyer can see an accurate running total while they shop. It happens again, separately, when the order is placed and saved to the database. Each of those calculations rounds money to two decimal places, because you cannot charge someone a fraction of a cent.
The trouble is that rounding a set of numbers per line item and then adding them up does not always give you the same result as adding the raw numbers up first and rounding once at the end. Both approaches are correct math. They just do not always agree, and the gap they leave behind is almost always exactly one cent, sometimes two or three on a large multi-item order.
Why it happens
None of this is a bug in the sense of broken code. It is what happens when floating point money math meets a rounding rule that has to be applied somewhere. A few common reasons the drift shows up:
- The store rounds tax per line item at checkout, then the WooCommerce settings under Tax, "Rounding," round the order total a different way, so the sum of the rounded lines does not equal the once-rounded total.
- A tax rate changed between the moment the buyer opened the cart and the moment the order was placed, so the live preview and the saved order used two different rates for a split second.
- A coupon that applies "before tax" changes the taxable base after the checkout page already displayed its own tax estimate, so the two calculations start from slightly different numbers.
- Multiple tax rates on one line item, such as a state rate and a local rate, are each rounded on their own before being added, which compounds a fraction of a cent per rate.
The WooCommerce core tracker has open reports of exactly this, where order totals and the cart total differ by rounding, especially on stores with compound tax rates or "round at subtotal level" turned on for some tax classes and off for others. See the citations at the end for the exact threads.
An order's own line items are the ground truth for its tax, because that is what was actually charged per item. If you re-add the tax on every line item, shipping line, and fee line, in whole cents, and the result does not match the order's stored total_tax, the order's summary field drifted, not the line items. Fix the summary field to match the parts it is supposed to add up to.
The fix, as a flow
We do not touch checkout or the tax settings. We add a job that runs on a schedule, reads recent settled orders from the WooCommerce REST API, and for each one adds up the tax stored on every line item, shipping line, and fee line in integer cents. If that recomputed number is a cent or two away from the order's stored total_tax, we correct the stored field to match. If the gap is larger than a small threshold, something else is wrong, so we leave it alone and flag it for a person instead of guessing.
Build it step by step
Get access to the store
You only need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create it under WooCommerce, Settings, Advanced, REST API. Stripe is not involved in this fix, since we are comparing two numbers that both live inside WooCommerce. Keep every value in environment variables, never in the file.
pip install requests
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export MAX_DRIFT_CENTS="3" # bigger drift gets flagged, not auto fixed
export DRY_RUN="true" # start safe, change to false to write
npm install
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export MAX_DRIFT_CENTS="3" // bigger drift gets flagged, not auto fixed
export DRY_RUN="true" // start safe, change to false to write
Turn money strings into whole cents
WooCommerce returns money as decimal strings like "12.34". Doing math on floating point dollars is exactly how small rounding bugs like this one happen in the first place, so every amount gets turned into an integer number of cents right away, rounded half away from zero the same way most tax engines round.
def to_minor(amount):
"""Turn a WooCommerce money string like "12.345" into integer cents,
rounding half away from zero the same way most tax engines do.
"""
cents = float(amount) * 100
if cents >= 0:
return int(cents + 0.5)
return -int(-cents + 0.5)
/** Turn a WooCommerce money string like "12.345" into integer cents, rounding
* half away from zero the same way most tax engines do. */
export function toMinor(amount) {
const cents = parseFloat(amount) * 100;
return cents >= 0 ? Math.floor(cents + 0.5) : -Math.floor(-cents + 0.5);
}
Re-add the tax from the order's own lines
Every line item, shipping line, and fee line in a WooCommerce order carries its own taxes.total, a map of tax rate ID to the amount charged for that rate on that line. Sum every one of those, across every line on the order, and you get the tax the order actually charged, built back up from its own parts.
def line_item_tax_minor(item):
taxes = (item.get("taxes") or {}).get("total") or {}
total = 0
for _rate_id, amount in taxes.items():
if amount not in (None, ""):
total += to_minor(amount)
return total
def expected_tax_minor(order):
"""Re-add every line item's own tax, the same rounded-per-line approach
the cart and checkout page use while shopping.
"""
total = 0
for item in order.get("line_items", []):
total += line_item_tax_minor(item)
for item in order.get("shipping_lines", []):
total += line_item_tax_minor(item)
for item in order.get("fee_lines", []):
total += line_item_tax_minor(item)
return total
export function lineItemTaxMinor(item) {
const taxes = (item.taxes && item.taxes.total) || {};
let total = 0;
for (const amount of Object.values(taxes)) {
if (amount !== null && amount !== undefined && amount !== "") total += toMinor(amount);
}
return total;
}
/** Re-add every line item's own tax, the same rounded-per-line approach
* the cart and checkout page use while shopping. */
export function expectedTaxMinor(order) {
let total = 0;
for (const item of order.line_items || []) total += lineItemTaxMinor(item);
for (const item of order.shipping_lines || []) total += lineItemTaxMinor(item);
for (const item of order.fee_lines || []) total += lineItemTaxMinor(item);
return total;
}
Decide, with one pure function
Keep the decision in its own function that takes an order and returns an action. It is easy to read and easy to test, which we do later. Orders that are not yet settled are skipped, a match is left alone, a small drift within the threshold gets fixed, and a drift larger than the threshold is flagged for review instead of guessed at.
SETTLED_STATUSES = {"processing", "completed", "on-hold"}
def stored_tax_minor(order):
return to_minor(order.get("total_tax", "0"))
def decide(order, max_drift_cents):
if order["status"] not in SETTLED_STATUSES:
return ("skip", "order not settled yet")
expected = expected_tax_minor(order)
stored = stored_tax_minor(order)
drift = stored - expected
if drift == 0:
return ("ok", "tax matches the line items")
if abs(drift) > max_drift_cents:
return ("review", f"tax off by {drift} cents, too large to auto fix")
return ("fix", f"tax off by {drift} cents, adjusting total_tax to {expected}")
const SETTLED_STATUSES = new Set(["processing", "completed", "on-hold"]);
export function storedTaxMinor(order) {
return toMinor(order.total_tax || "0");
}
export function decide(order, maxDriftCents) {
if (!SETTLED_STATUSES.has(order.status)) return ["skip", "order not settled yet"];
const expected = expectedTaxMinor(order);
const stored = storedTaxMinor(order);
const drift = stored - expected;
if (drift === 0) return ["ok", "tax matches the line items"];
if (Math.abs(drift) > maxDriftCents) {
return ["review", `tax off by ${drift} cents, too large to auto fix`];
}
return ["fix", `tax off by ${drift} cents, adjusting total_tax to ${expected}`];
}
Correct the order summary field, and only that field
When the action is fix, write the recomputed value back to total_tax through the REST API. We never touch the line items themselves, since those are the numbers that were actually charged. We only correct the summary field that is supposed to equal their sum. Then add an order note so a shop manager can see why the number changed.
def minor_to_amount(minor):
sign = "-" if minor < 0 else ""
minor = abs(minor)
return f"{sign}{minor // 100}.{minor % 100:02d}"
def apply_fix(order, expected_minor):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"total_tax": minor_to_amount(expected_minor)},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": "Tax reconciler: stored total_tax did not match the sum of the "
"line item taxes. Adjusted total_tax to match the line items."},
auth=AUTH, timeout=30,
).raise_for_status()
export function minorToAmount(minor) {
const sign = minor < 0 ? "-" : "";
minor = Math.abs(minor);
const whole = Math.floor(minor / 100);
const frac = String(minor % 100).padStart(2, "0");
return `${sign}${whole}.${frac}`;
}
async function applyFix(order, expectedMinor) {
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ total_tax: minorToAmount(expectedMinor) }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Tax reconciler: stored total_tax did not match the sum of the " +
"line item taxes. Adjusted total_tax to match the line items.",
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports what it would fix and what it would flag. Read the output, trust it, then switch it off to let it write. A daily or hourly cron schedule is plenty, since this is bookkeeping hygiene, not a live payment problem.
Always start with DRY_RUN=true. This script edits a stored total on real orders, so you want to see its exact plan before it acts. Keep MAX_DRIFT_CENTS small, a few cents at most, so anything unusual gets a human's attention instead of an automatic edit.
The full code
Here is the complete reconciler in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, only ever writes the order-level total_tax field, and is safe to run again and again because it never touches an order whose tax already matches.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find WooCommerce orders where the stored tax total does not match the tax you
get from re-adding the line item taxes, the same math the frontend cart used.
The checkout page rounds tax per line item as the buyer shops. The order that
gets saved can end up with a total_tax that was rounded a different way, so the
two numbers can disagree by a cent or two. This walks recent orders, recomputes
the expected tax from the saved line items in integer cents, and flags or fixes
any order whose stored total_tax drifts from that recomputed value. Safe by
default. Run on a schedule.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_order_tax")
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
MAX_DRIFT_CENTS = int(os.environ.get("MAX_DRIFT_CENTS", "3"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Orders in these statuses are still moving. Only reconcile settled money.
SETTLED_STATUSES = {"processing", "completed", "on-hold"}
def to_minor(amount):
"""Turn a WooCommerce money string like "12.345" into integer cents,
rounding half away from zero the same way most tax engines do.
"""
cents = float(amount) * 100
if cents >= 0:
return int(cents + 0.5)
return -int(-cents + 0.5)
def line_item_tax_minor(item):
"""Sum the per-rate tax entries on one line item, in cents.
WooCommerce stores this per item as taxes.total, a dict of rate_id -> amount.
"""
taxes = (item.get("taxes") or {}).get("total") or {}
total = 0
for _rate_id, amount in taxes.items():
if amount not in (None, ""):
total += to_minor(amount)
return total
def expected_tax_minor(order):
"""Recompute the order tax by re-adding every line item's own tax, the same
rounded-per-line approach the cart and checkout page use while shopping.
Covers line_items, shipping_lines, and fee_lines, since all three can carry tax.
"""
total = 0
for item in order.get("line_items", []):
total += line_item_tax_minor(item)
for item in order.get("shipping_lines", []):
total += line_item_tax_minor(item)
for item in order.get("fee_lines", []):
total += line_item_tax_minor(item)
return total
def stored_tax_minor(order):
return to_minor(order.get("total_tax", "0"))
def decide(order, max_drift_cents=MAX_DRIFT_CENTS):
"""Pure decision: compare the stored tax total against the tax recomputed
from the order's own line items. No network calls, no Stripe involved,
this is purely a WooCommerce order math question.
"""
if order["status"] not in SETTLED_STATUSES:
return ("skip", "order not settled yet")
expected = expected_tax_minor(order)
stored = stored_tax_minor(order)
drift = stored - expected
if drift == 0:
return ("ok", "tax matches the line items")
if abs(drift) > max_drift_cents:
return ("review", f"tax off by {drift} cents, too large to auto fix")
return ("fix", f"tax off by {drift} cents, adjusting total_tax to {expected}")
def minor_to_amount(minor):
sign = "-" if minor < 0 else ""
minor = abs(minor)
return f"{sign}{minor // 100}.{minor % 100:02d}"
def get_orders(page, after):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed,on-hold", "after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
return r.json()
def recent_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
batch = get_orders(page, after)
if not batch:
return
for order in batch:
yield order
page += 1
def apply_fix(order, expected_minor):
"""Set total_tax to the recomputed value and leave a note explaining why.
We only touch the order-level total_tax field, never the line items
themselves, so refunds and reports that read line item tax are unaffected.
"""
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"total_tax": minor_to_amount(expected_minor)},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": "Tax reconciler: stored total_tax did not match the sum of the "
"line item taxes. Adjusted total_tax to match the line items."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
flagged = 0
for order in recent_orders():
action, reason = decide(order)
if action == "skip" or action == "ok":
continue
if action == "review":
log.warning("Order %s: %s. Needs a human look.", order["id"], reason)
flagged += 1
continue
expected = expected_tax_minor(order)
log.info("Order %s: %s. %s", order["id"], reason, "would fix" if DRY_RUN else "fixing")
if not DRY_RUN:
apply_fix(order, expected)
fixed += 1
log.info("Done. %d order(s) %s, %d flagged for review.",
fixed, "to fix" if DRY_RUN else "fixed", flagged)
if __name__ == "__main__":
run()
/**
* Find WooCommerce orders where the stored tax total does not match the tax you
* get from re-adding the line item taxes, the same math the frontend cart used.
*
* The checkout page rounds tax per line item as the buyer shops. The order that
* gets saved can end up with a total_tax that was rounded a different way, so the
* two numbers can disagree by a cent or two. This walks recent orders, recomputes
* the expected tax from the saved line items in integer cents, and flags or fixes
* any order whose stored total_tax drifts from that recomputed value. Safe by
* default. Run on a schedule.
*/
import { pathToFileURL } from "node:url";
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const MAX_DRIFT_CENTS = Number(process.env.MAX_DRIFT_CENTS || 3);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Orders in these statuses are still moving. Only reconcile settled money.
const SETTLED_STATUSES = new Set(["processing", "completed", "on-hold"]);
/** Turn a WooCommerce money string like "12.345" into integer cents, rounding
* half away from zero the same way most tax engines do. */
export function toMinor(amount) {
const cents = parseFloat(amount) * 100;
return cents >= 0 ? Math.floor(cents + 0.5) : -Math.floor(-cents + 0.5);
}
/** Sum the per-rate tax entries on one line item, in cents. WooCommerce stores
* this per item as taxes.total, an object of rate_id -> amount. */
export function lineItemTaxMinor(item) {
const taxes = (item.taxes && item.taxes.total) || {};
let total = 0;
for (const amount of Object.values(taxes)) {
if (amount !== null && amount !== undefined && amount !== "") total += toMinor(amount);
}
return total;
}
/** Recompute the order tax by re-adding every line item's own tax, the same
* rounded-per-line approach the cart and checkout page use while shopping.
* Covers line_items, shipping_lines, and fee_lines, since all three can carry tax. */
export function expectedTaxMinor(order) {
let total = 0;
for (const item of order.line_items || []) total += lineItemTaxMinor(item);
for (const item of order.shipping_lines || []) total += lineItemTaxMinor(item);
for (const item of order.fee_lines || []) total += lineItemTaxMinor(item);
return total;
}
export function storedTaxMinor(order) {
return toMinor(order.total_tax || "0");
}
/** Pure decision: compare the stored tax total against the tax recomputed
* from the order's own line items. No network calls, no Stripe involved,
* this is purely a WooCommerce order math question. */
export function decide(order, maxDriftCents = MAX_DRIFT_CENTS) {
if (!SETTLED_STATUSES.has(order.status)) return ["skip", "order not settled yet"];
const expected = expectedTaxMinor(order);
const stored = storedTaxMinor(order);
const drift = stored - expected;
if (drift === 0) return ["ok", "tax matches the line items"];
if (Math.abs(drift) > maxDriftCents) {
return ["review", `tax off by ${drift} cents, too large to auto fix`];
}
return ["fix", `tax off by ${drift} cents, adjusting total_tax to ${expected}`];
}
export function minorToAmount(minor) {
const sign = minor < 0 ? "-" : "";
minor = Math.abs(minor);
const whole = Math.floor(minor / 100);
const frac = String(minor % 100).padStart(2, "0");
return `${sign}${whole}.${frac}`;
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* recentOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=processing,completed,on-hold&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
/** Set total_tax to the recomputed value and leave a note explaining why.
* We only touch the order-level total_tax field, never the line items
* themselves, so refunds and reports that read line item tax are unaffected. */
async function applyFix(order, expectedMinor) {
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ total_tax: minorToAmount(expectedMinor) }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Tax reconciler: stored total_tax did not match the sum of the " +
"line item taxes. Adjusted total_tax to match the line items.",
}),
});
}
export async function run() {
let fixed = 0;
let flagged = 0;
for await (const order of recentOrders()) {
const [action, reason] = decide(order);
if (action === "skip" || action === "ok") continue;
if (action === "review") {
console.warn(`Order ${order.id}: ${reason}. Needs a human look.`);
flagged++;
continue;
}
const expected = expectedTaxMinor(order);
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
if (!DRY_RUN) await applyFix(order, expected);
fixed++;
}
console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to fix" : "fixed"}, ${flagged} flagged for review.`);
}
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 whether a real order's stored tax gets rewritten. Because we kept decide and its helper math pure, the tests need no network and no WooCommerce store. They just feed in plain order objects and check the action.
from reconcile_order_tax import decide, expected_tax_minor, to_minor, minor_to_amount
def line_item(rate_totals):
return {"taxes": {"total": rate_totals}}
def order(status="processing", total_tax="5.00", line_items=None, shipping_lines=None, fee_lines=None):
return {
"status": status,
"total_tax": total_tax,
"line_items": line_items or [],
"shipping_lines": shipping_lines or [],
"fee_lines": fee_lines or [],
}
def test_ok_when_tax_matches_line_items():
o = order(total_tax="5.00", line_items=[line_item({"1": "3.00"}), line_item({"1": "2.00"})])
assert decide(o)[0] == "ok"
def test_fix_when_off_by_one_cent():
# Line items round to 2.50 + 2.49 = 4.99, but the stored total_tax is 5.00.
o = order(total_tax="5.00", line_items=[line_item({"1": "2.495"}), line_item({"1": "2.494"})])
action, reason = decide(o)
assert action == "fix"
assert "1 cent" in reason
def test_review_when_drift_too_large():
o = order(total_tax="8.00", line_items=[line_item({"1": "3.00"}), line_item({"1": "2.00"})])
assert decide(o)[0] == "review"
def test_skip_when_order_not_settled():
o = order(status="pending", total_tax="5.00", line_items=[line_item({"1": "5.00"})])
assert decide(o)[0] == "skip"
def test_shipping_and_fee_lines_are_included():
o = order(
total_tax="6.00",
line_items=[line_item({"1": "3.00"})],
shipping_lines=[line_item({"1": "2.00"})],
fee_lines=[line_item({"1": "1.00"})],
)
assert decide(o)[0] == "ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, expectedTaxMinor, toMinor, minorToAmount } from "./reconcile-order-tax.js";
const lineItem = (rateTotals) => ({ taxes: { total: rateTotals } });
const order = (over = {}) => ({
status: "processing",
total_tax: "5.00",
line_items: [],
shipping_lines: [],
fee_lines: [],
...over,
});
test("ok when tax matches line items", () => {
const o = order({ total_tax: "5.00", line_items: [lineItem({ 1: "3.00" }), lineItem({ 1: "2.00" })] });
assert.equal(decide(o)[0], "ok");
});
test("fix when off by one cent", () => {
// Line items round to 2.50 + 2.49 = 4.99, but the stored total_tax is 5.00.
const o = order({ total_tax: "5.00", line_items: [lineItem({ 1: "2.495" }), lineItem({ 1: "2.494" })] });
const [action, reason] = decide(o);
assert.equal(action, "fix");
assert.match(reason, /1 cent/);
});
test("review when drift too large", () => {
const o = order({ total_tax: "8.00", line_items: [lineItem({ 1: "3.00" }), lineItem({ 1: "2.00" })] });
assert.equal(decide(o)[0], "review");
});
test("skip when order not settled", () => {
const o = order({ status: "pending", total_tax: "5.00", line_items: [lineItem({ 1: "5.00" })] });
assert.equal(decide(o)[0], "skip");
});
Case studies
The store with a state rate and a local rate
A store selling into a region with both a state tax and a city tax rounded each rate separately on every line item. Across a busy weekend, about one in eight orders ended up with a stored total_tax that was a single cent away from the sum of its own line items, enough to make the daily revenue export fail an automated check against the payment processor.
Running the reconciler nightly in dry run first showed the exact list and the exact cent drift on each order. Once the team trusted the report, they turned off dry run and let it correct the summary field automatically, keeping the export clean without anyone touching a tax setting.
The sale that shifted the taxable base
A site ran a "before tax" storewide coupon. The checkout page calculated tax on the discounted subtotal as the buyer applied the code, but a caching layer in front of the cart briefly served a stale tax estimate for a handful of shoppers before the order was placed, landing a few orders one to two cents apart from their line item sum.
The reconciler flagged nothing as a large drift, since every gap was under the three cent threshold, so it corrected all of them in one run and logged a note on each order pointing back to the coupon and the cache timing.
After this runs on a schedule, a cent of rounding drift stops being a mystery in your accounting export. Most runs find nothing to fix. When they do, the change is small, explained by a note on the order, and limited to the one summary field it is supposed to correct. Anything bigger than a few cents never gets auto corrected, it waits for a person, which is exactly how a script that touches real money totals should behave.
FAQ
Why does the tax on my WooCommerce order not match what the buyer saw at checkout?
The checkout page rounds tax per line item as the cart updates live. The order that gets saved can round the same numbers a different way, often because of a rate change, a coupon applied after tax, or a rounding setting mismatch. The two totals are each internally consistent, they were just rounded at a different point, so they can land a cent or two apart.
Is a one cent tax difference worth fixing?
On its own, no single cent matters. Across thousands of orders it adds up in your accounting exports and can trip automated reconciliation with a payment processor or a tax filing tool. A small script that finds and corrects the drift keeps your books exact without anyone chasing pennies by hand.
Is it safe to change an order's stored tax total with a script?
Yes, when the fix only applies to a small drift, typically a few cents, and only adjusts the order-level total_tax field to match the sum of its own line item taxes. Larger drifts are flagged for a human instead of being auto corrected. Start in dry run mode to review the list before it writes.
Related field notes
Citations
On the problem:
- WooCommerce core issue tracker: order totals and cart totals can differ by rounding on compound tax rates. github.com/woocommerce/woocommerce/issues
- WooCommerce docs: setting up taxes, including the "round tax at subtotal level" option. woocommerce.com/document/setting-up-taxes-in-woocommerce
- WooCommerce docs: how line item, shipping, and fee taxes are calculated and stored per rate. woocommerce.com/document/tax-rates
On the solution:
- WooCommerce REST API: retrieve and update an order, including total_tax and line item tax fields. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: the order line item, shipping line, and fee line schema, including the taxes array. woocommerce.github.io/woocommerce-rest-api-docs
- General guidance on avoiding floating point rounding errors in money math by working in minor units (cents). martinfowler.com/eaaCatalog/money.html
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 clean up your tax totals?
If this saved you a pile of accounting cleanup or a failed reconciliation, 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