Reconciler Fees, payouts, and accounting
Record Stripe fees on WooCommerce orders
Your WooCommerce reports say you made a certain amount this month. Your bank says less. The gap is the Stripe processing fee, taken on Stripe's side and never written back to the order, so every report you run shows revenue before fees and overstates your profit. Here is why the fee goes missing and a small job that reads it from Stripe and records the fee and net on each order, so your numbers finally reflect what you kept.
WooCommerce stores the gross total but not the Stripe fee, so reports overstate revenue. Run a small Python or Node.js job that walks recent paid orders, retrieves each order's PaymentIntent with the charge and its balance transaction expanded, reads the fee and net, and saves them onto the order as the meta _stripe_fee and _stripe_net. It skips orders that already have the fee, so it is safe to run again. Full code, tests, and a dry run guard are below.
The problem in plain words
When a customer pays, WooCommerce records the order total, the full amount they were charged. That is the gross. Stripe then takes its processing fee out of that amount before the money lands in your bank, and passes you the rest, the net.
WooCommerce never sees that fee. It happens on Stripe's side, and nothing writes it back to the order. So every WooCommerce report is built on gross figures. Your revenue looks bigger than the money you actually keep, and there is no easy way to see true profit per order without leaving WooCommerce and digging through Stripe.
Why it happens
This is not a bug, it is a gap between two systems. A few things make it matter:
- WooCommerce is an order system, not an accounting system, so it records what was charged, not what a processor kept.
- The fee lives on Stripe, on the balance transaction behind each charge, which WooCommerce does not read.
- Fees vary by card, country, and method, so you cannot just subtract a flat percentage and be right.
- Refunds and disputes change the fee too, so a rough estimate drifts over time.
Because the exact fee is only known on Stripe, the way to get real numbers into WooCommerce is to read the balance transaction and store the fee and net on the order. See the citations at the end for where Stripe keeps these values.
The gross is in WooCommerce, the fee is in Stripe. Neither system shows profit on its own. Once you copy the fee and net from Stripe onto the order, WooCommerce holds both numbers, and every report and export can show what you actually kept, not just what you charged.
The fix, as a flow
We do not change the order total or anything the customer sees. We add a job that walks recent paid orders, and for each one that does not already have the fee, reads the Stripe balance transaction behind its charge and saves the fee and net onto the order as meta. From then on, your reporting can use those fields.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair with read and write access to orders. The write access is only used to add two meta fields. 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="14"
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="14"
export DRY_RUN="true" // start safe, change to false to write
Read the fee and net from Stripe
The fee and net live on the balance transaction behind the charge. Retrieve the PaymentIntent and expand the latest charge and its balance transaction in one call. The values come back in minor units, so a small pure function converts them to your currency, and returns nothing if the transaction is not available yet.
import os, stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def fee_and_net(balance_transaction):
if not balance_transaction:
return None
fee = balance_transaction.get("fee")
net = balance_transaction.get("net")
if fee is None or net is None:
return None
return {"fee": round(fee / 100, 2), "net": round(net / 100, 2)}
def balance_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
return charge.get("balance_transaction")
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function feeAndNet(balanceTransaction) {
if (!balanceTransaction) return null;
const { fee, net } = balanceTransaction;
if (fee == null || net == null) return null;
return { fee: Math.round(fee) / 100, net: Math.round(net) / 100 };
}
async function balanceFor(intentId) {
if (!intentId) return null;
try {
const pi = await stripe.paymentIntents.retrieve(intentId, { expand: ["latest_charge.balance_transaction"] });
const charge = pi.latest_charge;
if (!charge || typeof charge === "string") return null;
return charge.balance_transaction;
} catch {
return null;
}
}
Find the order's intent and skip ones already recorded
The PaymentIntent id is saved on the order, usually as the meta _stripe_intent_id or the transaction id. Two more small pure functions read that id, and check whether the order already has a fee recorded so we never write it twice. Keeping these pure makes them easy to test.
FEE_META_KEY = "_stripe_fee"
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_recorded(order):
return any(m.get("key") == FEE_META_KEY for m in order.get("meta_data") or [])
const FEE_META_KEY = "_stripe_fee";
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 hasFeeRecorded(order) {
return (order.meta_data || []).some((m) => m.key === FEE_META_KEY);
}
Save the fee and net onto the order
When we have the values, write them to the order as two meta fields, _stripe_fee and _stripe_net. That is the whole change. It does not touch the total, the status, or anything the customer sees. Reports, exports, and dashboards can then read these fields to show real profit. The update goes through the REST API, so it works with High Performance Order Storage.
NET_META_KEY = "_stripe_net"
def save_fee(order_id, values):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"meta_data": [
{"key": FEE_META_KEY, "value": values["fee"]},
{"key": NET_META_KEY, "value": values["net"]},
]},
auth=AUTH, timeout=30,
).raise_for_status()
const NET_META_KEY = "_stripe_net";
async function saveFee(orderId, values) {
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: FEE_META_KEY, value: values.fee },
{ key: NET_META_KEY, value: values.net },
],
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together, paging through recent paid orders and skipping any that already have the fee. On the first run, leave DRY_RUN on so it only reports the fee and net it would record. Check a couple against the Stripe dashboard, then switch it off. A daily run keeps new orders up to date.
Start with DRY_RUN=true. This job only adds two meta fields and never touches the order total or status, so it is low risk, but confirming a few values against the Stripe dashboard first builds trust. Remember that a refund or dispute later changes the net, which has its own field note.
The full code
Here is the complete job 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 only records the fee for orders that do not already have it.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Record the Stripe fee and net amount on each WooCommerce order.
Run on a schedule. Safe to run again and again.
"""
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("record_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", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
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_recorded(order):
return any(m.get("key") == FEE_META_KEY for m in order.get("meta_data") or [])
def fee_and_net(balance_transaction):
if not balance_transaction:
return None
fee = balance_transaction.get("fee")
net = balance_transaction.get("net")
if fee is None or net is None:
return None
return {"fee": round(fee / 100, 2), "net": round(net / 100, 2)}
def balance_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
return charge.get("balance_transaction")
def get(path, params=None):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3{path}", params=params or {}, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def save_fee(order_id, values):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"meta_data": [
{"key": FEE_META_KEY, "value": values["fee"]},
{"key": NET_META_KEY, "value": values["net"]},
]},
auth=AUTH, timeout=30,
).raise_for_status()
def paid_orders():
after = f"{datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)}T00:00:00"
page = 1
while True:
batch = get("/orders", {"status": "processing,completed", "after": after, "per_page": 50, "page": page})
if not batch:
return
for order in batch:
yield order
page += 1
def run():
saved = 0
for order in paid_orders():
if has_fee_recorded(order):
continue
values = fee_and_net(balance_for(intent_id_of(order)))
if values is None:
continue
log.info("Order %s fee %.2f net %.2f. %s", order["id"], values["fee"], values["net"],
"would save" if DRY_RUN else "saving")
if not DRY_RUN:
save_fee(order["id"], values)
saved += 1
log.info("Done. %d order(s) %s.", saved, "to record" if DRY_RUN else "recorded")
if __name__ == "__main__":
run()
/**
* Record the Stripe fee and net amount on each WooCommerce order.
* Run on a schedule. Safe to run again and again.
*/
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 || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
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 hasFeeRecorded(order) {
return (order.meta_data || []).some((m) => m.key === FEE_META_KEY);
}
function feeAndNet(balanceTransaction) {
if (!balanceTransaction) return null;
const { fee, net } = balanceTransaction;
if (fee == null || net == null) return null;
return { fee: Math.round(fee) / 100, net: Math.round(net) / 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 balanceFor(intentId) {
if (!intentId) return null;
try {
const pi = await stripe.paymentIntents.retrieve(intentId, { expand: ["latest_charge.balance_transaction"] });
const charge = pi.latest_charge;
if (!charge || typeof charge === "string") return null;
return charge.balance_transaction;
} catch {
return null;
}
}
async function saveFee(orderId, values) {
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: FEE_META_KEY, value: values.fee },
{ key: NET_META_KEY, value: values.net },
],
}),
});
}
async function* paidOrders() {
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) yield order;
page++;
}
}
async function run() {
let saved = 0;
for await (const order of paidOrders()) {
if (hasFeeRecorded(order)) continue;
const values = feeAndNet(await balanceFor(intentIdOf(order)));
if (values === null) continue;
console.log(`Order ${order.id} fee ${values.fee} net ${values.net}. ${DRY_RUN ? "would save" : "saving"}`);
if (!DRY_RUN) await saveFee(order.id, values);
saved++;
}
console.log(`Done. ${saved} order(s) ${DRY_RUN ? "to record" : "recorded"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The conversion and the checks are the parts most worth testing, because they decide what gets written. Because they are pure, the tests need no network and no Stripe account. They just feed in plain objects and check the result.
from record_fees import intent_id_of, has_fee_recorded, fee_and_net
def test_intent_id_from_meta():
order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_1"}], "transaction_id": ""}
assert intent_id_of(order) == "pi_1"
def test_intent_id_none_when_charge_id():
assert intent_id_of({"meta_data": [], "transaction_id": "ch_3"}) is None
def test_has_fee_recorded_true():
assert has_fee_recorded({"meta_data": [{"key": "_stripe_fee", "value": "1.20"}]}) is True
def test_has_fee_recorded_false():
assert has_fee_recorded({"meta_data": [{"key": "_other", "value": "x"}]}) is False
def test_fee_and_net_converts_cents():
assert fee_and_net({"fee": 175, "net": 4825}) == {"fee": 1.75, "net": 48.25}
def test_fee_and_net_none_when_missing_transaction():
assert fee_and_net(None) is None
def test_fee_and_net_none_when_fields_absent():
assert fee_and_net({"fee": 100}) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { intentIdOf, hasFeeRecorded, feeAndNet } from "./record-fees.js";
test("intentIdOf from meta", () => {
assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }], transaction_id: "" }), "pi_1");
});
test("intentIdOf null when charge id", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "ch_3" }), null);
});
test("hasFeeRecorded true", () => {
assert.equal(hasFeeRecorded({ meta_data: [{ key: "_stripe_fee", value: "1.20" }] }), true);
});
test("hasFeeRecorded false", () => {
assert.equal(hasFeeRecorded({ meta_data: [{ key: "_other", value: "x" }] }), false);
});
test("feeAndNet converts cents", () => {
assert.deepEqual(feeAndNet({ fee: 175, net: 4825 }), { fee: 1.75, net: 48.25 });
});
test("feeAndNet null when missing transaction", () => {
assert.equal(feeAndNet(null), null);
});
test("feeAndNet null when fields absent", () => {
assert.equal(feeAndNet({ fee: 100 }), null);
});
Case studies
The store that thought it was doing better
A shop with low margins ran its monthly report and celebrated a good month, then saw a smaller number hit the bank. The gap was the processing fee on every order, which the report never subtracted. On thin margins that gap was most of the profit.
Once the job recorded the fee and net on each order, the team built a report on the net field. The real picture was tighter than they thought, and they made pricing changes they had been putting off.
The month end that stopped guessing
An accountant reconciled WooCommerce against Stripe payouts by hand every month, estimating fees with a rough percentage that never quite matched. The exact fee varied by card and country, so the estimate was always a little off.
The team ran the job in dry run, checked a sample against the Stripe dashboard, then let it record the true fee and net on every order. Month end reconciliation went from an estimate to an exact match.
After this runs on a schedule, every paid order carries its real Stripe fee and net right in WooCommerce. Profit reports stop overstating, month end reconciliation ties out to the cent, and you never have to leave WooCommerce to see what you actually kept. The gross was always there, now the net is too.
FAQ
Why do my WooCommerce reports not show Stripe fees?
WooCommerce records the order total, the gross amount the customer paid. The Stripe processing fee is taken on Stripe's side and is not written back to the order, so reports show revenue before fees. To see real profit you have to fetch the fee from Stripe and store it on the order yourself.
Where does Stripe keep the fee and net for a charge?
Each successful charge has a balance transaction that holds the fee and the net amount you actually receive, in minor units. You reach it by retrieving the PaymentIntent and expanding the latest charge and its balance transaction, then converting the values to your currency.
Is it safe to run this on a live store?
Yes. It only adds two meta fields, the fee and the net, to orders that do not have them yet, so it never changes the order total, status, or anything a customer sees. Start in dry run mode to review what it would record.
Related field notes
Citations
On the problem:
- Stripe docs: pricing and the processing fee taken from each charge. stripe.com/pricing
- Stripe docs: the balance transaction that holds the fee and net for a charge. docs.stripe.com/api/balance_transactions/object
- WooCommerce docs: reports are built on order totals, which are gross. woocommerce.com/document/woocommerce-analytics
On the solution:
- Stripe API: retrieve a PaymentIntent and expand the charge and balance transaction. docs.stripe.com/api/payment_intents/retrieve
- Stripe API: expanding responses to pull nested objects in one call. docs.stripe.com/expand
- WooCommerce REST API: update an order, including meta data. woocommerce.github.io/woocommerce-rest-api-docs (update order)
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 show your real numbers?
If this got the true fee and net onto your orders, 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