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.

Python and Node.js Runs on a schedule Adds meta only
A group of coins
Photo by Allison Saeng on Unsplash
The short answer

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.

Customer pays gross total Stripe takes fee net to your bank Woo saves gross fee not recorded Reports overstate
The fee is taken on Stripe's side. WooCommerce only records the gross, so profit reports are always a little too high.

Why it happens

This is not a bug, it is a gap between two systems. A few things make it matter:

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 key insight

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.

Scheduled job daily List paid orders without a fee yet Read balance txn fee and net from Stripe Fee found? yes no, skip Save fee + net as order meta
The job records the fee and net only for orders that do not have them yet, reading the true values from the Stripe balance transaction.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

fee.py
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")
fee.js
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;
  }
}
3

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.

order.py
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 [])
order.js
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);
}
4

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.

apply.py
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()
apply.js
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 },
      ],
    }),
  });
}
5

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.

Run it safe

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_fees.py
"""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-fees.js
/**
 * 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.

test_fees.py
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
fees.test.js
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

Thin margins

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.

Bookkeeping

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.

What good looks like

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:

  1. Stripe docs: pricing and the processing fee taken from each charge. stripe.com/pricing
  2. Stripe docs: the balance transaction that holds the fee and net for a charge. docs.stripe.com/api/balance_transactions/object
  3. WooCommerce docs: reports are built on order totals, which are gross. woocommerce.com/document/woocommerce-analytics

On the solution:

  1. Stripe API: retrieve a PaymentIntent and expand the charge and balance transaction. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe API: expanding responses to pull nested objects in one call. docs.stripe.com/expand
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes