Diagnostic Refunds and disputes
Refund and dispute double reversal
You already refunded the order. The buyer got their money back, the WooCommerce order says Refunded, and the case looked closed. Weeks later the same buyer disputes the same charge with their bank, and Stripe pulls the money out again, plus a dispute fee. Your balance is now down twice for one sale, and because refunds and disputes live in different reports, almost no one catches it. Here is why it happens and a small script that measures exactly how much you lost.
A charge can be refunded and later disputed. Those are two separate ledger events in Stripe, so if the buyer opens a dispute after you already sent their money back, Stripe withdraws the disputed amount a second time and adds a dispute fee. WooCommerce has no idea this happened, because disputes are not part of the normal order flow. Run a small Python or Node.js script on a schedule that lists Stripe disputes, checks each dispute's charge for an existing full refund, and reports every case where the same money left your account twice. Full code, tests, and a dry run guard are below.
The problem in plain words
A refund and a dispute both move money out of your Stripe balance, but they are not aware of each other. A refund is something you chose to do, usually because the buyer asked for it or the order was cancelled. A dispute is something the buyer's bank does on its own, when the buyer tells the bank they never got what they paid for, or never agreed to the charge at all.
If the buyer disputes a charge you already refunded, maybe out of confusion, maybe after forgetting the refund landed, maybe on purpose, Stripe still processes the dispute like any other. It debits the disputed amount from your balance and adds a fixed dispute fee, usually around fifteen dollars, regardless of the fact that the refund already sent the buyer their money. The order in WooCommerce still says Refunded. Nothing in the store flags that the same charge was hit twice.
Why it happens
Stripe's own docs describe refunds and disputes as independent objects that both attach to a charge, but neither one checks the state of the other before moving money. A few reasons this keeps slipping past stores:
- The buyer opens the dispute with their bank, not with your store, so you get no warning before the withdrawal happens. The first sign is often a lower payout.
- WooCommerce order notes record the refund, but disputes usually arrive through a separate Stripe webhook that many stores never wire up, so the order page never shows anything changed.
- A refund can be full or partial, and a dispute later covers the full original charge amount, so even someone reading the numbers casually may not notice the overlap.
- The buyer may genuinely not remember getting the refund, may have disputed out of habit after seeing "pending" on their statement, or in the worst case is intentionally trying to get paid twice.
Stripe's disputes documentation is explicit that a dispute fee is charged when a dispute is created regardless of the eventual outcome, and that winning a dispute returns the disputed funds but not the fee. See the citations at the end for the exact pages.
A charge that is both fully refunded and disputed is not a normal chargeback, it is a double reversal. The loss is not the dispute amount alone, it is the dispute amount plus the fee, on top of a refund you already paid for. A script that lists disputes and checks each one against the refund history on its charge is the only reliable way to catch every case, because nothing inside WooCommerce ever flags it on its own.
The fix, as a flow
We do not try to stop disputes, and we do not touch WooCommerce order status automatically, since disputes are decided by the bank and by the evidence you submit, not by a script. Instead we add a job that runs on a schedule, lists recent Stripe disputes, pulls the refund history for each disputed charge, and reports the exact ones where a refund already happened before the dispute. For each match it computes the amount that left your account twice, in cents, so nothing gets lost to rounding.
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 and write access to orders. 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="30"
export DRY_RUN="true" # start safe, change to false to write a note
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="30"
export DRY_RUN="true" // start safe, change to false to write a note
List recent disputes from Stripe
Ask Stripe for disputes created in your lookback window. We page through all of them. Each dispute object already carries the charge ID and the disputed amount, in cents, so we do not need to guess currency conversions.
import os, time, stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def recent_disputes(lookback_days):
since = int(time.time()) - lookback_days * 86400
for dispute in stripe.Dispute.list(limit=100, created={"gte": since}).auto_paging_iter():
yield dispute
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function* recentDisputes(lookbackDays) {
const since = Math.floor(Date.now() / 1000) - lookbackDays * 86400;
for await (const dispute of stripe.disputes.list({ limit: 100, created: { gte: since } })) {
yield dispute;
}
}
Load the charge and its refunds
For each dispute, retrieve the charge with its refund list expanded. We sum the refunds already issued against that charge before the dispute was created. This tells us whether the buyer's money had already gone back before the bank got involved.
def refunded_before(charge, cutoff_ts):
"""Total minor units refunded on this charge before cutoff_ts."""
total = 0
for refund in charge.get("refunds", {}).get("data", []):
if refund["status"] == "succeeded" and refund["created"] <= cutoff_ts:
total += refund["amount"]
return total
def get_charge_with_refunds(charge_id):
return stripe.Charge.retrieve(charge_id, expand=["refunds"])
function refundedBefore(charge, cutoffTs) {
// Total minor units refunded on this charge before cutoffTs.
let total = 0;
for (const refund of (charge.refunds && charge.refunds.data) || []) {
if (refund.status === "succeeded" && refund.created <= cutoffTs) total += refund.amount;
}
return total;
}
async function getChargeWithRefunds(chargeId) {
return stripe.charges.retrieve(chargeId, { expand: ["refunds"] });
}
Decide, with one pure function
Keep the decision in its own function that takes the dispute and the refunded-before amount, both in cents, and returns an action plus the loss. A pure function like this is easy to read and easy to test, which we do later. The rule: if nothing was refunded before the dispute, it is a normal dispute, leave it alone. If some or all of the charge was already refunded, the smaller of the refunded amount and the disputed amount is money that left twice.
def decide(dispute_amount, refunded_before_amount, dispute_fee=1500):
"""All amounts in minor units (cents). dispute_fee defaults to Stripe's
typical flat fee; pass the real fee from the dispute's balance transaction
when you have it.
"""
if refunded_before_amount <= 0:
return ("skip", "no refund existed before this dispute", 0)
overlap = min(dispute_amount, refunded_before_amount)
loss = overlap + dispute_fee
return ("double_reversal", "charge was refunded before the dispute", loss)
export function decide(disputeAmount, refundedBeforeAmount, disputeFee = 1500) {
// All amounts in minor units (cents). disputeFee defaults to Stripe's
// typical flat fee; pass the real fee from the dispute's balance
// transaction when you have it.
if (refundedBeforeAmount <= 0) {
return ["skip", "no refund existed before this dispute", 0];
}
const overlap = Math.min(disputeAmount, refundedBeforeAmount);
const loss = overlap + disputeFee;
return ["double_reversal", "charge was refunded before the dispute", loss];
}
Find the WooCommerce order and record the loss
Read the PaymentIntent ID off the charge, then look it up against order meta _stripe_intent_id, falling back to transaction_id when it starts with pi_. We never change the order status automatically, since only a person should decide whether to submit dispute evidence or write the loss off. We only add a note with the exact amount, in the store's currency, so finance can see it without digging through the Stripe dashboard.
def find_order_by_intent(intent_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"search": intent_id, "per_page": 5},
auth=AUTH, timeout=30,
)
r.raise_for_status()
for order in r.json():
for meta in order.get("meta_data", []):
if meta.get("key") == "_stripe_intent_id" and meta.get("value") == intent_id:
return order
if order.get("transaction_id") == intent_id:
return order
return None
def record_loss(order_id, dispute_id, loss_cents, currency):
note = (
f"Double reversal detected. Dispute {dispute_id} withdrew money on a charge "
f"that was already refunded. Estimated extra loss: {loss_cents / 100:.2f} {currency.upper()}. "
f"Review and submit evidence if the refund predates the dispute."
)
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": note}, auth=AUTH, timeout=30,
).raise_for_status()
async function findOrderByIntent(intentId) {
const orders = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
for (const order of orders) {
const hit = (order.meta_data || []).some(
(m) => m.key === "_stripe_intent_id" && m.value === intentId
);
if (hit || order.transaction_id === intentId) return order;
}
return null;
}
async function recordLoss(orderId, disputeId, lossCents, currency) {
const note =
`Double reversal detected. Dispute ${disputeId} withdrew money on a charge ` +
`that was already refunded. Estimated extra loss: ${(lossCents / 100).toFixed(2)} ${currency.toUpperCase()}. ` +
`Review and submit evidence if the refund predates the dispute.`;
await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}
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 prints its findings and totals up the loss across the whole lookback window. Read the report, confirm it against a few disputes by hand in the Stripe dashboard, then switch it off to let it write notes. Run it once a day, since disputes take time to appear and there is no rush to react within minutes.
Always start with DRY_RUN=true. This script never changes an order's status and never touches Stripe's money, it only reports what it finds and, once you turn dry run off, adds an order note. There is no undo needed because it never reverses anything itself.
The full code
Here is the complete check 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 only reads from Stripe and only adds a note in WooCommerce.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find charges where a refund and a dispute both pulled money out.
A charge can be refunded by the store and later disputed by the buyer's bank.
Those are two separate withdrawals in Stripe, so the same sale can be paid for
twice by the merchant: once through the refund, once through the dispute plus
its fee. This walks recent disputes, checks each charge's refund history, and
reports every case where money left the account twice. Read only by default.
Run on a schedule.
"""
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("refund_dispute_double_reversal")
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", "30"))
DEFAULT_DISPUTE_FEE_CENTS = int(os.environ.get("DEFAULT_DISPUTE_FEE_CENTS", "1500"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def recent_disputes(lookback_days):
since = int(time.time()) - lookback_days * 86400
for dispute in stripe.Dispute.list(limit=100, created={"gte": since}).auto_paging_iter():
yield dispute
def get_charge_with_refunds(charge_id):
return stripe.Charge.retrieve(charge_id, expand=["refunds"])
def refunded_before(charge, cutoff_ts):
"""Total minor units refunded on this charge before cutoff_ts."""
total = 0
for refund in charge.get("refunds", {}).get("data", []):
if refund["status"] == "succeeded" and refund["created"] <= cutoff_ts:
total += refund["amount"]
return total
def decide(dispute_amount, refunded_before_amount, dispute_fee=DEFAULT_DISPUTE_FEE_CENTS):
"""All amounts in minor units (cents)."""
if refunded_before_amount <= 0:
return ("skip", "no refund existed before this dispute", 0)
overlap = min(dispute_amount, refunded_before_amount)
loss = overlap + dispute_fee
return ("double_reversal", "charge was refunded before the dispute", loss)
def find_order_by_intent(intent_id):
if not intent_id:
return None
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"search": intent_id, "per_page": 5},
auth=AUTH, timeout=30,
)
r.raise_for_status()
for order in r.json():
for meta in order.get("meta_data", []):
if meta.get("key") == "_stripe_intent_id" and meta.get("value") == intent_id:
return order
if order.get("transaction_id") == intent_id:
return order
return None
def record_loss(order_id, dispute_id, loss_cents, currency):
note = (
f"Double reversal detected. Dispute {dispute_id} withdrew money on a charge "
f"that was already refunded. Estimated extra loss: {loss_cents / 100:.2f} {currency.upper()}. "
f"Review and submit evidence if the refund predates the dispute."
)
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
total_loss_cents = 0
for dispute in recent_disputes(LOOKBACK_DAYS):
charge_id = dispute["charge"]
charge = get_charge_with_refunds(charge_id)
refunded = refunded_before(charge, dispute["created"])
action, reason, loss = decide(dispute["amount"], refunded)
if action == "skip":
continue
intent_id = charge.get("payment_intent")
order = find_order_by_intent(intent_id)
order_id = order["id"] if order else None
log.warning(
"Charge %s: %s. Extra loss %.2f %s. %s",
charge_id, reason, loss / 100, dispute["currency"].upper(),
"would record" if DRY_RUN else "recording",
)
if not DRY_RUN and order_id:
record_loss(order_id, dispute["id"], loss, dispute["currency"])
flagged += 1
total_loss_cents += loss
log.info(
"Done. %d double reversal(s) found, total extra loss %.2f.",
flagged, total_loss_cents / 100,
)
if __name__ == "__main__":
run()
/**
* Find charges where a refund and a dispute both pulled money out.
*
* A charge can be refunded by the store and later disputed by the buyer's
* bank. Those are two separate withdrawals in Stripe, so the same sale can be
* paid for twice by the merchant: once through the refund, once through the
* dispute plus its fee. This walks recent disputes, checks each charge's
* refund history, and reports every case where money left the account twice.
* 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 || 30);
const DEFAULT_DISPUTE_FEE_CENTS = Number(process.env.DEFAULT_DISPUTE_FEE_CENTS || 1500);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
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* recentDisputes(lookbackDays) {
const since = Math.floor(Date.now() / 1000) - lookbackDays * 86400;
for await (const dispute of stripe.disputes.list({ limit: 100, created: { gte: since } })) {
yield dispute;
}
}
async function getChargeWithRefunds(chargeId) {
return stripe.charges.retrieve(chargeId, { expand: ["refunds"] });
}
export function refundedBefore(charge, cutoffTs) {
// Total minor units refunded on this charge before cutoffTs.
let total = 0;
for (const refund of (charge.refunds && charge.refunds.data) || []) {
if (refund.status === "succeeded" && refund.created <= cutoffTs) total += refund.amount;
}
return total;
}
export function decide(disputeAmount, refundedBeforeAmount, disputeFee = DEFAULT_DISPUTE_FEE_CENTS) {
// All amounts in minor units (cents).
if (refundedBeforeAmount <= 0) {
return ["skip", "no refund existed before this dispute", 0];
}
const overlap = Math.min(disputeAmount, refundedBeforeAmount);
const loss = overlap + disputeFee;
return ["double_reversal", "charge was refunded before the dispute", loss];
}
async function findOrderByIntent(intentId) {
if (!intentId) return null;
const orders = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
for (const order of orders) {
const hit = (order.meta_data || []).some(
(m) => m.key === "_stripe_intent_id" && m.value === intentId
);
if (hit || order.transaction_id === intentId) return order;
}
return null;
}
async function recordLoss(orderId, disputeId, lossCents, currency) {
const note =
`Double reversal detected. Dispute ${disputeId} withdrew money on a charge ` +
`that was already refunded. Estimated extra loss: ${(lossCents / 100).toFixed(2)} ${currency.toUpperCase()}. ` +
`Review and submit evidence if the refund predates the dispute.`;
await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}
export async function run() {
let flagged = 0;
let totalLossCents = 0;
for await (const dispute of recentDisputes(LOOKBACK_DAYS)) {
const chargeId = dispute.charge;
const charge = await getChargeWithRefunds(chargeId);
const refunded = refundedBefore(charge, dispute.created);
const [action, reason, loss] = decide(dispute.amount, refunded);
if (action === "skip") continue;
const intentId = charge.payment_intent;
const order = await findOrderByIntent(intentId);
const orderId = order ? order.id : null;
console.warn(
`Charge ${chargeId}: ${reason}. Extra loss ${(loss / 100).toFixed(2)} ${dispute.currency.toUpperCase()}. ` +
`${DRY_RUN ? "would record" : "recording"}`
);
if (!DRY_RUN && orderId) await recordLoss(orderId, dispute.id, loss, dispute.currency);
flagged++;
totalLossCents += loss;
}
console.log(`Done. ${flagged} double reversal(s) found, total extra loss ${(totalLossCents / 100).toFixed(2)}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decision rule is the part most worth testing, because it decides how much loss gets reported and eventually how a chargeback gets disputed with your bank. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain numbers, in cents, and checks the action and the loss.
from refund_dispute_double_reversal import decide
def test_skip_when_no_prior_refund():
action, reason, loss = decide(5000, 0)
assert action == "skip"
assert loss == 0
def test_double_reversal_when_fully_refunded_first():
action, reason, loss = decide(5000, 5000, dispute_fee=1500)
assert action == "double_reversal"
assert loss == 6500
def test_double_reversal_uses_smaller_of_dispute_and_refund():
action, reason, loss = decide(5000, 2000, dispute_fee=1500)
assert action == "double_reversal"
assert loss == 3500
def test_default_dispute_fee_is_applied():
action, reason, loss = decide(3000, 3000)
assert loss == 3000 + 1500
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./refund-dispute-double-reversal.js";
test("skip when no prior refund", () => {
const [action, , loss] = decide(5000, 0);
assert.equal(action, "skip");
assert.equal(loss, 0);
});
test("double reversal when fully refunded first", () => {
const [action, , loss] = decide(5000, 5000, 1500);
assert.equal(action, "double_reversal");
assert.equal(loss, 6500);
});
test("double reversal uses smaller of dispute and refund", () => {
const [action, , loss] = decide(5000, 2000, 1500);
assert.equal(action, "double_reversal");
assert.equal(loss, 3500);
});
test("default dispute fee is applied", () => {
const [, , loss] = decide(3000, 3000);
assert.equal(loss, 3000 + 1500);
});
Case studies
The buyer who disputed a refund they already got
A customer asked for a refund on a damaged item, got it within a day, then three weeks later disputed the same charge with their bank, apparently not connecting the two. Stripe pulled the full amount out again plus the dispute fee. The order still read Refunded, so support had no reason to look twice.
Running the check across the last ninety days surfaced the charge immediately, with the refund timestamp sitting well before the dispute. The store submitted the refund receipt as evidence and won the dispute, recovering the second withdrawal, though not the fee.
A pattern across five separate orders
One buyer, using different cards, had five orders over two months where each was refunded through the standard return process and then disputed weeks later for the full amount. Each one alone looked like a normal, if unlucky, dispute.
The script's totals view added up over eleven hundred dollars in loss across those five charges once fees were included. Seeing them listed together was what made the pattern obvious enough to submit evidence on all five at once and flag the customer for review on future orders.
After this runs on a schedule, a double reversal stops being an invisible line in your Stripe payout and becomes a note on the order with an exact number attached. You will not stop every dispute, but you will always know when one landed on money you had already sent back, and you will have the evidence lined up before the response deadline passes.
FAQ
Why did Stripe take money for a charge I already refunded?
The buyer disputed the same charge with their bank after you had already refunded it. Stripe treats the refund and the dispute as two separate events, so it withdraws the disputed amount plus a dispute fee from your balance even though the money was already sent back. Nobody notices unless you compare refunds against disputes on the same charge.
Can I get the second withdrawal back?
Sometimes. If you can show the bank or Stripe that the charge was already refunded before the dispute, you can submit that as evidence and the dispute is often decided in your favor, which returns the disputed amount. The dispute fee is usually not refunded even when you win. Acting fast on the evidence deadline matters more than anything else.
How do I find every charge this happened to across a whole store?
List disputes from Stripe, then for each one check whether its charge already has a full refund recorded. A charge that is both refunded and disputed is a double reversal candidate. A script that does this on a schedule catches it far sooner than someone spotting it in the Stripe balance report by hand.
Related field notes
Citations
On the problem:
- Stripe docs: how disputes work, including the flat dispute fee charged regardless of outcome. docs.stripe.com/disputes/how-disputes-work
- Stripe docs: refunds, including how a charge can carry one or more separate refund objects. docs.stripe.com/refunds
- Stripe support: what happens to the dispute fee when you win a dispute. support.stripe.com/questions/dispute-fees
On the solution:
- Stripe API: list disputes and retrieve a charge with its refunds expanded. docs.stripe.com/api/disputes/list
- Stripe docs: responding to disputes and submitting evidence before the deadline. docs.stripe.com/disputes/responding
- WooCommerce REST API: search 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 loss you never booked?
If this surfaced a double reversal you would have otherwise missed, 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