Reconciler Charges and money
WooCommerce orders where the amount does not match the order
The order shows Processing or Completed, so it looks fine at a glance. But the number Stripe actually captured and the number on the WooCommerce order total quietly disagree. Nobody notices until a refund is short, a customer disputes a charge, or the books do not add up at the end of the month. Here is why the two numbers drift apart and a small script that compares them, order by order, and tells you exactly which ones to look at.
The WooCommerce order total and the amount Stripe actually captured are two separate numbers stored in two separate systems, and nothing keeps them in sync automatically after the order is placed. Run a small Python or Node.js check on a schedule that reads each paid order's total, looks up its Stripe PaymentIntent by the saved id, converts both amounts to minor units (cents), and flags any order where they differ by more than a tiny rounding tolerance. It never changes money on its own. It adds a clear note so a person can decide what to do. Full code, tests, and a dry run guard are below.
The problem in plain words
When a buyer checks out, WooCommerce builds an order total from the cart, and Stripe creates a PaymentIntent for that same amount. At that moment the two numbers match. The trouble starts after that moment.
A partial refund might get applied on the Stripe side through the dashboard without the matching WooCommerce refund being recorded. A coupon, a shipping change, or a manual price edit might touch the WooCommerce order after the PaymentIntent was already created and captured. A currency conversion or a zero decimal currency handled incorrectly can shift the number by a small but real amount. In every case, the order still says paid, so it looks correct in the order list. Only a direct comparison against Stripe reveals that the amounts do not match the order.
Why it happens
Stripe and WooCommerce Docs both describe the PaymentIntent and order objects as independent records that are only linked by an id, not kept continuously synchronized. A few common ways they drift:
- A refund is issued from the Stripe dashboard directly, so
amount_receiveddrops, but the WooCommerce order total and refund records are never updated to match. - The order is edited after checkout, a coupon is applied, a shipping line is changed, or a manual price correction is made, but the PaymentIntent was already captured for the original amount and nobody recharges or refunds the difference.
- A zero decimal currency such as JPY or KRW is divided by 100 somewhere in a custom integration, so the WooCommerce total is off by a factor that only shows up as a mismatch, not an error.
- A webhook applied a stale or duplicate event, so the order was marked paid using an old, since superseded PaymentIntent snapshot instead of the current one.
None of these cases throw an error. The order still shows Processing or Completed. The mismatch only becomes visible when someone compares the two numbers directly, which is exactly what a reconciler does on a schedule instead of a person doing it by hand.
Do not try to guess which side is right. The WooCommerce total could be correct and the Stripe capture wrong, or the other way around. The safe job for a script is to detect and report the drift in plain numbers, in minor units so rounding cannot hide a real difference, and leave the decision of what to refund, recharge, or correct to a person who can see the whole order.
The fix, as a flow
We add a job that runs on a schedule, walks recent paid orders, and for each one reads the Stripe PaymentIntent id saved on the order. It looks up that PaymentIntent, converts both the order total and the captured amount to cents, and compares them with a small tolerance for rounding. Anything outside that tolerance gets a note on the order explaining the drift and which direction it runs, so whoever reviews it can act quickly.
Build it step by step
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, plus write access to add notes. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
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 MISMATCH_TOLERANCE_MINOR="1" # cents of slack before flagging
export DRY_RUN="true" # start safe, this job only writes a note either way
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 MISMATCH_TOLERANCE_MINOR="1" // cents of slack before flagging
export DRY_RUN="true" // start safe, this job only writes a note either way
Walk recent paid orders
Page through orders with status processing or completed from your lookback window. There is no need to look at pending or cancelled orders, since the comparison only matters once WooCommerce believes the order is paid.
import os, 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 = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}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
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, 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* 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++;
}
}
Find the PaymentIntent id, then load it from Stripe
Read the id from order meta _stripe_intent_id first, since that is where the WooCommerce Stripe plugin usually stores it. Fall back to transaction_id when it looks like a PaymentIntent id (it starts with pi_), since some setups save it there instead. Then retrieve the intent so we can see what Stripe actually captured.
import stripe
def intent_id_of(order):
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 get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
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;
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
Decide, with one pure function, in minor units
Keep the comparison in its own function that takes an order and an intent and returns an action. Convert the WooCommerce total to minor units (cents) rather than comparing dollar strings or floats directly, since floating point rounding can make two equal amounts look different. Compare against amount_received, since that reflects any partial refund Stripe already applied, and allow a small tolerance for genuine rounding.
PAID_STATUSES = {"processing", "completed"}
MISMATCH_TOLERANCE_MINOR = 1
def order_amount_minor(order):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since round(x * 100) is wrong for those.
return round(float(order["total"]) * 100)
def captured_amount_minor(intent):
return intent.get("amount_received", intent.get("amount", 0))
def decide(order, intent, tolerance_minor=MISMATCH_TOLERANCE_MINOR):
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state")
if intent is None:
return ("skip", "no Stripe PaymentIntent id on this order")
if intent.get("status") != "succeeded":
return ("skip", "intent not succeeded, amount comparison does not apply yet")
order_minor = order_amount_minor(order)
charged_minor = captured_amount_minor(intent)
drift = order_minor - charged_minor
if abs(drift) <= tolerance_minor:
return ("ok", "order total matches the captured amount")
direction = "order total is higher than the Stripe charge" if drift > 0 else "order total is lower than the Stripe charge"
return ("flag", f"amount does not match the order: {direction} (drift {drift} minor units)")
const PAID_STATUSES = new Set(["processing", "completed"]);
const MISMATCH_TOLERANCE_MINOR = 1;
export function orderAmountMinor(order) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since Math.round(x * 100) is wrong for those.
return Math.round(parseFloat(order.total) * 100);
}
export function capturedAmountMinor(intent) {
return intent.amount_received ?? intent.amount ?? 0;
}
export function decide(order, intent, toleranceMinor = MISMATCH_TOLERANCE_MINOR) {
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
if (!intent) return ["skip", "no Stripe PaymentIntent id on this order"];
if (intent.status !== "succeeded") {
return ["skip", "intent not succeeded, amount comparison does not apply yet"];
}
const orderMinor = orderAmountMinor(order);
const chargedMinor = capturedAmountMinor(intent);
const drift = orderMinor - chargedMinor;
if (Math.abs(drift) <= toleranceMinor) return ["ok", "order total matches the captured amount"];
const direction = drift > 0
? "order total is higher than the Stripe charge"
: "order total is lower than the Stripe charge";
return ["flag", `amount does not match the order: ${direction} (drift ${drift} minor units)`];
}
Flag it, do not touch the money
When the action is flag, add an order note describing the exact drift so a person can decide what to do. As an optional extra step you can move the order to on-hold for review, controlled by its own flag, but the default behavior never changes the order total, never issues a refund, and never recharges the customer on its own.
def flag(order, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Payment check failed: {reason}. Please review before shipping "
f"or refunding this order."},
auth=AUTH, timeout=30,
).raise_for_status()
if REVIEW_HOLD:
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"status": "on-hold"}, auth=AUTH, timeout=30,
).raise_for_status()
async function flag(order, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Payment check failed: ${reason}. Please review before shipping or refunding this order.`,
}),
});
if (REVIEW_HOLD) {
await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
}
}
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 reports what it would flag. Read the output, trust it, then switch it off to let it write the notes. Run it on a schedule with cron a few times a day, since amount drift is rarely urgent to the minute.
Always start with DRY_RUN=true. Even though this job never changes an order total or issues a refund, it does write order notes, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.
The full code
Here is the complete check in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only reads and reports, never changing an order's amount on its own.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Flag WooCommerce orders whose total does not match the Stripe charge behind them.
A partial refund applied only on one side, a currency rounding difference, a coupon
that changed the order after the PaymentIntent was created, or a manual edit to the
order total can all leave the WooCommerce order total and the Stripe PaymentIntent
amount disagreeing. This walks recent paid orders, reads the saved PaymentIntent id
from order meta `_stripe_intent_id` (falling back to `transaction_id`), and flags any
order whose amount drifts from what Stripe actually captured, by adding an order note.
Read only by default. Run on a schedule.
"""
import os
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("check_amount_mismatch")
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"))
MISMATCH_TOLERANCE_MINOR = int(os.environ.get("MISMATCH_TOLERANCE_MINOR", "1"))
REVIEW_HOLD = os.environ.get("REVIEW_HOLD", "false").lower() == "true"
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_amount_minor(order):
"""Order total in minor units (cents). Two decimal currencies only; zero decimal
currencies such as JPY have their own guide, since round(x * 100) is wrong there."""
return round(float(order["total"]) * 100)
def captured_amount_minor(intent):
"""What Stripe actually captured for this intent, in minor units."""
return intent.get("amount_received", intent.get("amount", 0))
def decide(order, intent, tolerance_minor=MISMATCH_TOLERANCE_MINOR):
"""Pure decision: given an order and its Stripe PaymentIntent, decide whether the
amounts agree. No I/O here, so this is fully unit testable."""
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state")
if intent is None:
return ("skip", "no Stripe PaymentIntent id on this order")
if intent.get("status") != "succeeded":
return ("skip", "intent not succeeded, amount comparison does not apply yet")
order_minor = order_amount_minor(order)
charged_minor = captured_amount_minor(intent)
drift = order_minor - charged_minor
if abs(drift) <= tolerance_minor:
return ("ok", "order total matches the captured amount")
direction = "order total is higher than the Stripe charge" if drift > 0 else "order total is lower than the Stripe charge"
return ("flag", f"amount does not match the order: {direction} (drift {drift} minor units)")
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 = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}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 flag(order, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Payment check failed: {reason}. Please review before shipping "
f"or refunding this order."},
auth=AUTH, timeout=30,
).raise_for_status()
if REVIEW_HOLD:
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"status": "on-hold"}, 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 != "flag":
continue
log.warning("Order %s: %s. %s", order["id"], reason, "would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
flag(order, reason)
flagged += 1
log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Flag WooCommerce orders whose total does not match the Stripe charge behind them.
*
* A partial refund applied only on one side, a currency rounding difference, a coupon
* that changed the order after the PaymentIntent was created, or a manual edit to the
* order total can all leave the WooCommerce order total and the Stripe PaymentIntent
* amount disagreeing. This walks recent paid orders, reads the saved PaymentIntent id
* from order meta `_stripe_intent_id` (falling back to `transaction_id`), and flags any
* order whose amount drifts from what Stripe actually captured, by adding an order note.
* Read only by default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/amount-does-not-match-the-order/
*/
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 MISMATCH_TOLERANCE_MINOR = Number(process.env.MISMATCH_TOLERANCE_MINOR || 1);
const REVIEW_HOLD = (process.env.REVIEW_HOLD || "false").toLowerCase() === "true";
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 orderAmountMinor(order) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since Math.round(x * 100) is wrong for those.
return Math.round(parseFloat(order.total) * 100);
}
export function capturedAmountMinor(intent) {
return intent.amount_received ?? intent.amount ?? 0;
}
export function decide(order, intent, toleranceMinor = MISMATCH_TOLERANCE_MINOR) {
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
if (!intent) return ["skip", "no Stripe PaymentIntent id on this order"];
if (intent.status !== "succeeded") {
return ["skip", "intent not succeeded, amount comparison does not apply yet"];
}
const orderMinor = orderAmountMinor(order);
const chargedMinor = capturedAmountMinor(intent);
const drift = orderMinor - chargedMinor;
if (Math.abs(drift) <= toleranceMinor) return ["ok", "order total matches the captured amount"];
const direction = drift > 0
? "order total is higher than the Stripe charge"
: "order total is lower than the Stripe charge";
return ["flag", `amount does not match the order: ${direction} (drift ${drift} minor units)`];
}
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 flag(order, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Payment check failed: ${reason}. Please review before shipping or refunding this order.`,
}),
});
if (REVIEW_HOLD) {
await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
}
}
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 !== "flag") continue;
console.warn(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) await flag(order, 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 real orders get flagged for a person to review. 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 reason.
from check_amount_mismatch import decide, intent_id_of, order_amount_minor, captured_amount_minor
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000}
base.update(over)
return base
def test_ok_when_amounts_match():
order = {"status": "processing", "total": "50.00"}
assert decide(order, intent())[0] == "ok"
def test_flag_when_order_total_higher():
order = {"status": "processing", "total": "55.00"}
action, reason = decide(order, intent())
assert action == "flag"
assert "higher" in reason
def test_flag_when_order_total_lower():
order = {"status": "completed", "total": "45.00"}
action, reason = decide(order, intent())
assert action == "flag"
assert "lower" in reason
def test_skip_when_order_not_paid():
order = {"status": "pending", "total": "50.00"}
assert decide(order, intent())[0] == "skip"
def test_tolerance_allows_rounding_of_one_cent():
order = {"status": "processing", "total": "50.01"}
assert decide(order, intent(amount_received=5000))[0] == "ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, orderAmountMinor } from "./check-amount-mismatch.js";
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });
test("ok when amounts match", () => {
assert.equal(decide({ status: "processing", total: "50.00" }, intent())[0], "ok");
});
test("flag when order total higher than charge", () => {
const [action, reason] = decide({ status: "processing", total: "55.00" }, intent());
assert.equal(action, "flag");
assert.match(reason, /higher/);
});
test("flag when order total lower than charge", () => {
const [action, reason] = decide({ status: "completed", total: "45.00" }, intent());
assert.equal(action, "flag");
assert.match(reason, /lower/);
});
test("skip when order not paid", () => {
assert.equal(decide({ status: "pending", total: "50.00" }, intent())[0], "skip");
});
test("tolerance allows rounding of one cent", () => {
assert.equal(decide({ status: "processing", total: "50.01" }, intent({ amount_received: 5000 }))[0], "ok");
});
test("orderAmountMinor converts dollars to cents", () => {
assert.equal(orderAmountMinor({ total: "19.99" }), 1999);
});
Case studies
The support agent who refunded the wrong side
A support agent issued a $12 partial refund straight from the Stripe dashboard to resolve a shipping complaint, without touching the WooCommerce order. Stripe's amount_received dropped by $12, but the order total in WooCommerce stayed the same, and the order still showed Completed.
The weekly check flagged the order the same day, showing the exact $12 drift and its direction. Finance corrected the WooCommerce order to match the real refund in a couple of minutes, instead of finding the gap during month end reconciliation.
The order edited after the charge was already captured
A store manager added a discount to an order after checkout to make up for a delayed shipment, lowering the WooCommerce total by $8. The original PaymentIntent had already captured the full amount, and nobody circled back to refund the difference.
Running the check in dry run surfaced fourteen orders like this over a month. Once the team saw the pattern, they added a rule to always issue a matching Stripe refund whenever an order total is edited after payment.
After this runs on a schedule, a silent amount mismatch becomes a same day note on the order instead of a surprise during a dispute or a monthly reconciliation. The script never guesses which side is correct, it only measures the gap in cents and hands a clear, specific report to a person who can look at the whole order and decide what to do.
FAQ
Why does the amount Stripe charged not match my WooCommerce order total?
A partial refund applied on only one side, a coupon or shipping change made to the order after the PaymentIntent was created, or a manual edit to the order total can all leave the two numbers disagreeing. A reconciler that compares the order total to the amount Stripe actually captured finds every order where this happened.
Should a script automatically fix the order total when it does not match?
No. Changing money automatically is risky, since either side could be the one that is wrong. The safe move is to flag the order with a note describing the drift and let a person decide whether to refund, recharge, or correct the order total.
How do I compare a WooCommerce total to a Stripe amount without rounding bugs?
Convert the WooCommerce order total to minor units, cents for most currencies, and compare it directly to the intent's amount_received in minor units. Comparing dollar strings to integer cents is what causes false positives from floating point rounding.
Related field notes
Citations
On the problem:
- Stripe docs: the PaymentIntent object, including
amountandamount_receivedand how refunds change them. docs.stripe.com/api/payment_intents/object - WooCommerce docs: how order totals, refunds, and Stripe are recorded on an order. woocommerce.com/document/stripe
- Stripe docs: refunding a payment, including partial refunds made from the dashboard. docs.stripe.com/refunds
On the solution:
- Stripe docs: retrieve a PaymentIntent to read its current captured amount. docs.stripe.com/api/payment_intents/retrieve
- Stripe docs: working with amounts in minor units across currencies. docs.stripe.com/currencies
- 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.
Did this catch a mismatch for you?
If this saved you a short refund, a dispute, or a rough month end 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