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.
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.
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.
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.
Build it step by step
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.
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
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;
}
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.
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
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 };
}
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.
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}.")
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)}.`);
}
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- WooCommerce Stripe plugin issue: fee and net meta not updated after a partial refund. github.com/woocommerce/woocommerce-gateway-stripe/issues/1073
- Stripe docs: the balance transaction, its fee and net. docs.stripe.com/api/balance_transactions/object
- Stripe docs: refunds and how they affect fees. docs.stripe.com/refunds
On the solution:
- Stripe API: retrieve a charge and expand the balance transactions. docs.stripe.com/api/charges/retrieve
- Stripe API: the refund object and its balance transaction. docs.stripe.com/api/refunds/object
- 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.
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