Diagnostic WooCommerce core: tax, totals, and analytics

Rounding drifts the order total by a cent

The order total on WooCommerce says 49.99. Stripe says it charged 50.00. Nobody typed a wrong number, no plugin misfired, and the buyer paid exactly what checkout showed them. The two totals just landed a cent apart because they were rounded in two different places. Here is why that happens and a small script that finds every order where the drift is real rounding and every order where it is not.

Python and Node.js Runs on a schedule Safe by default (dry run)
Drawers stacked on top of each other
Photo by Alexander Grigoryev on Unsplash
The short answer

WooCommerce can round the tax on each line item and then add the rounded lines together, while Stripe rounds the grand total once at the end. Those two paths sometimes land on different final digits, so the order total and the amount Stripe actually charged (amount_received) differ by a cent. Run a small Python or Node.js script that compares both amounts in cents, treats a one or two cent gap as ordinary rounding, and flags anything bigger as a real mismatch worth a human look. Full code, tests, and a dry run guard are below.

The problem in plain words

Every price WooCommerce shows has already gone through rounding somewhere. When a cart has several taxed line items, the store has a setting for how to round the tax: round each line first and add up the rounded amounts, or add up the exact amounts and round the total once. Either choice is reasonable on its own, but it is a choice, and it is a choice Stripe is not part of.

Stripe only ever sees one number: the total amount to charge, already rounded to whole cents by WooCommerce before the request goes out. The card network then settles that exact amount. So if a rounding rule anywhere in the chain, WooCommerce's own tax settings, a coupon that splits unevenly across lines, or a currency conversion step, nudges the stored order total by a cent after the charge was already sent, the order total and the Stripe charge stop agreeing with each other. Nobody was overcharged or undercharged. Two ledgers just describe the same sale with slightly different math.

3 line items tax rounded per line then summed Order total 49.99 rounded again Sent to Stripe 50.00 order total (49.99) ≠ Stripe amount_received (50.00) one cent gap, correct sale, two disagreeing records
Each system rounds the money in a slightly different place. The buyer paid one real amount, but the two stored records of that amount do not quite match.

Why it happens

WooCommerce documents its own rounding setting under WooCommerce, Settings, Tax, "Rounding", which controls whether tax is rounded per line item or at the subtotal level. Changing that setting, or having it differ between a staging site and production, is enough to shift totals by a cent on carts with multiple taxed lines. A few other common causes:

None of this means a customer was charged the wrong amount. Stripe's amount_received on the PaymentIntent is the one number that was actually collected. The order total in WooCommerce is a separately computed number that is supposed to match it and, because of rounding, sometimes does not by a cent or two.

The key insight

A one or two cent gap between the order total and amount_received is almost always rounding, not a bug, and is safe to leave alone once you understand the cause. The thing worth building a detector for is telling that ordinary drift apart from a real mismatch, a difference too large to be rounding, which usually means something else went wrong and does need a human to look at it.

The fix, as a flow

We do not change how WooCommerce rounds anything, and we do not touch a single order automatically. We add a small job that runs on a schedule, reads every recently paid order, reads the Stripe PaymentIntent it was paired with, and compares the two amounts in cents. Anything that matches exactly is left alone. Anything within a small tolerance is logged as ordinary rounding. Anything past that tolerance gets a note added to the order so a person can decide what to do.

Scheduled job every few hours Load paid order and its PaymentIntent Compare in cents total vs amount_received how big is the gap? 0 cents: ok, skip ≤ tolerance Log as drift ordinary rounding > tolerance: mismatch Add order note flag for review
The comparison happens in whole cents, not floating point dollars, so the tolerance check is exact instead of approximate.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read access to orders, and write access if you want it to add notes. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export ROUNDING_TOLERANCE_CENTS="1"
export DRY_RUN="true"   # start safe, change to false to write notes
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export ROUNDING_TOLERANCE_CENTS="1"
export DRY_RUN="true"   // start safe, change to false to write notes
2

Find the Stripe PaymentIntent id on the order

WooCommerce's Stripe gateway stores the PaymentIntent id as order meta under the key _stripe_intent_id. Some older orders instead have it saved as the order's transaction_id. Read the meta first and fall back to the transaction ID only if it looks like a PaymentIntent id, since a charge ID or nothing at all is also possible there.

step2.py
def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None
step2.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}
3

List recent paid orders from WooCommerce

Use the REST API to page through orders in Processing or Completed status created after your lookback window. Going through the REST API keeps this working the same on stores with High Performance Order Storage (HPOS) turned on, since WooCommerce handles where the data actually lives.

step3.py
import os, datetime, requests
from requests.auth import HTTPBasicAuth

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"))

def paid_orders():
    page = 1
    after = (datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)).isoformat() + "T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1
step3.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);

async function woo(path) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    headers: { "Content-Type": "application/json", Authorization: AUTH },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* paidOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
4

Decide, with one pure function, in cents

Convert the order total to minor units first, so the comparison is an integer subtraction instead of a floating point one. Compare that to Stripe's amount_received, which is already in cents. A pure function like this is easy to read and easy to test, which we do later. The rule sorts every paid order into one of five buckets: not paid yet, no matching Stripe charge, exact match, ordinary rounding, or a real mismatch.

decide.py
PAID_STATUSES = {"processing", "completed"}
ROUNDING_TOLERANCE_CENTS = 1

def order_total_minor(order):
    # Keep money math in cents so the comparison is exact, not floating point.
    return round(float(order["total"]) * 100)

def decide(order, intent):
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a paid state")
    if intent is None:
        return ("orphan", "no Stripe PaymentIntent found for a paid order")
    if intent.get("status") != "succeeded":
        return ("orphan", "Stripe shows the payment not succeeded")

    charged = intent.get("amount_received")
    if charged is None:
        return ("orphan", "PaymentIntent has no amount_received")

    diff_cents = order_total_minor(order) - charged
    if diff_cents == 0:
        return ("ok", "order total matches the Stripe charge exactly")
    if abs(diff_cents) <= ROUNDING_TOLERANCE_CENTS:
        return ("drift", f"order total is {diff_cents:+d} cent(s) from the Stripe charge")
    return ("mismatch", f"order total is {diff_cents:+d} cent(s) from the Stripe charge, too large to be rounding")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);
const ROUNDING_TOLERANCE_CENTS = 1;

export function orderTotalMinor(order) {
  // Keep money math in cents so the comparison is exact, not floating point.
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent, toleranceCents = ROUNDING_TOLERANCE_CENTS) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["orphan", "no Stripe PaymentIntent found for a paid order"];
  if (intent.status !== "succeeded") return ["orphan", "Stripe shows the payment not succeeded"];

  const charged = intent.amount_received;
  if (charged === undefined || charged === null) {
    return ["orphan", "PaymentIntent has no amount_received"];
  }

  const diffCents = orderTotalMinor(order) - charged;
  if (diffCents === 0) return ["ok", "order total matches the Stripe charge exactly"];
  if (Math.abs(diffCents) <= toleranceCents) {
    return ["drift", `order total is ${diffCents > 0 ? "+" : ""}${diffCents} cent(s) from the Stripe charge`];
  }
  return ["mismatch", `order total is ${diffCents > 0 ? "+" : ""}${diffCents} cent(s) from the Stripe charge, too large to be rounding`];
}
5

Report drifted and mismatched orders with a note

When the action is drift or mismatch, add an order note explaining exactly what was compared and what the gap was. This does not change the order status or the total, it just leaves a clear trail so accounting can see at a glance which orders are ordinary rounding and which ones actually need a look.

report.py
def report(order, action, reason):
    note = (
        f"Rounding check: {reason}. Order total is {order['total']} {order.get('currency', '')}. "
        f"Flagged as {action} by the rounding drift detector."
    )
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": note},
        auth=AUTH, timeout=30,
    ).raise_for_status()
report.js
async function report(order, action, reason) {
  const note =
    `Rounding check: ${reason}. Order total is ${order.total} ${order.currency || ""}. ` +
    `Flagged as ${action} by the rounding drift detector.`;
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({ note }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs what it would flag. Read the output, confirm the drift orders really are rounding, then switch it off to let it write notes. Run it on a schedule with cron, once or twice a day is plenty since this is not urgent the way a stuck payment is.

Run it safe

Always start with DRY_RUN=true. This script only ever adds a note, it never changes an order's status or total, but you still want to see its plan before it writes anything to real orders.

The full code

Here is the complete detector in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever adds an order note, so it is safe to run again and again.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

detect_rounding_drift.py
"""Detect WooCommerce orders whose total is off by a cent (or two) from what
Stripe actually charged.

WooCommerce can round each line item's tax separately while Stripe (or the
card network) rounds the grand total once, so the two systems land on
different final digits even though nothing is actually wrong with the sale.
This walks recent paid orders, reads the saved Stripe PaymentIntent, compares
the amounts in minor units (cents), and flags any order where the drift is
larger than one cent (a real mismatch) or, optionally, notes the ones off by
exactly one or two cents so accounting can reconcile them. Read only by
default. Run on a schedule.
"""
import os
import datetime
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
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"))
ROUNDING_TOLERANCE_CENTS = int(os.environ.get("ROUNDING_TOLERANCE_CENTS", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def order_total_minor(order):
    """The order total in cents. Keep all money math in minor units so
    float rounding never sneaks a second bug into the comparison."""
    return round(float(order["total"]) * 100)


def decide(order, intent):
    """Pure decision: no network, no I/O. Given a WooCommerce order (dict)
    and its Stripe PaymentIntent (dict or None), return an (action, reason)
    tuple. Actions: skip, orphan, drift, mismatch, ok."""
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a paid state")
    if intent is None:
        return ("orphan", "no Stripe PaymentIntent found for a paid order")
    if intent.get("status") != "succeeded":
        return ("orphan", "Stripe shows the payment not succeeded")

    charged = intent.get("amount_received")
    if charged is None:
        return ("orphan", "PaymentIntent has no amount_received")

    diff_cents = order_total_minor(order) - charged
    if diff_cents == 0:
        return ("ok", "order total matches the Stripe charge exactly")
    if abs(diff_cents) <= ROUNDING_TOLERANCE_CENTS:
        return ("drift", f"order total is {diff_cents:+d} cent(s) from the Stripe charge")
    return ("mismatch", f"order total is {diff_cents:+d} cent(s) from the Stripe charge, too large to be rounding")


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def paid_orders():
    page = 1
    after = (datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)).isoformat() + "T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def report(order, action, reason):
    note = (
        f"Rounding check: {reason}. Order total is {order['total']} {order.get('currency', '')}. "
        f"Flagged as {action} by the rounding drift detector."
    )
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": note},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    flagged = 0
    for order in paid_orders():
        intent = get_intent(intent_id_of(order))
        action, reason = decide(order, intent)
        if action in ("skip", "ok"):
            continue
        log.warning("Order %s: %s. %s", order["id"], reason, "would flag" if DRY_RUN else "flagging")
        if not DRY_RUN:
            report(order, action, reason)
        flagged += 1
    log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
detect-rounding-drift.js
/**
 * Detect WooCommerce orders whose total is off by a cent (or two) from what
 * Stripe actually charged.
 *
 * WooCommerce can round each line item's tax separately while Stripe (or the
 * card network) rounds the grand total once, so the two systems land on
 * different final digits even though nothing is actually wrong with the sale.
 * This walks recent paid orders, reads the saved Stripe PaymentIntent,
 * compares the amounts in minor units (cents), and flags any order where the
 * drift is larger than the tolerance (a real mismatch) or, optionally, notes
 * the ones off by exactly one or two cents so accounting can reconcile them.
 * Read only by default. Run on a schedule.
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
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 ROUNDING_TOLERANCE_CENTS = Number(process.env.ROUNDING_TOLERANCE_CENTS || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function orderTotalMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent, toleranceCents = ROUNDING_TOLERANCE_CENTS) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["orphan", "no Stripe PaymentIntent found for a paid order"];
  if (intent.status !== "succeeded") return ["orphan", "Stripe shows the payment not succeeded"];

  const charged = intent.amount_received;
  if (charged === undefined || charged === null) {
    return ["orphan", "PaymentIntent has no amount_received"];
  }

  const diffCents = orderTotalMinor(order) - charged;
  if (diffCents === 0) return ["ok", "order total matches the Stripe charge exactly"];
  if (Math.abs(diffCents) <= toleranceCents) {
    return ["drift", `order total is ${diffCents > 0 ? "+" : ""}${diffCents} cent(s) from the Stripe charge`];
  }
  return ["mismatch", `order total is ${diffCents > 0 ? "+" : ""}${diffCents} cent(s) from the Stripe charge, too large to be rounding`];
}

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 getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function* paidOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function report(order, action, reason) {
  const note =
    `Rounding check: ${reason}. Order total is ${order.total} ${order.currency || ""}. ` +
    `Flagged as ${action} by the rounding drift detector.`;
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({ note }),
  });
}

export async function run() {
  let flagged = 0;
  for await (const order of paidOrders()) {
    const intent = await getIntent(intentIdOf(order));
    const [action, reason] = decide(order, intent);
    if (action === "skip" || action === "ok") continue;
    console.warn(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
    if (!DRY_RUN) await report(order, action, reason);
    flagged++;
  }
  console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

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 a note added and which ones do not. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action and the boundary between ordinary rounding and a real mismatch.

test_rounding_decide.py
from detect_rounding_drift import decide, intent_id_of, order_total_minor


def intent(**over):
    base = {"status": "succeeded", "amount_received": 5000}
    base.update(over)
    return base


def test_ok_when_amounts_match_exactly():
    order = {"status": "processing", "total": "50.00"}
    assert decide(order, intent())[0] == "ok"


def test_drift_when_off_by_one_cent():
    order = {"status": "processing", "total": "50.01"}
    assert decide(order, intent(amount_received=5000))[0] == "drift"


def test_mismatch_when_off_by_more_than_tolerance():
    order = {"status": "processing", "total": "50.10"}
    assert decide(order, intent(amount_received=5000))[0] == "mismatch"


def test_orphan_when_no_intent():
    order = {"status": "completed", "total": "50.00"}
    assert decide(order, None)[0] == "orphan"


def test_skip_when_order_not_paid():
    order = {"status": "pending", "total": "50.00"}
    assert decide(order, None)[0] == "skip"


def test_intent_id_from_meta():
    order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
    assert intent_id_of(order) == "pi_123"
detect-rounding-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./detect-rounding-drift.js";

const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });

test("ok when amounts match exactly", () => {
  assert.equal(decide({ status: "processing", total: "50.00" }, intent())[0], "ok");
});

test("drift when off by one cent", () => {
  assert.equal(decide({ status: "processing", total: "50.01" }, intent({ amount_received: 5000 }))[0], "drift");
});

test("mismatch when off by more than tolerance", () => {
  assert.equal(decide({ status: "processing", total: "50.10" }, intent({ amount_received: 5000 }))[0], "mismatch");
});

test("orphan when no intent", () => {
  assert.equal(decide({ status: "completed", total: "50.00" }, null)[0], "orphan");
});

test("skip when order not paid", () => {
  assert.equal(decide({ status: "pending", total: "50.00" }, null)[0], "skip");
});

test("intentIdOf from meta", () => {
  assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});

Case studies

Tax setting change

The staging fix that only helped staging

A store had a tax rounding mismatch on staging and switched the "Rounding" option under WooCommerce tax settings from per line item to per total to fix it there. Production kept the old setting. From that day on, every multi-item order in production ran one cent off from its Stripe charge, and nobody noticed until a monthly reconciliation report came up short by a small, strange amount.

The detector, run once across the last quarter, listed a few hundred orders all flagged as drift, all exactly one cent, all on orders with three or more taxed line items. That pattern confirmed it was the tax setting, not fraud or a broken integration, and the fix was a two minute settings change plus a note in the reconciliation spreadsheet.

Currency conversion

The mismatch that was not rounding at all

A store selling in euros had a small number of orders flagged as mismatch rather than drift, off by four to nine cents instead of one. The pattern only showed up on orders paid with a card issued in a different currency than the store's.

Because the tolerance in the decision function drew a clear line between ordinary rounding and a real gap, the team knew immediately these were not the tax rounding issue and dug into the currency conversion step instead, where a stale exchange rate cache was the actual cause.

What good looks like

After this runs on a schedule, a one cent rounding gap stops being a mystery and becomes an expected, labeled line in a report. The detector never changes an order, it only tells you which cent-level gaps are normal and which ones are large enough to be a different, real problem. Keep it running even after you understand your store's rounding pattern, since a new mismatch pattern is often the first sign of a separate bug.

FAQ

Why is my WooCommerce order total a cent off from the Stripe charge?

WooCommerce can round the tax on each line item separately, then add the rounded lines together, while Stripe (and some tax settings) round the grand total once at the end. Those two rounding paths can land on different final digits, so the order total and the amount Stripe actually charged differ by a cent or two even though the sale is correct.

Is a one cent difference a bug I need to fix for every order?

No. A one or two cent difference caused by rounding is expected and safe to leave alone once you have confirmed that is the cause. What you do want to catch is any order where the difference is larger than a couple of cents, since that usually points to a real problem like a currency mismatch or an edited order.

How do I catch the orders that are wrong instead of just rounded?

Run a small script that reads each paid order's total and its Stripe PaymentIntent amount_received, compares them in cents, and sorts the results into ordinary rounding drift versus a real mismatch. Keep the comparison as a pure function so you can test the rule directly and trust the output.

Related field notes

Citations

On the problem:

  1. WooCommerce docs: Setting up taxes in WooCommerce, including the tax rounding option. woocommerce.com/document/setting-up-taxes-in-woocommerce
  2. WooCommerce core issue: order totals can differ by rounding when tax is calculated per line versus per total. github.com/woocommerce/woocommerce/issues
  3. Stripe docs: understanding amounts, minor units, and how Stripe represents money. docs.stripe.com/currencies

On the solution:

  1. Stripe API: the PaymentIntent object and the amount_received field. docs.stripe.com/api/payment_intents/object
  2. Stripe docs: retrieve a PaymentIntent by id. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce REST API: list orders and add an order note. woocommerce.github.io/woocommerce-rest-api-docs

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.

Contact me on LinkedIn

Did this clear up your rounding mystery?

If this saved you a confusing afternoon staring at a one cent gap, 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 WooCommerce and Stripe field notes