Repair Refunds and disputes
Fee and net missing on renewals
A subscription renewal charges the card just fine, WooCommerce Subscriptions marks the order paid, and everything looks normal in the order list. But open the order and the Stripe fee and net fields are blank, while the original signup order right next to it has both filled in. Somewhere an update quietly broke the code that saves those two numbers, only for renewals. Here is why that split happens and a small script that backfills the missing fee and net from Stripe so your books are accurate again.
Renewal orders get their status from a different hook than the initial checkout order, and an update, a plugin change, or a custom snippet that used to save the Stripe fee and net stopped firing on that renewal hook. The charge itself is fine. Run a small Python or Node.js backfill that finds paid renewal orders missing the fee and net meta, reads the balance transaction behind the matching Stripe PaymentIntent, and writes _stripe_fee and _stripe_net back onto the order. It only touches orders that are missing the values, so it is safe to run again and again. Full code, tests, and a dry run guard are below.
The problem in plain words
When WooCommerce Subscriptions renews a subscription, it creates a fresh renewal order, charges the saved card through Stripe, and marks that renewal order Processing or Completed once the charge succeeds. From the customer's side and from the money's side, nothing is different from a first-time purchase.
But a lot of stores save the Stripe processing fee and the net amount you actually keep with a small hook that fires when a payment completes. If that hook was written to listen for the checkout order's payment event, and a plugin update, a WooCommerce Subscriptions update, or a theme change swapped which action fires for renewals, the fee and net code stops running for renewals while still working for new orders. The renewal order is paid and correct. It is just missing two numbers your profit reports depend on.
Why it happens
WooCommerce Subscriptions and the payment gateway fire several different hooks depending on whether an order is the original signup or a later renewal. A few common reasons the fee and net code stops covering renewals:
- The custom snippet or mini plugin hooked into
woocommerce_payment_completeor a gateway-specific action that fires for the first order but not for a scheduled renewal processed throughWC_Subscriptions_Manager. - A WooCommerce Subscriptions update changed the order it fires
woocommerce_subscription_renewal_payment_completerelative to when the gateway attaches its own charge metadata, so code that read a value too early got nothing and quietly skipped saving. - A gateway plugin update renamed or moved the balance transaction expansion it requests from Stripe, so the fee and net lookup silently returns empty for orders created after the update, renewals included.
- The fee and net code was added as a one-off customization years ago and was never covered by a test, so nobody noticed it stopped working until someone reconciled payout totals against WooCommerce reports and the numbers did not add up.
Store owners usually find this the same way, by comparing a Stripe payout total against what WooCommerce reports as revenue and cost, and discovering that only orders from a certain date forward are missing the fee. That date almost always lines up with a plugin update in the changelog.
The renewal order is not broken. The charge succeeded, Stripe has a complete balance transaction with the exact fee and net, and the order status is correct. The only missing piece is two numbers that a backfill can read straight from Stripe and write back, using the same PaymentIntent id that is already saved on the order.
The fix, as a flow
We do not touch checkout, the renewal schedule, or the payment flow at all. We add a script that looks at recent renewal orders, skips any that already have the fee and net saved, and for the rest reads the PaymentIntent id already stored in order meta, asks Stripe for the balance transaction behind that charge, and writes the fee and net back in cents converted to your currency's minor unit math.
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="90"
export DRY_RUN="true" # start safe, change to false to write
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="90"
export DRY_RUN="true" // start safe, change to false to write
List renewal orders that could be missing the meta
Renewal orders in WooCommerce Subscriptions carry a _subscription_renewal meta key pointing at the parent subscription. We page through recent paid orders and keep only the ones with that meta key set, since those are renewals rather than original signups.
import os, datetime, requests
from requests.auth import HTTPBasicAuth
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", "90"))
def is_renewal_order(order):
return any(m.get("key") == "_subscription_renewal" for m in order.get("meta_data") or [])
def paid_renewal_orders():
after = f"{datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)}T00:00:00"
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
if is_renewal_order(order):
yield order
page += 1
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 || 90);
function isRenewalOrder(order) {
return (order.meta_data || []).some((m) => m.key === "_subscription_renewal");
}
async function* paidRenewalOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo orders returned ${res.status}`);
const batch = await res.json();
if (!batch.length) return;
for (const order of batch) {
if (isRenewalOrder(order)) yield order;
}
page++;
}
}
Read the PaymentIntent id already saved on the order
You do not need to guess which charge paid for the renewal. The gateway already wrote the PaymentIntent id onto the order, either as meta _stripe_intent_id or as the order's transaction_id. We read whichever one is there.
FEE_META_KEY = "_stripe_fee"
NET_META_KEY = "_stripe_net"
def intent_id_of(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def has_fee_and_net(order):
keys = {m.get("key") for m in order.get("meta_data") or []}
return FEE_META_KEY in keys and NET_META_KEY in keys
const FEE_META_KEY = "_stripe_fee";
const NET_META_KEY = "_stripe_net";
function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
function hasFeeAndNet(order) {
const keys = new Set((order.meta_data || []).map((m) => m.key));
return keys.has(FEE_META_KEY) && keys.has(NET_META_KEY);
}
Decide, with one pure function
Keep the decision in its own function that takes an order and the balance transaction Stripe returns for that charge, then returns an action. All money math happens in cents (Stripe's minor unit), and we only convert to a decimal amount right at the very end, when we write the meta values. The rule is simple. If the order already has fee and net, skip it. If there is no PaymentIntent id or no balance transaction yet, mark it orphan so it can be checked by hand. Otherwise, fix it.
def decide(order, balance_transaction):
if not is_renewal_order(order):
return ("skip", "not a renewal order")
if order["status"] not in {"processing", "completed"}:
return ("skip", "renewal not paid yet")
if has_fee_and_net(order):
return ("skip", "fee and net already recorded")
if not intent_id_of(order):
return ("orphan", "no PaymentIntent id saved on the order")
if balance_transaction is None:
return ("orphan", "no balance transaction found for the charge")
fee = balance_transaction.get("fee")
net = balance_transaction.get("net")
if fee is None or net is None:
return ("orphan", "balance transaction missing fee or net")
return ("fix", "renewal paid, fee and net can be backfilled")
export function decide(order, balanceTransaction) {
if (!isRenewalOrder(order)) return ["skip", "not a renewal order"];
if (!["processing", "completed"].includes(order.status)) {
return ["skip", "renewal not paid yet"];
}
if (hasFeeAndNet(order)) return ["skip", "fee and net already recorded"];
if (!intentIdOf(order)) return ["orphan", "no PaymentIntent id saved on the order"];
if (!balanceTransaction) return ["orphan", "no balance transaction found for the charge"];
const { fee, net } = balanceTransaction;
if (fee == null || net == null) return ["orphan", "balance transaction missing fee or net"];
return ["fix", "renewal paid, fee and net can be backfilled"];
}
Turn cents into the two saved fields
Stripe reports fee and net in the currency's minor unit, cents for US dollars. Keep the math in cents until the last possible moment, then divide by 100 only when building the value you write back, so rounding only happens once.
def fee_and_net_minor(balance_transaction):
return balance_transaction["fee"], balance_transaction["net"]
def to_major(minor_amount):
return round(minor_amount / 100, 2)
def save_fee_and_net(order_id, fee_minor, net_minor):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"meta_data": [
{"key": FEE_META_KEY, "value": f"{to_major(fee_minor):.2f}"},
{"key": NET_META_KEY, "value": f"{to_major(net_minor):.2f}"},
]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Backfilled Stripe fee {to_major(fee_minor):.2f} and net "
f"{to_major(net_minor):.2f} for this renewal. Recorded by the fee backfill."},
auth=AUTH, timeout=30,
).raise_for_status()
function feeAndNetMinor(balanceTransaction) {
return [balanceTransaction.fee, balanceTransaction.net];
}
function toMajor(minorAmount) {
return Math.round(minorAmount) / 100;
}
async function saveFeeAndNet(orderId, feeMinor, netMinor) {
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: FEE_META_KEY, value: toMajor(feeMinor).toFixed(2) },
{ key: NET_META_KEY, value: toMajor(netMinor).toFixed(2) },
],
}),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Backfilled Stripe fee ${toMajor(feeMinor).toFixed(2)} and net ` +
`${toMajor(netMinor).toFixed(2)} for this renewal. Recorded by the fee backfill.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. It asks Stripe for each order's PaymentIntent expanded with its charge and balance transaction in a single call, runs the pure decide function, and only writes when DRY_RUN is off. Run it once to clear the backlog, then leave it on a weekly schedule to catch anything new until the root cause is patched.
Always start with DRY_RUN=true. Spot check the fee and net the script reports against the Stripe Dashboard for two or three orders before you switch it off and let it write.
The full code
Here is the complete backfill in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a renewal order that already has the fee and net saved.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Backfill the Stripe fee and net on WooCommerce Subscriptions renewal orders
that are missing them, usually because an update stopped a fee-saving hook
from firing on renewals. Read only by default. Safe to run again and again.
Guide: https://www.allanninal.dev/woocommerce/fee-and-net-missing-on-renewals/
"""
import os
import datetime
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("backfill_renewal_fees")
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", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
FEE_META_KEY = "_stripe_fee"
NET_META_KEY = "_stripe_net"
PAID_STATUSES = {"processing", "completed"}
def is_renewal_order(order):
return any(m.get("key") == "_subscription_renewal" for m in order.get("meta_data") or [])
def has_fee_and_net(order):
keys = {m.get("key") for m in order.get("meta_data") or []}
return FEE_META_KEY in keys and NET_META_KEY in keys
def intent_id_of(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def decide(order, balance_transaction):
if not is_renewal_order(order):
return ("skip", "not a renewal order")
if order["status"] not in PAID_STATUSES:
return ("skip", "renewal not paid yet")
if has_fee_and_net(order):
return ("skip", "fee and net already recorded")
if not intent_id_of(order):
return ("orphan", "no PaymentIntent id saved on the order")
if balance_transaction is None:
return ("orphan", "no balance transaction found for the charge")
fee = balance_transaction.get("fee")
net = balance_transaction.get("net")
if fee is None or net is None:
return ("orphan", "balance transaction missing fee or net")
return ("fix", "renewal paid, fee and net can be backfilled")
def to_major(minor_amount):
return round(minor_amount / 100, 2)
def balance_transaction_for(intent_id):
if not intent_id:
return None
try:
pi = stripe.PaymentIntent.retrieve(intent_id, expand=["latest_charge.balance_transaction"])
except stripe.error.InvalidRequestError:
return None
charge = pi.get("latest_charge")
if not charge or isinstance(charge, str):
return None
bt = charge.get("balance_transaction")
if not bt or isinstance(bt, str):
return None
return bt
def paid_renewal_orders():
after = f"{datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)}T00:00:00"
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
if is_renewal_order(order):
yield order
page += 1
def save_fee_and_net(order_id, fee_minor, net_minor):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"meta_data": [
{"key": FEE_META_KEY, "value": f"{to_major(fee_minor):.2f}"},
{"key": NET_META_KEY, "value": f"{to_major(net_minor):.2f}"},
]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Backfilled Stripe fee {to_major(fee_minor):.2f} and net "
f"{to_major(net_minor):.2f} for this renewal. Recorded by the fee backfill."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
orphans = 0
for order in paid_renewal_orders():
intent_id = intent_id_of(order)
bt = balance_transaction_for(intent_id)
action, reason = decide(order, bt)
if action == "orphan":
log.warning("Order %s: %s", order["id"], reason)
orphans += 1
continue
if action == "skip":
continue
fee_minor, net_minor = bt["fee"], bt["net"]
log.info("Order %s: fee %.2f net %.2f. %s", order["id"], to_major(fee_minor), to_major(net_minor),
"would save" if DRY_RUN else "saving")
if not DRY_RUN:
save_fee_and_net(order["id"], fee_minor, net_minor)
fixed += 1
log.info("Done. %d order(s) %s, %d orphan(s) need a manual look.",
fixed, "to backfill" if DRY_RUN else "backfilled", orphans)
if __name__ == "__main__":
run()
/**
* Backfill the Stripe fee and net on WooCommerce Subscriptions renewal orders
* that are missing them, usually because an update stopped a fee-saving hook
* from firing on renewals. Read only by default. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/fee-and-net-missing-on-renewals/
*/
import Stripe from "stripe";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 90);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const FEE_META_KEY = "_stripe_fee";
const NET_META_KEY = "_stripe_net";
const PAID_STATUSES = new Set(["processing", "completed"]);
export function isRenewalOrder(order) {
return (order.meta_data || []).some((m) => m.key === "_subscription_renewal");
}
export function hasFeeAndNet(order) {
const keys = new Set((order.meta_data || []).map((m) => m.key));
return keys.has(FEE_META_KEY) && keys.has(NET_META_KEY);
}
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function decide(order, balanceTransaction) {
if (!isRenewalOrder(order)) return ["skip", "not a renewal order"];
if (!PAID_STATUSES.has(order.status)) return ["skip", "renewal not paid yet"];
if (hasFeeAndNet(order)) return ["skip", "fee and net already recorded"];
if (!intentIdOf(order)) return ["orphan", "no PaymentIntent id saved on the order"];
if (!balanceTransaction) return ["orphan", "no balance transaction found for the charge"];
const { fee, net } = balanceTransaction;
if (fee == null || net == null) return ["orphan", "balance transaction missing fee or net"];
return ["fix", "renewal paid, fee and net can be backfilled"];
}
export function toMajor(minorAmount) {
return Math.round(minorAmount) / 100;
}
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 balanceTransactionFor(intentId) {
if (!intentId) return null;
let pi;
try {
pi = await stripe.paymentIntents.retrieve(intentId, { expand: ["latest_charge.balance_transaction"] });
} catch {
return null;
}
const charge = pi.latest_charge;
if (!charge || typeof charge === "string") return null;
const bt = charge.balance_transaction;
if (!bt || typeof bt === "string") return null;
return bt;
}
async function* paidRenewalOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) {
if (isRenewalOrder(order)) yield order;
}
page++;
}
}
async function saveFeeAndNet(orderId, feeMinor, netMinor) {
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: FEE_META_KEY, value: toMajor(feeMinor).toFixed(2) },
{ key: NET_META_KEY, value: toMajor(netMinor).toFixed(2) },
],
}),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Backfilled Stripe fee ${toMajor(feeMinor).toFixed(2)} and net ` +
`${toMajor(netMinor).toFixed(2)} for this renewal. Recorded by the fee backfill.`,
}),
});
}
export async function run() {
let fixed = 0;
let orphans = 0;
for await (const order of paidRenewalOrders()) {
const bt = await balanceTransactionFor(intentIdOf(order));
const [action, reason] = decide(order, bt);
if (action === "orphan") {
console.warn(`Order ${order.id}: ${reason}`);
orphans++;
continue;
}
if (action === "skip") continue;
console.log(`Order ${order.id}: fee ${toMajor(bt.fee).toFixed(2)} net ${toMajor(bt.net).toFixed(2)}. ` +
`${DRY_RUN ? "would save" : "saving"}`);
if (!DRY_RUN) await saveFeeAndNet(order.id, bt.fee, bt.net);
fixed++;
}
console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to backfill" : "backfilled"}, ${orphans} orphan(s) need a manual look.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which renewal orders get written to and it is where a currency or rounding mistake would hide. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from backfill_renewal_fees import decide
def renewal_order(**over):
base = {
"status": "processing",
"meta_data": [{"key": "_subscription_renewal", "value": "9"},
{"key": "_stripe_intent_id", "value": "pi_1"}],
}
base.update(over)
return base
def balance_transaction(**over):
base = {"fee": 88, "net": 4912}
base.update(over)
return base
def test_fix_when_renewal_paid_and_missing_fee():
assert decide(renewal_order(), balance_transaction())[0] == "fix"
def test_skip_when_not_a_renewal():
order = {"status": "processing", "meta_data": [{"key": "_stripe_intent_id", "value": "pi_1"}]}
assert decide(order, balance_transaction())[0] == "skip"
def test_skip_when_fee_and_net_already_saved():
order = renewal_order(meta_data=[
{"key": "_subscription_renewal", "value": "9"},
{"key": "_stripe_intent_id", "value": "pi_1"},
{"key": "_stripe_fee", "value": "0.88"},
{"key": "_stripe_net", "value": "49.12"},
])
assert decide(order, balance_transaction())[0] == "skip"
def test_skip_when_renewal_not_yet_paid():
order = renewal_order(status="pending")
assert decide(order, balance_transaction())[0] == "skip"
def test_orphan_when_no_intent_id():
order = renewal_order(meta_data=[{"key": "_subscription_renewal", "value": "9"}])
assert decide(order, balance_transaction())[0] == "orphan"
def test_orphan_when_no_balance_transaction():
assert decide(renewal_order(), None)[0] == "orphan"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./backfill-renewal-fees.js";
const renewalOrder = (over = {}) => ({
status: "processing",
meta_data: [
{ key: "_subscription_renewal", value: "9" },
{ key: "_stripe_intent_id", value: "pi_1" },
],
...over,
});
const balanceTransaction = (over = {}) => ({ fee: 88, net: 4912, ...over });
test("fix when renewal paid and missing fee", () => {
assert.equal(decide(renewalOrder(), balanceTransaction())[0], "fix");
});
test("skip when not a renewal", () => {
const order = { status: "processing", meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }] };
assert.equal(decide(order, balanceTransaction())[0], "skip");
});
test("skip when fee and net already saved", () => {
const order = renewalOrder({
meta_data: [
{ key: "_subscription_renewal", value: "9" },
{ key: "_stripe_intent_id", value: "pi_1" },
{ key: "_stripe_fee", value: "0.88" },
{ key: "_stripe_net", value: "49.12" },
],
});
assert.equal(decide(order, balanceTransaction())[0], "skip");
});
test("skip when renewal not yet paid", () => {
assert.equal(decide(renewalOrder({ status: "pending" }), balanceTransaction())[0], "skip");
});
test("orphan when no intent id", () => {
const order = renewalOrder({ meta_data: [{ key: "_subscription_renewal", value: "9" }] });
assert.equal(decide(order, balanceTransaction())[0], "orphan");
});
test("orphan when no balance transaction", () => {
assert.equal(decide(renewalOrder(), null)[0], "orphan");
});
Case studies
The gateway update that moved the goalposts
A store had a small custom snippet that saved the Stripe fee on woocommerce_payment_complete. A gateway plugin update changed the point at which it attached the charge id to the order relative to that hook, so the snippet ran before the charge id existed on new orders. Signup orders still worked because a different code path set the charge id earlier for them. Every renewal from that day forward was missing fee and net.
The backfill ran once with a ninety day lookback, found around 340 renewal orders missing the two fields, and filled every one of them in about four minutes using the PaymentIntent id already on each order.
The payout total that would not add up
A bookkeeper reconciling monthly Stripe payouts against WooCommerce Analytics kept finding a gap between gross revenue and reported cost that grew every month. Digging in, only orders with the _subscription_renewal meta were missing the fee field, going back to a WooCommerce Subscriptions update from months earlier.
Running the backfill in dry run first produced a clean list matching the gap almost to the cent, which confirmed the cause before anyone wrote a single value.
After the backfill runs, every renewal order carries the same _stripe_fee and _stripe_net meta as a first-time order, and your profit reports agree with your Stripe payouts again. Keep the script around on a monthly schedule until the underlying hook is fixed, and it will quietly catch any renewal the broken code path still misses.
FAQ
Why do my WooCommerce Subscriptions renewal orders have no Stripe fee or net saved?
An update to the store, a payment plugin, or a custom hook stopped the code that writes the fee and net meta from running on renewal orders specifically. The original checkout order still gets it because it runs on a different hook than the one WooCommerce Subscriptions fires for a renewal. A backfill that reads the Stripe balance transaction for each renewal and writes the meta back fixes it.
Is it safe to backfill fee and net on orders that are months old?
Yes, because the script only reads history from Stripe and never changes the order status, the total, or the customer record. It writes two small meta fields used for reporting. Start in dry run mode and check a handful of orders against the Stripe Dashboard before you let it write.
What if a renewal was refunded after it was charged?
Refunds change the true net and fee, since Stripe applies a partial fee credit. This backfill fills in missing values on unrefunded renewals. A refunded renewal needs the fee and net recomputed from the charge plus its refunds, which is a related but separate job.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: renewal orders and the hooks that fire around a successful renewal payment. woocommerce.com/document/subscriptions/develop/action-reference
- WooCommerce Subscriptions docs: how renewal orders relate to their parent subscription and its meta. woocommerce.com/document/subscriptions/renewal-process
- WooCommerce Stripe plugin repository, for how the gateway attaches PaymentIntent and charge identifiers to orders. github.com/woocommerce/woocommerce-gateway-stripe
On the solution:
- Stripe API: retrieve a PaymentIntent and expand the latest charge and its balance transaction. docs.stripe.com/api/payment_intents/retrieve
- Stripe docs: balance transaction fields, including
feeandnetin minor units. docs.stripe.com/api/balance_transactions/object - WooCommerce REST API: update an order's meta data 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 fix your reporting gap?
If this saved you a pile of manual reconciliation or a confusing payout gap, 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