Diagnostic Payment lifecycle

WooCommerce orders marked paid with no matching Stripe charge

An order shows as Processing or Completed, so your reports count it as a sale and your team ships it. But when you look in Stripe, there is no charge behind it. The money was never taken. It can come from a manual status change, a plugin that got it wrong, or a tampered checkout, and it quietly inflates revenue and lets goods walk out the door unpaid. Here is why it happens and a small job that checks every paid order against Stripe and flags the ones that do not add up.

Python and Node.js Runs on a schedule Read only by default
Calculator, magnifying glass, and chart with gears on paper.
Photo by Sasun Bughdaryan on Unsplash
The short answer

Some orders reach a paid status without a real payment behind them. Run a small Python or Node.js job that walks recent Processing and Completed orders, reads the Stripe PaymentIntent id saved on each one, looks it up in Stripe, and flags any order where the payment is missing, not succeeded, or the wrong amount by adding an order note. It is read only by default. Full code, tests, and a dry run guard are below.

The problem in plain words

In WooCommerce, an order status like Processing means paid and ready to fulfill. But the status is just a field. Anything that sets it, a person, a plugin, an import, marks the order as paid whether or not money actually moved.

Stripe is where the money really is. If an order says paid but Stripe has no matching charge, the two disagree, and WooCommerce is the one that is wrong. These orders slip into your revenue reports and your fulfillment queue as if they were real sales, and nobody notices until the books do not match the bank.

Order set to paid not by a charge No Stripe charge money never moved Counted as sale reports inflated Shipped unpaid
The status says paid, but no charge stands behind it. The order is counted as revenue and can be shipped even though the money never moved.

Why it happens

There are a handful of everyday ways an order ends up paid without a payment:

None of these leave an obvious mark. The order looks exactly like a real one. The only reliable way to tell them apart is to ask Stripe whether the payment truly happened. See the citations at the end for more on order statuses and verifying payments.

The key insight

A WooCommerce status is a claim. A Stripe charge is proof. When the two disagree, believe Stripe. Checking every paid order against its PaymentIntent turns a silent revenue leak into a short list you can actually review.

The fix, as a flow

We do not change good orders. We add a job that reads each recent paid order, finds the Stripe PaymentIntent id saved on it, and looks that up in Stripe. If the payment is missing, not succeeded, or the wrong amount, we add a clear note to the order so a human can review it. You can also choose to move flagged orders to on-hold.

Scheduled job daily audit List paid orders processing, completed Look up in Stripe by PaymentIntent id Charge good and matches? yes no leave it alone Flag for review
Only orders Stripe cannot confirm are flagged. Everything with a matching succeeded charge is left exactly as it was.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair with read and write access to orders. The write access is only used to add a note, or to move an order to on-hold if you turn that on. 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 REVIEW_HOLD="false"   # true also moves flagged orders to on-hold
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 REVIEW_HOLD="false"   // true also moves flagged orders to on-hold
export DRY_RUN="true"        // start safe, change to false to write notes
2

Find the saved PaymentIntent id

The WooCommerce Stripe plugin saves the PaymentIntent id on the order, usually in the meta field _stripe_intent_id, and often also as the order's transaction id. We read that id so we know which Stripe payment to check. If there is no PaymentIntent id at all, that alone is a reason to review the order.

step2.py
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
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

Decide, with one pure function

Keep the check in its own function that takes an order and the Stripe intent, which may be missing. An order is fine only if it is in a paid state, the intent exists and is succeeded, and the amount matches. Anything else on a paid order is a flag. Because the function is pure, we can test every branch without a network.

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

def order_amount_minor(order):
    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 ("flag", "no Stripe charge found for a paid order")
    if intent.get("status") != "succeeded":
        return ("flag", "Stripe shows the payment not succeeded")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("flag", "amount does not match the Stripe charge")
    return ("ok", "matches a succeeded Stripe charge")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);

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

export function decide(order, intent) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["flag", "no Stripe charge found for a paid order"];
  if (intent.status !== "succeeded") return ["flag", "Stripe shows the payment not succeeded"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["flag", "amount does not match the Stripe charge"];
  }
  return ["ok", "matches a succeeded Stripe charge"];
}
4

Flag the order for review

When the action is flag, add an order note that says what failed and asks a human to review. That is the safe default, because it never changes a good order. If you set REVIEW_HOLD on, the job also moves the order to on-hold so it will not ship while it waits for review. Notes and status go through the REST API, so HPOS is handled for you.

apply.py
def flag(order, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Payment check failed: {reason}. This order is marked paid but "
                      f"Stripe does not confirm a matching succeeded charge. Please review."},
        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()
apply.js
async function flag(order, reason) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Payment check failed: ${reason}. This order is marked paid but Stripe does ` +
            `not confirm a matching succeeded charge. Please review.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
  }
}
5

Wire it together with a dry run guard

The loop ties every piece together. On the first run, leave DRY_RUN on so the job only reports which orders it would flag. Read the output, confirm the flagged orders really are missing a charge, then switch it off. A daily run is plenty for an audit like this.

Run it safe

Start with DRY_RUN=true, and keep REVIEW_HOLD off until you trust the results. The default action is only a note, so a false positive costs nothing but a quick look. Turn on the hold once you are confident the check is right for your store.

The full code

Here is the complete audit in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is read only by default, so it is safe to run against a live store.

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

verify_paid.py
"""Flag WooCommerce orders marked paid that have no matching succeeded Stripe charge.
Read only by default. Run on a schedule.
"""
import os
import logging
import datetime
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

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"))
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):
    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):
    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 ("flag", "no Stripe charge found for a paid order")
    if intent.get("status") != "succeeded":
        return ("flag", "Stripe shows the payment not succeeded")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("flag", "amount does not match the Stripe charge")
    return ("ok", "matches a succeeded Stripe charge")


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():
    after = f"{datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    page = 1
    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}. This order is marked paid but "
                      f"Stripe does not confirm a matching succeeded charge. Please review."},
        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()
verify-paid.js
/**
 * Flag WooCommerce orders marked paid that have no matching succeeded Stripe charge.
 * Read only by default. Run on a schedule.
 */
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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);
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;
}

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

function decide(order, intent) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["flag", "no Stripe charge found for a paid order"];
  if (intent.status !== "succeeded") return ["flag", "Stripe shows the payment not succeeded"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["flag", "amount does not match the Stripe charge"];
  }
  return ["ok", "matches a succeeded Stripe charge"];
}

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}. This order is marked paid but Stripe does ` +
            `not confirm a matching succeeded charge. Please review.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
  }
}

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"}.`);
}

run().catch((err) => { console.error(err); process.exit(1); });

Add a test

The decision rule and the id lookup are the parts most worth testing, because they decide which orders get flagged. Because both are pure, the tests need no network and no Stripe account. They just feed in plain objects and check the result.

test_verify_decide.py
from verify_paid import decide, intent_id_of


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


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


def test_flag_when_no_intent():
    assert decide({"status": "completed", "total": "50.00"}, None)[0] == "flag"


def test_flag_when_intent_not_succeeded():
    assert decide({"status": "processing", "total": "50.00"}, intent(status="requires_payment_method"))[0] == "flag"


def test_flag_when_amount_mismatch():
    assert decide({"status": "processing", "total": "80.00"}, intent())[0] == "flag"


def test_skip_when_order_not_paid():
    assert decide({"status": "pending", "total": "50.00"}, 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"


def test_intent_id_none_when_transaction_is_a_charge():
    assert intent_id_of({"meta_data": [], "transaction_id": "ch_789"}) is None
verify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./verify-paid.js";

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

test("ok when paid and charge matches", () => {
  assert.equal(decide({ status: "processing", total: "50.00" }, intent())[0], "ok");
});

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

test("flag when intent not succeeded", () => {
  assert.equal(decide({ status: "processing", total: "50.00" }, intent({ status: "requires_payment_method" }))[0], "flag");
});

test("flag when amount mismatch", () => {
  assert.equal(decide({ status: "processing", total: "80.00" }, intent())[0], "flag");
});

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

test("intentIdOf null when transaction is a charge", () => {
  assert.equal(intentIdOf({ meta_data: [], transaction_id: "ch_789" }), null);
});

Case studies

Manual change

The status change that never got undone

A support agent set a stuck order to Processing so an anxious customer would stop worrying, planning to sort the payment later. The note to follow up got lost, the order shipped, and the store never collected. It sat in the revenue reports as a real sale for months.

The daily audit now catches this the moment it happens. Any paid order without a matching Stripe charge gets a note within a day, so a lost follow up turns into a quick review instead of a silent loss.

Bad import

The migration that invented sales

A store moved platforms and imported old orders with their statuses. A batch came in marked Completed even though those payments had never run through the new Stripe account, so the numbers looked far better than the bank.

The team ran the audit in dry run and got a clean list of every imported order with no real charge behind it. They reviewed the list, corrected the statuses, and their reports finally matched reality.

What good looks like

After this runs on a schedule, a paid status you can trust is one Stripe agrees with. Orders that were never really paid get surfaced within a day instead of hiding in your revenue for months, and the few that are flagged are a short, honest list a person can work through. Your reports and your bank start telling the same story.

FAQ

Why does my WooCommerce order show as paid when Stripe has no charge?

An order can be moved to Processing or Completed without a real payment: a manual status change, a plugin that marked it paid, or a checkout that was tampered with. The order looks paid in WooCommerce while Stripe has no matching succeeded charge. Checking each paid order against Stripe finds these.

How do I verify a WooCommerce order was really paid?

Read the Stripe PaymentIntent id saved on the order and look it up in Stripe. The order is genuinely paid only when Stripe shows that PaymentIntent as succeeded with an amount that matches the order total. If there is no id, or the payment is not succeeded, the order should be reviewed.

Is it safe to run this on a live store?

Yes. By default it is read only and just adds a note to any order it cannot confirm, so it never changes a good order. You can optionally have it move flagged orders to on-hold for review. Start in dry run mode to see the list first.

Related field notes

Citations

On the problem:

  1. WooCommerce docs: order statuses and what Processing and Completed mean. woocommerce.com/document/managing-orders/order-statuses
  2. WooCommerce docs: the Stripe gateway and the payment data it stores on orders. woocommerce.com/document/stripe
  3. Stripe docs: a PaymentIntent status is the source of truth for whether a payment succeeded. docs.stripe.com/payments/paymentintents/lifecycle

On the solution:

  1. Stripe API: retrieve a PaymentIntent by id. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: list orders filtered by status and date. woocommerce.github.io/woocommerce-rest-api-docs (list orders)
  3. WooCommerce REST API: add an order note and update an order. 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 tighten up your books?

If this found a sale that was never really paid, 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