Repair Refunds and disputes

Stripe fee and net stay stale on an order after a refund

You partially refund an order, and the money goes back on Stripe. But the Stripe fee and net that WooCommerce saved on the order still show the pre-refund numbers, as if nothing was returned. Any tool that reads those figures for accounting now overstates what you actually kept. This guide recomputes the fee and net from the Stripe balance transaction and writes the correct values back.

Python and Node.js One off repair Correct accounting figures
Office desk with smartphone and financial charts
Photo by Jakub Żerdzicki on Unsplash
The short answer

After a partial refund the plugin can leave _stripe_net and _stripe_fee at their pre-refund values. The true numbers live on the Stripe balance transaction: sum the charge net with each refund net, and the same for the fee. Compare to what WooCommerce saved and write back the corrected values. The full code is below.

The problem in plain words

When a payment settles, Stripe records a fee and a net, which is what you keep after the fee. WooCommerce saves those on the order as _stripe_fee and _stripe_net, and accounting and reporting tools read them. When you refund part of the order, the fee and net change on Stripe, but the saved values on the order are often left untouched.

So the order keeps reporting the original net as if the full amount were still yours. Your books look healthier than they are, and reconciling against a Stripe payout no longer lines up.

Partial refund net changes Saved net not updated _stripe_net stale Reports overstate net you kept Books off vs payout
The refund changed the real net. The saved figure did not follow, so reporting is wrong.

Why it happens

The WooCommerce Stripe plugin has a reported issue where a partial refund does not update _stripe_net and _stripe_fee, and no refund meta entry is written, so downstream invoicing reads the pre-refund figures. The citations at the end link the thread.

The key insight

Stripe keeps the truth on the balance transaction. The charge has one that gives its fee and net, and every refund has one too. Add the charge net to the refund nets, and the charge fee to the refund fees, and you have the real, current fee and net for the order. Those are the values to store.

The fix, as a flow

We read each order's Stripe charge with its balance transaction and its refunds. We recompute the true fee and net from those, compare against what WooCommerce saved, and write back the corrected values when they differ.

Read charge and refunds Recompute fee and net Compare saved stale or not Write back corrected
Recompute from the balance transactions and store the true fee and net.

Build it step by step

1

Recompute fee and net with a pure function

Keep the math in one function. Start from the charge balance transaction, then add each refund's balance transaction. Refund nets and fees come through as negative numbers, so adding them reduces the totals correctly.

recompute.py
def recompute_net_fee(charge_bt, refund_bts):
    net = charge_bt["net"]
    fee = charge_bt["fee"]
    for bt in refund_bts:
        net += bt["net"]   # refund net is negative
        fee += bt["fee"]   # refunded fee is negative or zero
    return net, fee

def is_stale(saved_minor, true_minor, tolerance=1):
    return abs(saved_minor - true_minor) > tolerance
recompute.js
export function recomputeNetFee(chargeBt, refundBts) {
  let net = chargeBt.net;
  let fee = chargeBt.fee;
  for (const bt of refundBts) {
    net += bt.net;   // refund net is negative
    fee += bt.fee;   // refunded fee is negative or zero
  }
  return { net, fee };
}

export function isStale(savedMinor, trueMinor, tolerance = 1) {
  return Math.abs(savedMinor - trueMinor) > tolerance;
}
2

Read the charge, its fee and net, and its refunds

Ask Stripe for the charge and expand the balance transactions, both the charge's own and each refund's. That gives every number we need in one call, without extra lookups.

step2.py
def charge_numbers(charge_id):
    charge = stripe.Charge.retrieve(
        charge_id,
        expand=["balance_transaction", "refunds.data.balance_transaction"],
    )
    charge_bt = charge["balance_transaction"]
    refund_bts = [r["balance_transaction"] for r in charge["refunds"]["data"] if r.get("balance_transaction")]
    return charge_bt, refund_bts
step2.js
async function chargeNumbers(chargeId) {
  const charge = await stripe.charges.retrieve(chargeId, {
    expand: ["balance_transaction", "refunds.data.balance_transaction"],
  });
  const chargeBt = charge.balance_transaction;
  const refundBts = charge.refunds.data.map((r) => r.balance_transaction).filter(Boolean);
  return { chargeBt, refundBts };
}
3

Write back the corrected fee and net

Compare the true fee and net against the saved _stripe_fee and _stripe_net. When they are stale, write the corrected values as order meta and add a note. The values are stored in major units, so convert from the minor units Stripe uses.

write.py
def write_back(order_id, net_minor, fee_minor):
    requests.put(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
                 json={"meta_data": [
                     {"key": "_stripe_net", "value": f"{net_minor / 100:.2f}"},
                     {"key": "_stripe_fee", "value": f"{fee_minor / 100:.2f}"},
                 ]}, auth=AUTH, timeout=30).raise_for_status()
    add_note(order_id, f"Recomputed Stripe fee and net after refund: net {net_minor / 100:.2f}, "
                       f"fee {fee_minor / 100:.2f}.")
write.js
async function writeBack(orderId, netMinor, feeMinor) {
  await woo(`/orders/${orderId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [
      { key: "_stripe_net", value: (netMinor / 100).toFixed(2) },
      { key: "_stripe_fee", value: (feeMinor / 100).toFixed(2) },
    ] }),
  });
  await addNote(orderId, `Recomputed Stripe fee and net after refund: net ${(netMinor / 100).toFixed(2)}, fee ${(feeMinor / 100).toFixed(2)}.`);
}
Reporting only

This corrects the reporting figures the plugin saved. It does not change the order total, the refund, or anything the customer sees. Still, run with DRY_RUN=true first and read the before and after numbers so you trust them.

The full code

The complete repair walks paid Stripe orders that have refunds, recomputes the fee and net from the balance transactions, and writes back the corrected values when they are stale.

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

fix_fee_net.py
"""Recompute stale Stripe fee and net on refunded WooCommerce orders.
Reporting only. Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/woocommerce/stripe-fee-net-stale-after-refund/
"""
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("fix_fee_net")

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 recompute_net_fee(charge_bt, refund_bts):
    net = charge_bt["net"]
    fee = charge_bt["fee"]
    for bt in refund_bts:
        net += bt["net"]
        fee += bt["fee"]
    return net, fee


def is_stale(saved_minor, true_minor, tolerance=1):
    return abs(saved_minor - true_minor) > tolerance


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


def to_minor(value):
    try:
        return round(float(value) * 100)
    except (TypeError, ValueError):
        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,refunded", "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 charge_numbers(charge_id):
    charge = stripe.Charge.retrieve(
        charge_id, expand=["balance_transaction", "refunds.data.balance_transaction"])
    charge_bt = charge["balance_transaction"]
    refund_bts = [r["balance_transaction"] for r in charge["refunds"]["data"] if r.get("balance_transaction")]
    return charge_bt, refund_bts


def add_note(order_id, note):
    requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
                  json={"note": note}, auth=AUTH, timeout=30).raise_for_status()


def write_back(order_id, net_minor, fee_minor):
    requests.put(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
                 json={"meta_data": [
                     {"key": "_stripe_net", "value": f"{net_minor / 100:.2f}"},
                     {"key": "_stripe_fee", "value": f"{fee_minor / 100:.2f}"},
                 ]}, auth=AUTH, timeout=30).raise_for_status()
    add_note(order_id, f"Recomputed Stripe fee and net after refund: net {net_minor / 100:.2f}, "
                       f"fee {fee_minor / 100:.2f}.")


def run():
    fixed = 0
    for order in paid_stripe_orders():
        charge_id = get_meta(order, "_stripe_charge_id")
        if not charge_id:
            continue
        charge_bt, refund_bts = charge_numbers(charge_id)
        if not charge_bt or not refund_bts:
            continue
        true_net, true_fee = recompute_net_fee(charge_bt, refund_bts)
        saved_net = to_minor(get_meta(order, "_stripe_net"))
        saved_fee = to_minor(get_meta(order, "_stripe_fee"))
        if saved_net is not None and not is_stale(saved_net, true_net) \
                and saved_fee is not None and not is_stale(saved_fee, true_fee):
            continue
        log.info("Order %s: net %s -> %s, fee %s -> %s. %s",
                 order["id"], saved_net, true_net, saved_fee, true_fee, "dry run" if DRY_RUN else "fixing")
        if not DRY_RUN:
            write_back(order["id"], true_net, true_fee)
        fixed += 1
    log.info("Done. %d order(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
fix-fee-net.js
/**
 * Recompute stale Stripe fee and net on refunded WooCommerce orders.
 * Reporting only. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/stripe-fee-net-stale-after-refund/
 */
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 recomputeNetFee(chargeBt, refundBts) {
  let net = chargeBt.net;
  let fee = chargeBt.fee;
  for (const bt of refundBts) {
    net += bt.net;
    fee += bt.fee;
  }
  return { net, fee };
}

export function isStale(savedMinor, trueMinor, tolerance = 1) {
  return Math.abs(savedMinor - trueMinor) > tolerance;
}

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

function toMinor(value) {
  const n = parseFloat(value);
  return Number.isFinite(n) ? Math.round(n * 100) : 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,refunded&per_page=50&page=${page}`);
    if (!orders.length) return;
    for (const order of orders) yield order;
    page++;
  }
}

async function chargeNumbers(chargeId) {
  const charge = await stripe.charges.retrieve(chargeId, {
    expand: ["balance_transaction", "refunds.data.balance_transaction"],
  });
  const chargeBt = charge.balance_transaction;
  const refundBts = charge.refunds.data.map((r) => r.balance_transaction).filter(Boolean);
  return { chargeBt, refundBts };
}

async function run() {
  let fixed = 0;
  for await (const order of paidStripeOrders()) {
    const chargeId = getMeta(order, "_stripe_charge_id");
    if (!chargeId) continue;
    const { chargeBt, refundBts } = await chargeNumbers(chargeId);
    if (!chargeBt || !refundBts.length) continue;
    const { net: trueNet, fee: trueFee } = recomputeNetFee(chargeBt, refundBts);
    const savedNet = toMinor(getMeta(order, "_stripe_net"));
    const savedFee = toMinor(getMeta(order, "_stripe_fee"));
    if (savedNet !== null && !isStale(savedNet, trueNet) && savedFee !== null && !isStale(savedFee, trueFee)) continue;
    console.log(`Order ${order.id}: net ${savedNet} -> ${trueNet}, fee ${savedFee} -> ${trueFee}. ${DRY_RUN ? "dry run" : "fixing"}`);
    if (!DRY_RUN) {
      await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ meta_data: [
        { key: "_stripe_net", value: (trueNet / 100).toFixed(2) },
        { key: "_stripe_fee", value: (trueFee / 100).toFixed(2) },
      ] }) });
      await woo(`/orders/${order.id}/notes`, { method: "POST", body: JSON.stringify({ note: `Recomputed Stripe fee and net after refund: net ${(trueNet / 100).toFixed(2)}, fee ${(trueFee / 100).toFixed(2)}.` }) });
    }
    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 recompute and the stale check decide what gets written, so they are the pieces to test. Both are pure, so no network is needed.

test_recompute.py
from fix_fee_net import recompute_net_fee, is_stale


def test_recompute_subtracts_refund():
    charge_bt = {"net": 4700, "fee": 300}
    refund_bts = [{"net": -2000, "fee": 0}]
    net, fee = recompute_net_fee(charge_bt, refund_bts)
    assert net == 2700 and fee == 300


def test_recompute_with_refunded_fee():
    charge_bt = {"net": 4700, "fee": 300}
    refund_bts = [{"net": -4700, "fee": -300}]
    net, fee = recompute_net_fee(charge_bt, refund_bts)
    assert net == 0 and fee == 0


def test_stale_detects_difference():
    assert is_stale(4700, 2700) is True


def test_not_stale_within_tolerance():
    assert is_stale(2700, 2700) is False
recompute.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { recomputeNetFee, isStale } from "./fix-fee-net.js";

test("recompute subtracts refund", () => {
  const { net, fee } = recomputeNetFee({ net: 4700, fee: 300 }, [{ net: -2000, fee: 0 }]);
  assert.equal(net, 2700);
  assert.equal(fee, 300);
});

test("recompute with refunded fee", () => {
  const { net, fee } = recomputeNetFee({ net: 4700, fee: 300 }, [{ net: -4700, fee: -300 }]);
  assert.equal(net, 0);
  assert.equal(fee, 0);
});

test("stale detects difference", () => {
  assert.equal(isStale(4700, 2700), true);
});

test("not stale within tolerance", () => {
  assert.equal(isStale(2700, 2700), false);
});

Case studies

Accounting sync

The books that never matched the payout

A store synced its Stripe net into accounting software from the order meta. After a season of partial refunds, the software showed thousands more in net than Stripe had actually paid out, because every refunded order kept its old net.

The repair recomputed the fee and net for each refunded order, and the accounting figures finally lined up with the Stripe payouts.

Refunded fees

The fee that came back

On a full refund, Stripe returned the processing fee too, but the order still showed the original fee. Profit per order looked lower than reality on refunded sales.

Recomputing from the balance transactions captured the returned fee, so the fully refunded orders correctly showed a net and fee of zero.

What good looks like

After this runs, every refunded order reports the fee and net that Stripe actually settled. Your accounting reconciles against payouts, and profit reporting reflects what you really kept. Keep it scheduled so new refunds stay correct.

FAQ

Why is the Stripe net wrong on a refunded WooCommerce order?

After a partial refund the plugin often leaves the saved _stripe_net and _stripe_fee at their pre-refund values, so the order still reports the full net as if nothing was refunded. Accounting tools that read those figures then overstate what you kept.

Where do the true fee and net come from?

From the Stripe balance transaction. The charge has one, and each refund has one. Summing the charge net with the refund nets gives the true net, and the same for the fee. Those are the correct numbers to store.

Does fixing the meta change the order total?

No. It only corrects the reporting figures the plugin saved, not the order total or any refund. Your customer is not affected.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: fee and net meta not updated after a partial refund. github.com/woocommerce/woocommerce-gateway-stripe/issues/1073
  2. Stripe docs: the balance transaction, its fee and net. docs.stripe.com/api/balance_transactions/object
  3. Stripe docs: refunds and how they affect fees. docs.stripe.com/refunds

On the solution:

  1. Stripe API: retrieve a charge and expand the balance transactions. docs.stripe.com/api/charges/retrieve
  2. Stripe API: the refund object and its balance transaction. docs.stripe.com/api/refunds/object
  3. WooCommerce REST API: update an order with meta_data. 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 fix your books?

If this got your fee and net reporting right after refunds, 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