Repair Refunds and disputes

Stripe refunds on non-card methods never mark the order Refunded

You refunded an order paid with iDEAL, EPS, giropay, or SEPA, and the money went back on Stripe. But the WooCommerce order still shows its full total and never moved to Refunded. The webhook arrived, it just skipped the order because the handler only recognised plain card payments. This guide finds the orders whose refund was silently dropped and records it, then marks the fully refunded ones Refunded.

Python and Node.js One off repair Never refunds twice
20 euro bill on white printer paper
Photo by Ibrahim Boran on Unsplash
The short answer

A plugin bug matched the payment method as exactly stripe, but alternative methods are stored as stripe_ideal, stripe_eps, stripe_sepa_debit, and so on, so their refunds were skipped. Find orders paid with a stripe_ alternative method, compare the refund on the Stripe charge against what WooCommerce recorded, record the difference with api_refund false, and set fully refunded orders to Refunded. The full code is below.

The problem in plain words

Stripe supports more than cards. Shoppers in Europe often pay with iDEAL, giropay, EPS, or SEPA direct debit. WooCommerce stores each of those as its own payment method name, like stripe_ideal or stripe_sepa_debit, rather than plain stripe.

When one of these orders is refunded, Stripe sends the same refund webhook it sends for cards. The bug was that the handler only acted when the payment method was exactly stripe. For every alternative method the event arrived and was thrown away, so the order kept its full total and never showed as refunded, even though the customer got their money back.

Refund iDEAL order money returned Handler checks method == "stripe" only No match stripe_ideal skipped Full total not refunded
The refund happened on Stripe. The order never reflects it because the method name did not match.

Why it happens

The WooCommerce Stripe plugin has a reported bug where the charge.refunded handler compared the payment method with a strict equals to stripe, which never matches the alternative method names. The refund is received and then silently dropped. The citations at the end link the thread.

The key insight

The refund is real and complete on Stripe. Only the WooCommerce record is missing. WooCommerce can record a refund without contacting Stripe again by creating it with api_refund set to false. That lets us write in the missing refund for reporting, and mark the order Refunded when the charge is fully refunded, without ever moving money twice.

The fix, as a flow

We list orders paid with a Stripe alternative method, read the refund total from the linked Stripe charge, and compare it against what WooCommerce recorded. Where Stripe refunded more, we record the difference with api_refund false. When the charge is fully refunded, we set the order status to Refunded.

APM orders stripe_ methods Read charge amount refunded Compare to Woo missing amount Record + mark refunded
Record only what Stripe already refunded, and mark the order Refunded when the charge is fully refunded.

Build it step by step

1

Decide the refund action with pure functions

Two small functions carry the logic. One tells whether an order used a Stripe alternative method, which is any method that starts with stripe_. The other works out how much refund is missing and whether the charge is now fully refunded.

decide.py
def is_stripe_apm(payment_method):
    # Cards are stored as "stripe"; alternative methods as "stripe_ideal" etc.
    return payment_method.startswith("stripe_")

def refund_action(order_total_minor, stripe_refunded_minor, wc_refunded_minor):
    missing = max(0, stripe_refunded_minor - wc_refunded_minor)
    fully = stripe_refunded_minor >= order_total_minor and stripe_refunded_minor > 0
    return missing, fully
decide.js
export function isStripeApm(paymentMethod) {
  // Cards are stored as "stripe"; alternative methods as "stripe_ideal" etc.
  return paymentMethod.startsWith("stripe_");
}

export function refundAction(orderTotalMinor, stripeRefundedMinor, wcRefundedMinor) {
  const missing = Math.max(0, stripeRefundedMinor - wcRefundedMinor);
  const fully = stripeRefundedMinor >= orderTotalMinor && stripeRefundedMinor > 0;
  return { missing, fully };
}
2

Read the refund total from the Stripe charge

Each order stores its Stripe charge ID. The charge object carries amount_refunded, the total refunded so far in the smallest currency unit. Reading it tells us the true refunded amount to compare against WooCommerce.

step2.py
def stripe_refunded_minor(order):
    charge_id = get_meta(order, "_stripe_charge_id")
    if not charge_id:
        return 0
    charge = stripe.Charge.retrieve(charge_id)
    return charge.get("amount_refunded", 0)

def wc_refunded_minor(order):
    return sum(round(abs(float(r["total"])) * 100) for r in order.get("refunds", []))
step2.js
async function stripeRefundedMinor(order) {
  const chargeId = getMeta(order, "_stripe_charge_id");
  if (!chargeId) return 0;
  const charge = await stripe.charges.retrieve(chargeId);
  return charge.amount_refunded || 0;
}

function wcRefundedMinor(order) {
  return (order.refunds || []).reduce((sum, r) => sum + Math.round(Math.abs(parseFloat(r.total)) * 100), 0);
}
3

Record the refund and mark it Refunded

When Stripe refunded more than WooCommerce shows, record the difference with api_refund false so no second refund is sent. When the charge is fully refunded, set the order status to Refunded so it reads correctly everywhere. Add a note that explains the fix.

apply.py
def record_and_mark(order_id, missing_minor, fully):
    if missing_minor > 0:
        requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/refunds",
                      json={"amount": f"{missing_minor / 100:.2f}", "api_refund": False,
                            "reason": "Recorded a Stripe refund the webhook skipped for this method."},
                      auth=AUTH, timeout=30).raise_for_status()
    if fully:
        put_order(order_id, {"status": "refunded"})
apply.js
async function recordAndMark(orderId, missingMinor, fully) {
  if (missingMinor > 0) {
    await woo(`/orders/${orderId}/refunds`, {
      method: "POST",
      body: JSON.stringify({ amount: (missingMinor / 100).toFixed(2), api_refund: false,
        reason: "Recorded a Stripe refund the webhook skipped for this method." }),
    });
  }
  if (fully) await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "refunded" }) });
}
api_refund false keeps it safe

Recording with api_refund false is what stops a second refund. The money already moved on Stripe, so we only write the record. Always run with DRY_RUN=true first and read which orders it would touch.

The full code

The complete repair lists Stripe alternative method orders, compares refunds, records anything missing, and marks fully refunded orders Refunded. It is safe to run again because an order that already matches is skipped.

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

sync_apm_refunds.py
"""Record Stripe refunds on non-card methods that the webhook skipped, and mark
fully refunded orders as Refunded. Uses api_refund false, never refunds twice.
Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/woocommerce/refund-webhook-skips-non-card-methods/
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("sync_apm_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"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def is_stripe_apm(payment_method):
    return payment_method.startswith("stripe_")


def refund_action(order_total_minor, stripe_refunded_minor, wc_refunded_minor):
    missing = max(0, stripe_refunded_minor - wc_refunded_minor)
    fully = stripe_refunded_minor >= order_total_minor and stripe_refunded_minor > 0
    return missing, fully


def get_meta(order, key):
    for m in order.get("meta_data", []):
        if m.get("key") == key:
            return m.get("value")
    return None


def paid_stripe_orders():
    page = 1
    while True:
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders",
                         params={"status": "processing,completed", "per_page": 50, "page": page},
                         auth=AUTH, timeout=30)
        r.raise_for_status()
        orders = r.json()
        if not orders:
            return
        for order in orders:
            yield order
        page += 1


def stripe_refunded_minor(order):
    charge_id = get_meta(order, "_stripe_charge_id")
    if not charge_id:
        return 0
    return stripe.Charge.retrieve(charge_id).get("amount_refunded", 0)


def wc_refunded_minor(order):
    return sum(round(abs(float(r["total"])) * 100) for r in order.get("refunds", []))


def put_order(order_id, body):
    requests.put(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", json=body, auth=AUTH, timeout=30).raise_for_status()


def record_and_mark(order_id, missing_minor, fully):
    if missing_minor > 0:
        requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/refunds",
                      json={"amount": f"{missing_minor / 100:.2f}", "api_refund": False,
                            "reason": "Recorded a Stripe refund the webhook skipped for this method."},
                      auth=AUTH, timeout=30).raise_for_status()
    if fully:
        put_order(order_id, {"status": "refunded"})


def run():
    fixed = 0
    for order in paid_stripe_orders():
        if not is_stripe_apm(order["payment_method"]):
            continue
        order_total_minor = round(float(order["total"]) * 100)
        missing, fully = refund_action(order_total_minor, stripe_refunded_minor(order), wc_refunded_minor(order))
        if not missing and not (fully and order["status"] != "refunded"):
            continue
        log.info("Order %s: record %s, mark refunded=%s. %s",
                 order["id"], missing, fully, "dry run" if DRY_RUN else "applying")
        if not DRY_RUN:
            record_and_mark(order["id"], missing, fully and order["status"] != "refunded")
        fixed += 1
    log.info("Done. %d order(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
sync-apm-refunds.js
/**
 * Record Stripe refunds on non-card methods that the webhook skipped, and mark
 * fully refunded orders as Refunded. Uses api_refund false, never refunds twice.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/refund-webhook-skips-non-card-methods/
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function isStripeApm(paymentMethod) {
  return paymentMethod.startsWith("stripe_");
}

export function refundAction(orderTotalMinor, stripeRefundedMinor, wcRefundedMinor) {
  const missing = Math.max(0, stripeRefundedMinor - wcRefundedMinor);
  const fully = stripeRefundedMinor >= orderTotalMinor && stripeRefundedMinor > 0;
  return { missing, fully };
}

export function getMeta(order, key) {
  const hit = (order.meta_data || []).find((m) => m.key === key);
  return hit ? hit.value : null;
}

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* paidStripeOrders() {
  let page = 1;
  while (true) {
    const orders = await woo(`/orders?status=processing,completed&per_page=50&page=${page}`);
    if (!orders.length) return;
    for (const order of orders) yield order;
    page++;
  }
}

async function stripeRefundedMinor(order) {
  const chargeId = getMeta(order, "_stripe_charge_id");
  if (!chargeId) return 0;
  const charge = await stripe.charges.retrieve(chargeId);
  return charge.amount_refunded || 0;
}

function wcRefundedMinor(order) {
  return (order.refunds || []).reduce((sum, r) => sum + Math.round(Math.abs(parseFloat(r.total)) * 100), 0);
}

async function run() {
  let fixed = 0;
  for await (const order of paidStripeOrders()) {
    if (!isStripeApm(order.payment_method)) continue;
    const orderTotalMinor = Math.round(parseFloat(order.total) * 100);
    const { missing, fully } = refundAction(orderTotalMinor, await stripeRefundedMinor(order), wcRefundedMinor(order));
    const needsStatus = fully && order.status !== "refunded";
    if (!missing && !needsStatus) continue;
    console.log(`Order ${order.id}: record ${missing}, mark refunded=${needsStatus}. ${DRY_RUN ? "dry run" : "applying"}`);
    if (!DRY_RUN) {
      if (missing > 0) {
        await woo(`/orders/${order.id}/refunds`, { method: "POST", body: JSON.stringify({ amount: (missing / 100).toFixed(2), api_refund: false, reason: "Recorded a Stripe refund the webhook skipped for this method." }) });
      }
      if (needsStatus) await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "refunded" }) });
    }
    fixed++;
  }
  console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}

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

Add a test

The two pure functions decide what gets recorded and marked, so they are the ones to test. No network is needed.

test_refund_action.py
from sync_apm_refunds import is_stripe_apm, refund_action


def test_apm_detected():
    assert is_stripe_apm("stripe_ideal") is True
    assert is_stripe_apm("stripe") is False
    assert is_stripe_apm("paypal") is False


def test_records_missing_and_marks_full():
    missing, fully = refund_action(5000, 5000, 0)
    assert missing == 5000 and fully is True


def test_partial_refund_not_full():
    missing, fully = refund_action(5000, 2000, 0)
    assert missing == 2000 and fully is False


def test_nothing_when_matched():
    missing, fully = refund_action(5000, 2000, 2000)
    assert missing == 0 and fully is False
refund-action.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStripeApm, refundAction } from "./sync-apm-refunds.js";

test("apm detected", () => {
  assert.equal(isStripeApm("stripe_ideal"), true);
  assert.equal(isStripeApm("stripe"), false);
  assert.equal(isStripeApm("paypal"), false);
});

test("records missing and marks full", () => {
  const { missing, fully } = refundAction(5000, 5000, 0);
  assert.equal(missing, 5000); assert.equal(fully, true);
});

test("partial refund not full", () => {
  const { missing, fully } = refundAction(5000, 2000, 0);
  assert.equal(missing, 2000); assert.equal(fully, false);
});

test("nothing when matched", () => {
  const { missing, fully } = refundAction(5000, 2000, 2000);
  assert.equal(missing, 0); assert.equal(fully, false);
});

Case studies

iDEAL refunds

The Dutch store with silent refunds

A store selling into the Netherlands took most payments through iDEAL. Refunds worked on Stripe, but not one of them showed in WooCommerce, so the sales reports counted money that had already gone back to customers.

The repair recorded every missed refund and marked the fully refunded orders Refunded. The reports matched Stripe again, with no customer refunded twice.

Mixed methods

The card refunds that worked, and the SEPA ones that did not

A shop noticed its card refunds synced fine while SEPA refunds never did. It was the exact method-name bug: stripe matched, stripe_sepa_debit did not.

Running the repair scoped to alternative methods fixed the SEPA orders without touching the card ones that were already correct.

What good looks like

After this runs, refunds on every Stripe method show in WooCommerce, and fully refunded orders read as Refunded. Keep it on a schedule as a safety net, since a customer paying with an alternative method should never leave your books out of step with Stripe.

FAQ

Why does a refund on iDEAL or SEPA not update the WooCommerce order?

A plugin bug matched the payment method as exactly stripe, but alternative methods are stored as stripe_ideal, stripe_sepa_debit, and so on. The charge.refunded webhook arrived but the handler skipped it, so the order kept its full total even though the money was refunded on Stripe.

How do I fix orders whose refund was skipped?

Find orders paid with a stripe_ alternative method, compare the refund amount on the Stripe charge against what WooCommerce recorded, and record the difference with api_refund set to false. When the charge is fully refunded, set the order to Refunded.

Will this refund the customer again?

No. Recording with api_refund false only writes the refund into WooCommerce for reporting. The money already moved on Stripe, so no second refund is sent.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: charge.refunded skips non-card methods due to a strict payment-method match. github.com/woocommerce/woocommerce-gateway-stripe/issues/3073
  2. WooCommerce docs: refunding Stripe orders. woocommerce.com/document/stripe/admin-experience/refunding-orders
  3. Stripe docs: the charge object and amount_refunded. docs.stripe.com/api/charges/object

On the solution:

  1. WooCommerce REST API: create an order refund with api_refund. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a charge and read amount_refunded. docs.stripe.com/api/charges/retrieve
  3. WooCommerce docs: order statuses and Refunded. woocommerce.com/document/managing-orders/order-statuses

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 square your refunds with Stripe?

If this recorded the refunds your webhook skipped, you can buy me a coffee. It keeps these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes