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.

Python and Node.js Runs on a schedule Read only by default (dry run)
White printer paper on a white surface
Photo by Rayia Soderberg on Unsplash
The short answer

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.

Charge succeeds order paid in full Store refunds it money returned once Order marked Refunded in WooCommerce weeks later, buyer disputes it Same charge disputed with the bank Money out again + dispute fee
The refund and the dispute each pull money out on their own. Nothing links them, so the second withdrawal on top of the refund goes unnoticed.

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:

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.

The key insight

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.

Scheduled job once a day List disputes from Stripe Load the charge and its refund list Already fully refunded? yes no, normal dispute Record the loss note + cents amount
The check reads the truth from Stripe and only reports charges that were already refunded before the dispute landed. A plain dispute with no prior refund is left alone.

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

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="30"
export DRY_RUN="true"   # start safe, change to false to write a note
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="30"
export DRY_RUN="true"   // start safe, change to false to write a note
2

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.

step2.py
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
step2.js
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;
  }
}
3

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.

step3.py
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"])
step3.js
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"] });
}
4

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.

decide.py
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)
decide.js
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];
}
5

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.

apply.py
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()
apply.js
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 }) });
}
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 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.

Run it safe

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.

refund_dispute_double_reversal.py
"""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()
refund-dispute-double-reversal.js
/**
 * 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.

test_reversal_decide.py
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
reversal-decide.test.js
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

Forgotten refund

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.

Friendly fraud

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.

What good looks like

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:

  1. Stripe docs: how disputes work, including the flat dispute fee charged regardless of outcome. docs.stripe.com/disputes/how-disputes-work
  2. Stripe docs: refunds, including how a charge can carry one or more separate refund objects. docs.stripe.com/refunds
  3. Stripe support: what happens to the dispute fee when you win a dispute. support.stripe.com/questions/dispute-fees

On the solution:

  1. Stripe API: list disputes and retrieve a charge with its refunds expanded. docs.stripe.com/api/disputes/list
  2. Stripe docs: responding to disputes and submitting evidence before the deadline. docs.stripe.com/disputes/responding
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes