Repair Charges and money

Partial refund gives back everything

A shop manager clicks refund for ten dollars on an eighty dollar order. A minute later the customer messages back confused, because Stripe just returned the entire eighty. Nobody touched the amount field twice. Nobody fat fingered a number. The order was captured outside the normal flow, and WooCommerce's refund math never knew that. Here is why it happens and a small script that catches the gap before it drains a payout.

Python and Node.js Runs on a schedule Safe by default (dry run)
A person holding a paper ticket
Photo by Kayvie on Unsplash
The short answer

WooCommerce sizes a refund against the order's stored total, not against what Stripe actually captured. When a PaymentIntent was captured for a different amount than the order total, a manual capture, a phone order finished outside checkout, or a partial capture, that stored total is wrong, and a small refund request can end up returning the whole remaining balance. Run a small Python or Node.js audit on a schedule that compares each order's recorded refund to Stripe's charge.amount_refunded and flags any order where Stripe gave back more than WooCommerce intended. It cannot undo the refund, but it catches the next one before it happens. Full code, tests, and a dry run guard are below.

The problem in plain words

Every refund box in wp-admin does the same quiet arithmetic. It looks at the order total, subtracts anything already refunded, and offers you the rest to refund. That number assumes the amount actually charged on Stripe equals the order total. Most of the time it does.

It does not hold for orders captured outside the usual checkout run. A manually captured PaymentIntent, a phone order rung up through a separate tool and then attached to the order, or a partial capture that only took part of the authorized amount, all of these can leave Stripe holding a different amount than the order says. When the refund request goes out with no amount, or with an amount the gateway thinks is a small slice but Stripe reads against its own smaller captured balance, Stripe can end up returning far more than anyone intended, sometimes the entire remaining charge.

Order total: $80 stored in WooCommerce Captured: $30 manual capture on Stripe Manager clicks refund intends to refund $10 sized against $80 Stripe refunds $30 the entire capture Customer gets it all back
WooCommerce sizes the refund against the order total. When Stripe actually captured a different amount, the request drains far more than the manager intended.

Why it happens

WooCommerce Payments and the WooCommerce Stripe gateway both trust the order's own total and its recorded refund history as the source of truth for "how much is left." Stripe, not WooCommerce, is the actual source of truth for what was captured and what remains. A few common ways these two numbers drift apart:

Community threads on the WooCommerce Stripe gateway describe exactly this pattern: a partial refund request that is confirmed in wp-admin, followed by the customer reporting the full charge back on their statement. See the citations at the end for the exact reports.

The key insight

A refund cannot be recalled once Stripe sends it. There is no code fix that pulls money back off a customer's card. The only real fix is to stop trusting the order total as "how much is left to refund" and instead check Stripe's own amount_refunded against what WooCommerce meant to send, so the next over refund gets caught before someone clicks the button, not after.

The fix, as a flow

We do not touch the refund flow itself, and we never ask Stripe to move money. We add a job that runs on a schedule, looks at orders that were refunded recently, and pulls the real Stripe charge behind each one. If Stripe's amount_refunded is meaningfully larger than what WooCommerce's own refund record says it asked for, we flag the order with a note so a person reviews it, and so the pattern gets caught on the very next order before it happens again.

Scheduled job every few hours List recently refunded orders Load the Stripe charge behind it Stripe matches what Woo intended? no, over yes, leave it Flag with a note no money touched
The audit only reads and reports. It never asks Stripe to move money and never edits the order's refund amount.

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 leave 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_HOURS="72"
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_HOURS="72"
export DRY_RUN="true"   // start safe, change to false to write notes
2

Find the PaymentIntent, not just the transaction ID

WooCommerce stores the Stripe reference in order meta under _stripe_intent_id, or sometimes only as the order's transaction_id. Either can hold a PaymentIntent ID or, on older orders, a charge ID directly. Check both so a real order is never skipped just because of where the ID happened to be saved.

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_") or tid.startswith("ch_")) 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.startsWith("ch_")) ? tid : null;
}
3

List orders that had a refund recorded recently

Ask the WooCommerce REST API for recent orders and keep the ones with total_refunded greater than zero. This is the group at risk, because these are the orders where someone already clicked refund and we want to check that Stripe agrees with what WooCommerce thinks happened.

step3.py
import time, requests
from requests.auth import HTTPBasicAuth

def recently_refunded_orders(lookback_hours):
    since = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - lookback_hours * 3600))
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "refunded,processing,completed", "after": since, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            if float(order.get("total_refunded") or 0) > 0:
                yield order
        page += 1
step3.js
async function* recentlyRefundedOrders(lookbackHours) {
  const since = new Date(Date.now() - lookbackHours * 3600 * 1000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(
      `/orders?status=refunded,processing,completed&after=${since}&per_page=50&page=${page}`
    );
    if (!batch.length) return;
    for (const order of batch) {
      if (parseFloat(order.total_refunded || 0) > 0) yield order;
    }
    page++;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order, the Stripe charge, the amount WooCommerce meant to refund, and the amount Stripe actually refunded, all in minor units (cents). It returns an action and the size of the gap. This is the part worth testing hardest, because it never touches money itself, it only reports.

decide.py
TOLERANCE_MINOR = 1

def woo_intended_refund_minor(order):
    return round(float(order.get("total_refunded") or 0) * 100)

def stripe_refunded_minor(charge):
    return int((charge or {}).get("amount_refunded") or 0)

def decide(order, charge, woo_intended_minor, stripe_refunded_minor_value):
    if charge is None:
        return ("orphan", "no matching Stripe charge for this order", 0)
    if woo_intended_minor <= 0:
        return ("skip", "no refund recorded on this order", 0)
    gap = stripe_refunded_minor_value - woo_intended_minor
    if gap > TOLERANCE_MINOR:
        return ("overrefund", "Stripe refunded more than WooCommerce intended", gap)
    if gap < -TOLERANCE_MINOR:
        return ("underrefund", "Stripe refunded less than WooCommerce intended", gap)
    return ("ok", "Stripe refund matches the intended amount", 0)
decide.js
const TOLERANCE_MINOR = 1;

export function wooIntendedRefundMinor(order) {
  return Math.round(parseFloat(order.total_refunded || 0) * 100);
}

export function stripeRefundedMinor(charge) {
  return (charge && charge.amount_refunded) || 0;
}

export function decide(order, charge, wooIntendedMinor, stripeRefundedMinorValue) {
  if (!charge) return ["orphan", "no matching Stripe charge for this order", 0];
  if (wooIntendedMinor <= 0) return ["skip", "no refund recorded on this order", 0];
  const gap = stripeRefundedMinorValue - wooIntendedMinor;
  if (gap > TOLERANCE_MINOR) return ["overrefund", "Stripe refunded more than WooCommerce intended", gap];
  if (gap < -TOLERANCE_MINOR) return ["underrefund", "Stripe refunded less than WooCommerce intended", gap];
  return ["ok", "Stripe refund matches the intended amount", 0];
}
5

Flag the order, never move money

When the action is overrefund, add an order note describing the gap in plain numbers. That is the entire write this script is allowed to do. It never calls Stripe's refund endpoint and never edits the order's refund amount, because both of those risk making a bad situation worse.

apply.py
def flag(order, reason, over_refund_minor):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": (
            f"Refund check: {reason}. Stripe returned "
            f"{over_refund_minor / 100:.2f} more than the WooCommerce refund record shows. "
            f"This usually means the order's captured amount differs from Stripe's actual "
            f"amount_captured (a manual or partial capture). Review before refunding this "
            f"order again."
        )},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function flag(order, reason, overRefundMinor) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Refund check: ${reason}. Stripe returned ${(overRefundMinor / 100).toFixed(2)} ` +
            `more than the WooCommerce refund record shows. This usually means the order's ` +
            `captured amount differs from Stripe's actual amount_captured (a manual or ` +
            `partial capture). Review before refunding this order again.`,
    }),
  });
}
6

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 logs what it would flag. Read the output, confirm the gaps are real by checking the Stripe dashboard, then switch it off to let it write notes. Run it every few hours with cron, since refunds are far less frequent than payments.

Run it safe

Always start with DRY_RUN=true. This script only ever reads from Stripe and writes an order note, it never calls a refund or capture endpoint, but you still want to see its findings before trusting them on a live store.

The full code

Here is the complete audit in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because it never asks Stripe or WooCommerce to move a single cent.

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

audit_refunds.py
"""Catch WooCommerce partial refunds that actually returned the whole charge.

On an order whose PaymentIntent was captured for less than the order total
(a manual capture, a phone order finished outside checkout, a split payment),
WooCommerce computes "amount left to refund" from the order total instead of
the real Stripe amount_captured. Ask for a small partial refund and the
gateway can send Stripe a refund with no amount, or an amount larger than
what is actually left, so Stripe refunds the entire remaining balance.

This script compares, per order, the refund the shop manager intended
(the WooCommerce refund line item) against what Stripe actually refunded on
the matching charge. When Stripe refunded more than intended, it writes an
order note flagging the gap. It never asks Stripe to move money and never
edits amounts. It only reports. Read only by default. Run on a schedule.

Guide: https://www.allanninal.dev/woocommerce/partial-refund-gives-back-everything/
"""
import os
import time
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("audit_refunds")

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_HOURS = int(os.environ.get("LOOKBACK_HOURS", "72"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Tolerance in cents for rounding noise between the two systems.
TOLERANCE_MINOR = 1


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_") or tid.startswith("ch_")) else None


def recently_refunded_orders(lookback_hours):
    """WooCommerce orders that had a refund recorded in the lookback window."""
    since = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - lookback_hours * 3600))
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "refunded,processing,completed", "after": since, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            if float(order.get("total_refunded") or 0) > 0:
                yield order
        page += 1


def woo_intended_refund_minor(order):
    """What the shop manager actually asked WooCommerce to refund, in cents."""
    return round(float(order.get("total_refunded") or 0) * 100)


def get_charge_for_intent(intent_id):
    """The Stripe Charge behind a PaymentIntent or charge id, or None if not found."""
    if not intent_id:
        return None
    try:
        if intent_id.startswith("ch_"):
            return stripe.Charge.retrieve(intent_id)
        intent = stripe.PaymentIntent.retrieve(intent_id, expand=["latest_charge"])
        return intent.get("latest_charge")
    except stripe.error.InvalidRequestError:
        return None


def stripe_refunded_minor(charge):
    """What Stripe actually returned to the card for this charge, in cents."""
    return int((charge or {}).get("amount_refunded") or 0)


def decide(order, charge, woo_intended_minor, stripe_refunded_minor_value):
    """Pure decision: compare the intended refund to what Stripe actually moved.

    order: dict with at least "id" and "status".
    charge: dict-like Stripe Charge, or None if it could not be found.
    woo_intended_minor: cents the WooCommerce refund record says was refunded.
    stripe_refunded_minor_value: cents Stripe's charge.amount_refunded reports.

    Returns (action, reason, over_refund_minor).
    """
    if charge is None:
        return ("orphan", "no matching Stripe charge for this order", 0)
    if woo_intended_minor <= 0:
        return ("skip", "no refund recorded on this order", 0)
    gap = stripe_refunded_minor_value - woo_intended_minor
    if gap > TOLERANCE_MINOR:
        return ("overrefund", "Stripe refunded more than WooCommerce intended", gap)
    if gap < -TOLERANCE_MINOR:
        return ("underrefund", "Stripe refunded less than WooCommerce intended", gap)
    return ("ok", "Stripe refund matches the intended amount", 0)


def flag(order, reason, over_refund_minor):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": (
            f"Refund check: {reason}. Stripe returned "
            f"{over_refund_minor / 100:.2f} more than the WooCommerce refund record shows. "
            f"This usually means the order's captured amount differs from Stripe's actual "
            f"amount_captured (a manual or partial capture). Review before refunding this "
            f"order again."
        )},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    flagged = 0
    for order in recently_refunded_orders(LOOKBACK_HOURS):
        intent_id = intent_id_of(order)
        charge = get_charge_for_intent(intent_id)
        woo_intended = woo_intended_refund_minor(order)
        stripe_refunded = stripe_refunded_minor(charge)
        action, reason, over_refund_minor = decide(order, charge, woo_intended, stripe_refunded)
        if action == "orphan":
            log.warning("Order %s has a refund but no matching Stripe charge (%s)", order["id"], intent_id)
            continue
        if action in ("skip", "ok", "underrefund"):
            continue
        log.warning(
            "Order %s: %s. Woo intended %sc, Stripe refunded %sc. %s",
            order["id"], reason, woo_intended, stripe_refunded,
            "would flag" if DRY_RUN else "flagging",
        )
        if not DRY_RUN:
            flag(order, reason, over_refund_minor)
        flagged += 1
    log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
audit-refunds.js
/**
 * Catch WooCommerce partial refunds that actually returned the whole charge.
 *
 * On an order whose PaymentIntent was captured for less than the order total
 * (a manual capture, a phone order finished outside checkout, a split payment),
 * WooCommerce computes "amount left to refund" from the order total instead of
 * the real Stripe amount_captured. Ask for a small partial refund and the
 * gateway can send Stripe a refund with no amount, or an amount larger than
 * what is actually left, so Stripe refunds the entire remaining balance.
 *
 * This script compares, per order, the refund the shop manager intended
 * (the WooCommerce refund line item) against what Stripe actually refunded on
 * the matching charge. When Stripe refunded more than intended, it writes an
 * order note flagging the gap. It never asks Stripe to move money and never
 * edits amounts. It only reports. Read only by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/partial-refund-gives-back-everything/
 */
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_HOURS = Number(process.env.LOOKBACK_HOURS || 72);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Tolerance in cents for rounding noise between the two systems.
const TOLERANCE_MINOR = 1;

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.startsWith("ch_")) ? tid : null;
}

export function wooIntendedRefundMinor(order) {
  return Math.round(parseFloat(order.total_refunded || 0) * 100);
}

export function stripeRefundedMinor(charge) {
  return (charge && charge.amount_refunded) || 0;
}

/**
 * Pure decision: compare the intended refund to what Stripe actually moved.
 *
 * order: object with at least "id" and "status".
 * charge: Stripe Charge-shaped object, or null if it could not be found.
 * wooIntendedMinor: cents the WooCommerce refund record says was refunded.
 * stripeRefundedMinorValue: cents Stripe's charge.amount_refunded reports.
 *
 * Returns [action, reason, overRefundMinor].
 */
export function decide(order, charge, wooIntendedMinor, stripeRefundedMinorValue) {
  if (!charge) return ["orphan", "no matching Stripe charge for this order", 0];
  if (wooIntendedMinor <= 0) return ["skip", "no refund recorded on this order", 0];
  const gap = stripeRefundedMinorValue - wooIntendedMinor;
  if (gap > TOLERANCE_MINOR) return ["overrefund", "Stripe refunded more than WooCommerce intended", gap];
  if (gap < -TOLERANCE_MINOR) return ["underrefund", "Stripe refunded less than WooCommerce intended", gap];
  return ["ok", "Stripe refund matches the intended amount", 0];
}

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 getChargeForIntent(intentId) {
  if (!intentId) return null;
  try {
    if (intentId.startsWith("ch_")) return await stripe.charges.retrieve(intentId);
    const intent = await stripe.paymentIntents.retrieve(intentId, { expand: ["latest_charge"] });
    return intent.latest_charge || null;
  } catch {
    return null;
  }
}

async function* recentlyRefundedOrders(lookbackHours) {
  const since = new Date(Date.now() - lookbackHours * 3600 * 1000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(
      `/orders?status=refunded,processing,completed&after=${since}&per_page=50&page=${page}`
    );
    if (!batch.length) return;
    for (const order of batch) {
      if (parseFloat(order.total_refunded || 0) > 0) yield order;
    }
    page++;
  }
}

async function flag(order, reason, overRefundMinor) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Refund check: ${reason}. Stripe returned ${(overRefundMinor / 100).toFixed(2)} ` +
            `more than the WooCommerce refund record shows. This usually means the order's ` +
            `captured amount differs from Stripe's actual amount_captured (a manual or ` +
            `partial capture). Review before refunding this order again.`,
    }),
  });
}

export async function run() {
  let flagged = 0;
  for await (const order of recentlyRefundedOrders(LOOKBACK_HOURS)) {
    const intentId = intentIdOf(order);
    const charge = await getChargeForIntent(intentId);
    const wooIntended = wooIntendedRefundMinor(order);
    const stripeRefunded = stripeRefundedMinor(charge);
    const [action, reason, overRefundMinor] = decide(order, charge, wooIntended, stripeRefunded);
    if (action === "orphan") {
      console.warn(`Order ${order.id} has a refund but no matching Stripe charge (${intentId})`);
      continue;
    }
    if (action === "skip" || action === "ok" || action === "underrefund") continue;
    console.warn(
      `Order ${order.id}: ${reason}. Woo intended ${wooIntended}c, Stripe refunded ${stripeRefunded}c. ` +
      `${DRY_RUN ? "would flag" : "flagging"}`
    );
    if (!DRY_RUN) await flag(order, reason, overRefundMinor);
    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 flagged for a human to review. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects, in cents, and checks the action.

test_partial_refund_decide.py
from audit_refunds import decide


def charge(**over):
    base = {"amount_refunded": 1000}
    base.update(over)
    return base


def test_overrefund_when_stripe_returned_more_than_intended():
    order = {"id": 1, "status": "processing"}
    action, reason, gap = decide(order, charge(amount_refunded=5000), 1000, 5000)
    assert action == "overrefund"
    assert gap == 4000


def test_ok_when_amounts_match():
    order = {"id": 2, "status": "processing"}
    action, reason, gap = decide(order, charge(amount_refunded=1000), 1000, 1000)
    assert action == "ok"


def test_orphan_when_no_charge_found():
    order = {"id": 5, "status": "processing"}
    action, reason, gap = decide(order, None, 1000, 0)
    assert action == "orphan"


def test_skip_when_no_refund_recorded():
    order = {"id": 6, "status": "processing"}
    action, reason, gap = decide(order, charge(amount_refunded=0), 0, 0)
    assert action == "skip"
audit-refunds.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./audit-refunds.js";

const charge = (over = {}) => ({ amount_refunded: 1000, ...over });

test("overrefund when Stripe returned more than intended", () => {
  const [action, , gap] = decide({ id: 1, status: "processing" }, charge({ amount_refunded: 5000 }), 1000, 5000);
  assert.equal(action, "overrefund");
  assert.equal(gap, 4000);
});

test("ok when amounts match", () => {
  const [action] = decide({ id: 2, status: "processing" }, charge({ amount_refunded: 1000 }), 1000, 1000);
  assert.equal(action, "ok");
});

test("orphan when no charge found", () => {
  const [action] = decide({ id: 5, status: "processing" }, null, 1000, 0);
  assert.equal(action, "orphan");
});

test("skip when no refund recorded", () => {
  const [action] = decide({ id: 6, status: "processing" }, charge({ amount_refunded: 0 }), 0, 0);
  assert.equal(action, "skip");
});

Case studies

Manual capture

The $10 refund that emptied a $200 order

A store used manual capture so warehouse staff could confirm stock before charging the card. One order was only captured for $40 of a $200 authorization once part of the order was out of stock. A manager later issued a $10 refund for a damaged item.

WooCommerce sized the refund against the $200 total, but Stripe only had $40 captured, so the refund request returned the entire $40. The audit flagged the order the same night, and the team switched to checking Stripe's captured amount before refunding manual capture orders.

Phone order

The support agent who refunded a shipping fee

A customer called in and support processed the payment directly in Stripe, then created a matching WooCommerce order and pasted in the PaymentIntent ID by hand. The order total in WooCommerce did not match what was actually charged.

When a $5 shipping refund was requested weeks later, Stripe returned the full remaining balance instead. The audit caught it on its next scheduled run, well before the monthly payout reconciliation would have, and the team added a step to verify captured amounts for any order created this way.

What good looks like

After this runs on a schedule, an over refund stops being a silent surprise on next month's payout report. It becomes a same-day order note that says exactly which order, how much extra went out, and why. You still cannot get the money back from the customer's card, but you catch the pattern immediately and can stop trusting the WooCommerce refund box on any order that was captured outside the normal checkout flow.

FAQ

Why did a small refund return the customer's entire payment?

WooCommerce works out how much is left to refund using the order total, not Stripe's real captured amount. If the order was captured for a different amount than the total, for example a manual capture or a phone order finished outside checkout, that math is wrong. A small refund request can end up asking Stripe to return the whole remaining balance.

Can this script undo an over refund?

No, and it should not try to. Once Stripe returns money it is gone, a script cannot pull it back from the customer's card. The audit only compares WooCommerce's refund record to Stripe's charge and flags the order with a note so a person can decide what to do next, like collecting a new payment if that is appropriate.

How do I know if an order is at risk before refunding it?

Compare the order total in WooCommerce to the PaymentIntent's amount_captured in Stripe before you click refund. If they do not match, the order was captured outside the normal checkout flow and any refund on it should be sized and confirmed in the Stripe dashboard directly, not trusted to the WooCommerce refund box.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe gateway support thread: a partial refund in wp-admin returned the full charge to the customer. wordpress.org/support/plugin/woocommerce-gateway-stripe
  2. Stripe docs: manual capture and partial capture change how much of an authorization is ever charged. docs.stripe.com/payments/capture-later
  3. WooCommerce docs: how refunds are recorded against an order and its line items. woocommerce.com/document/managing-orders

On the solution:

  1. Stripe API: the Charge object, including amount_captured and amount_refunded. docs.stripe.com/api/charges/object
  2. Stripe API: retrieve a PaymentIntent and expand its latest_charge. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce REST API: orders, refund totals, and order notes. 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 catch an over refund for you?

If this saved you from a surprise on your payout report or a confused customer email, 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