Diagnostic Fees, payouts, and accounting
Match payouts to orders
The deposit lands in your bank account. It is one round number for one day. Nothing in WooCommerce tells you which orders are inside it, and the number itself never equals the sum of the order totals from that day anyway. Here is why a payout never lines up with your orders on its own, and a small report that matches every payout line back to a WooCommerce order and ties out to the cent.
A Stripe payout bundles many charges, refunds, and fees from different days into one deposit, in minor units, net of Stripe's cut, so it will never equal a simple sum of WooCommerce order totals. Run a small Python or Node.js report that lists every balance transaction inside one payout, reads the PaymentIntent id off each charge, matches it to the WooCommerce order whose _stripe_intent_id meta or transaction_id agrees, and compares the order total to the payout's net amount in cents. The report totals every line, confirms the payout ties out to the cent, and flags anything it could not match for a person to review. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce keeps one order per sale, each with its own total, in whatever currency and precision your store uses. Stripe keeps its own ledger, and every so often, daily for most stores, it bundles a batch of that ledger into a single payout and sends the cash to your bank.
That payout is not a copy of your order list. It is Stripe's own accounting, expressed in minor units, with its processing fee already taken out, any refunds and disputes from the last few days netted in, and everything converted from a set of individual charges into one line on your bank statement. Add up your WooCommerce orders for a given day and you will get a different number than the deposit, every time, because you are comparing two different things: gross order totals against a net, batched, time shifted settlement.
Why it happens
Stripe's own payout documentation describes exactly this shape, and it is by design, not a bug on either side. A few reasons the numbers never line up without help:
- A payout is net of Stripe's processing fee, so it is always smaller than the sum of the gross order totals it covers.
- A payout groups charges by when Stripe settles them, not by the calendar day the order was placed, so a payout can span parts of several days of orders, and an order placed near a cutoff can land in the next payout instead of the one you expect.
- Refunds and disputes are netted into whichever payout happens to be building at the time, which can pull money out of a payout that has no other connection to that order.
- WooCommerce order screens show the order total you charged. They do not show the balance transaction, the fee, or which payout a charge ended up in, so there is nothing in the admin to compare against without a report.
None of this means anything is wrong. It means a payout is an accounting object, not an order list, and the only way to know it is correct is to rebuild the order list from Stripe's own ledger and compare the two.
Do not try to compare a payout amount to a sum of WooCommerce totals directly. Compare it to the sum of the same payout's own balance transactions instead, then separately confirm that each charge in the payout matches the WooCommerce order it came from. A payout ties out when the report explains where every cent of it went, in minor units, not when a rough total looks close.
The fix, as a flow
We add a small report, run per payout or on a schedule shortly after each one lands, that lists every balance transaction Stripe grouped into that payout. For each charge line, it reads the PaymentIntent id and looks up the matching WooCommerce order. It compares the order total to the line's net amount in cents, and rolls every line, charges, fees, and refunds alike, into one summary that either ties out to the payout amount or tells you exactly how much and where it does not.
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 access to orders, plus write access if you want the report to leave order notes on mismatches. 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 TIE_OUT_TOLERANCE_MINOR="1" # cents of slack before flagging a payout
export DRY_RUN="true" # start safe, prints the report without writing anything
export PAYOUT_ID="po_1Nxxxxxxxxxxxxx"
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 TIE_OUT_TOLERANCE_MINOR="1" // cents of slack before flagging a payout
export DRY_RUN="true" // start safe, prints the report without writing anything
export PAYOUT_ID="po_1Nxxxxxxxxxxxxx"
List every balance transaction inside the payout
Stripe lets you filter balance transactions by the payout they belong to. This is the full, authoritative list of what that deposit contains: charges, refunds, disputes, and Stripe's own fee lines, each with a net amount already in minor units.
import os, stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def list_payout_transactions(payout_id):
for txn in stripe.BalanceTransaction.list(payout=payout_id, limit=100).auto_paging_iter():
yield txn
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function* listPayoutTransactions(payoutId) {
for await (const txn of stripe.balanceTransactions.list({ payout: payoutId, limit: 100 })) {
yield txn;
}
}
Find the PaymentIntent behind each charge line, then the order
A charge type balance transaction carries its PaymentIntent id under source.payment_intent. Use that id to search WooCommerce for the order whose saved _stripe_intent_id meta, or transaction_id when that is where a store keeps it, matches. This is the same lookup pattern used to detect orders marked paid with no matching charge, just run in the other direction, from the payout back to the order.
import 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"])
def source_intent_id(balance_txn):
source = balance_txn.get("source")
if isinstance(source, dict):
return source.get("payment_intent")
return None
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 get_order_by_intent(intent_id):
if not intent_id:
return None
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"search": intent_id, "per_page": 5},
auth=AUTH, timeout=30,
)
r.raise_for_status()
for order in r.json():
if intent_id_of(order) == intent_id:
return order
return None
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");
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();
}
export function sourceIntentId(balanceTxn) {
const source = balanceTxn.source;
if (source && typeof source === "object") return source.payment_intent || null;
return null;
}
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;
}
async function getOrderByIntent(intentId) {
if (!intentId) return null;
const matches = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
for (const order of matches) {
if (intentIdOf(order) === intentId) return order;
}
return null;
}
Classify each line, with one pure function
Keep the classification in its own function that takes a balance transaction and the order it matched to, and returns a status. A fee or refund line has no single order to tie to, so it is marked not_a_charge and still counted in the payout total. A charge with no PaymentIntent, or an intent with no matching order, is unusual enough to flag as unmatched or orphan. A charge whose order total agrees with the net amount, in minor units within a small tolerance, is matched. Anything else is a mismatch worth a second look.
CHARGE_TYPES = {"charge", "payment"}
TIE_OUT_TOLERANCE_MINOR = 1
def order_amount_minor(order):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since round(x * 100) is wrong for those.
return round(float(order["total"]) * 100)
def line_for(balance_txn, order):
net_minor = balance_txn.get("net", 0)
txn_type = balance_txn.get("type")
intent_id = source_intent_id(balance_txn)
row = {
"balance_transaction_id": balance_txn.get("id"),
"type": txn_type,
"net_minor": net_minor,
"intent_id": intent_id,
"order_id": order.get("id") if order else None,
}
if txn_type not in CHARGE_TYPES:
row["status"] = "not_a_charge"
row["note"] = f"'{txn_type}' line, included in the payout total but has no single order to match"
return row
if not intent_id:
row["status"] = "unmatched"
row["note"] = "no PaymentIntent on this balance transaction"
return row
if order is None:
row["status"] = "orphan"
row["note"] = f"no WooCommerce order has PaymentIntent {intent_id} on record"
return row
order_minor = order_amount_minor(order)
drift = order_minor - net_minor
if abs(drift) <= TIE_OUT_TOLERANCE_MINOR:
row["status"] = "matched"
row["note"] = "order total matches the net amount in the payout"
else:
row["status"] = "mismatch"
row["note"] = f"order total and payout net disagree by {drift} minor units"
return row
const CHARGE_TYPES = new Set(["charge", "payment"]);
const TIE_OUT_TOLERANCE_MINOR = 1;
export function orderAmountMinor(order) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since Math.round(x * 100) is wrong for those.
return Math.round(parseFloat(order.total) * 100);
}
export function lineFor(balanceTxn, order) {
const netMinor = balanceTxn.net ?? 0;
const txnType = balanceTxn.type;
const intentId = sourceIntentId(balanceTxn);
const row = {
balanceTransactionId: balanceTxn.id,
type: txnType,
netMinor,
intentId,
orderId: order ? order.id : null,
};
if (!CHARGE_TYPES.has(txnType)) {
row.status = "not_a_charge";
row.note = `'${txnType}' line, included in the payout total but has no single order to match`;
return row;
}
if (!intentId) {
row.status = "unmatched";
row.note = "no PaymentIntent on this balance transaction";
return row;
}
if (!order) {
row.status = "orphan";
row.note = `no WooCommerce order has PaymentIntent ${intentId} on record`;
return row;
}
const orderMinor = orderAmountMinor(order);
const drift = orderMinor - netMinor;
if (Math.abs(drift) <= TIE_OUT_TOLERANCE_MINOR) {
row.status = "matched";
row.note = "order total matches the net amount in the payout";
} else {
row.status = "mismatch";
row.note = `order total and payout net disagree by ${drift} minor units`;
}
return row;
}
Roll every line up into one payout summary
Add every matched and mismatched charge's net amount to every fee and refund line's net amount. That total should equal the payout's own amount to the cent, because the payout is built from exactly these balance transactions. If it does not, some line was missed or the payout query itself needs a second look.
def summarize(payout, rows):
matched_net = sum(r["net_minor"] for r in rows if r["status"] in ("matched", "mismatch"))
other_net = sum(r["net_minor"] for r in rows if r["status"] == "not_a_charge")
accounted_minor = matched_net + other_net
payout_minor = payout.get("amount", 0)
drift = payout_minor - accounted_minor
ties_out = abs(drift) <= TIE_OUT_TOLERANCE_MINOR
unmatched = [r for r in rows if r["status"] in ("unmatched", "orphan", "mismatch")]
return {
"payout_id": payout.get("id"),
"payout_amount_minor": payout_minor,
"accounted_minor": accounted_minor,
"drift_minor": drift,
"ties_out": ties_out,
"unmatched_count": len(unmatched),
}
export function summarize(payout, rows) {
const matchedNet = rows
.filter((r) => r.status === "matched" || r.status === "mismatch")
.reduce((sum, r) => sum + r.netMinor, 0);
const otherNet = rows
.filter((r) => r.status === "not_a_charge")
.reduce((sum, r) => sum + r.netMinor, 0);
const accountedMinor = matchedNet + otherNet;
const payoutMinor = payout.amount ?? 0;
const drift = payoutMinor - accountedMinor;
const tiesOut = Math.abs(drift) <= TIE_OUT_TOLERANCE_MINOR;
const unmatchedCount = rows.filter((r) =>
r.status === "unmatched" || r.status === "orphan" || r.status === "mismatch"
).length;
return {
payoutId: payout.id,
payoutAmountMinor: payoutMinor,
accountedMinor,
driftMinor: drift,
tiesOut,
unmatchedCount,
};
}
Wire it together, write a CSV, flag mismatches with a note
The full script ties every piece together, writes a CSV report for the payout, and only when DRY_RUN is off does it save the CSV to disk and add an order note on any mismatched order. Nothing about an order's status, total, or amount is ever changed. Run it manually per payout, or on a schedule shortly after payouts land, using the payout.paid webhook or a daily cron to pick up new payout ids.
Always start with DRY_RUN=true. The report only reads Stripe and WooCommerce in dry run mode. Once you trust the numbers for a few payouts, turn it off to also save the CSV and leave a note on any order the report could not tie out.
The full code
Here is the complete report builder in one file for each language. It reads settings from the environment, logs a one line summary per payout, respects the dry run flag, and is safe to run again and again because it only reads Stripe and WooCommerce until you explicitly turn dry run off.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Build a per payout report that ties a Stripe payout to the WooCommerce orders behind it.
A bank deposit is never the sum of the order totals you see in WooCommerce. Stripe groups
many charges, refunds, and fees into one payout, converts everything to minor units, and
settles a few days after the charge. Nothing in WooCommerce shows you that grouping. This
script reads one payout's balance transactions from Stripe, matches each charge to its
WooCommerce order by the saved PaymentIntent id, and builds a line by line report where the
payout total, the sum of the matched order net amounts, and Stripe's own totals all agree to
the cent. Any line that cannot be matched, or any payout that does not tie out, is flagged for
a person to look at. Read only by default. Run once per payout, or on a schedule shortly after
each payout lands.
"""
import os
import csv
import io
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("build_payout_report")
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"])
TIE_OUT_TOLERANCE_MINOR = int(os.environ.get("TIE_OUT_TOLERANCE_MINOR", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Balance transaction types that represent a customer charge landing in the payout.
CHARGE_TYPES = {"charge", "payment"}
# Types that reduce the payout but are not tied to a single order line.
ADJUSTING_TYPES = {"refund", "payment_refund", "adjustment", "stripe_fee"}
def source_intent_id(balance_txn):
"""The PaymentIntent id behind a balance transaction, when there is one."""
source = balance_txn.get("source")
if isinstance(source, dict):
return source.get("payment_intent")
return None
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
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 order_amount_minor(order):
"""Order total in minor units (cents). Two decimal currencies only; zero decimal
currencies such as JPY have their own guide, since round(x * 100) is wrong there."""
return round(float(order["total"]) * 100)
def line_for(balance_txn, order):
"""Pure decision: given one balance transaction from a payout and the WooCommerce
order it points to (or None), classify the line for the report. No I/O here, so
this is fully unit testable.
Returns a dict with the fields the report needs: txn id, type, net amount in minor
units, matched order id (or None), and a status explaining the match.
"""
net_minor = balance_txn.get("net", 0)
txn_type = balance_txn.get("type")
intent_id = source_intent_id(balance_txn)
row = {
"balance_transaction_id": balance_txn.get("id"),
"type": txn_type,
"net_minor": net_minor,
"intent_id": intent_id,
"order_id": order.get("id") if order else None,
}
if txn_type not in CHARGE_TYPES:
row["status"] = "not_a_charge"
row["note"] = f"'{txn_type}' line, included in the payout total but has no single order to match"
return row
if not intent_id:
row["status"] = "unmatched"
row["note"] = "no PaymentIntent on this balance transaction"
return row
if order is None:
row["status"] = "orphan"
row["note"] = f"no WooCommerce order has PaymentIntent {intent_id} on record"
return row
order_minor = order_amount_minor(order)
drift = order_minor - net_minor
if abs(drift) <= TIE_OUT_TOLERANCE_MINOR:
row["status"] = "matched"
row["note"] = "order total matches the net amount in the payout"
else:
row["status"] = "mismatch"
row["note"] = f"order total and payout net disagree by {drift} minor units"
return row
def summarize(payout, rows):
"""Pure roll up: does the report tie out to the cent for this payout."""
matched_net = sum(r["net_minor"] for r in rows if r["status"] in ("matched", "mismatch"))
other_net = sum(r["net_minor"] for r in rows if r["status"] == "not_a_charge")
accounted_minor = matched_net + other_net
payout_minor = payout.get("amount", 0)
drift = payout_minor - accounted_minor
ties_out = abs(drift) <= TIE_OUT_TOLERANCE_MINOR
unmatched = [r for r in rows if r["status"] in ("unmatched", "orphan", "mismatch")]
return {
"payout_id": payout.get("id"),
"payout_amount_minor": payout_minor,
"accounted_minor": accounted_minor,
"drift_minor": drift,
"ties_out": ties_out,
"unmatched_count": len(unmatched),
}
def list_payout_transactions(payout_id):
for txn in stripe.BalanceTransaction.list(payout=payout_id, limit=100).auto_paging_iter():
yield txn
def get_order_by_intent(intent_id):
if not intent_id:
return None
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"search": intent_id, "per_page": 5},
auth=AUTH, timeout=30,
)
r.raise_for_status()
for order in r.json():
if intent_id_of(order) == intent_id:
return order
return None
def write_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 build_report(payout_id):
payout = stripe.Payout.retrieve(payout_id)
rows = []
for txn in list_payout_transactions(payout_id):
intent_id = source_intent_id(txn)
order = get_order_by_intent(intent_id) if intent_id else None
rows.append(line_for(txn, order))
summary = summarize(payout, rows)
return summary, rows
def to_csv(summary, rows):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["payout_id", summary["payout_id"]])
writer.writerow(["payout_amount_minor", summary["payout_amount_minor"]])
writer.writerow(["accounted_minor", summary["accounted_minor"]])
writer.writerow(["drift_minor", summary["drift_minor"]])
writer.writerow(["ties_out", summary["ties_out"]])
writer.writerow([])
writer.writerow(["balance_transaction_id", "type", "net_minor", "intent_id", "order_id", "status", "note"])
for r in rows:
writer.writerow([r["balance_transaction_id"], r["type"], r["net_minor"], r["intent_id"],
r["order_id"], r["status"], r["note"]])
return buf.getvalue()
def run(payout_id):
summary, rows = build_report(payout_id)
log.info(
"Payout %s: amount %d, accounted %d, drift %d, ties out: %s, %d line(s) need review",
summary["payout_id"], summary["payout_amount_minor"], summary["accounted_minor"],
summary["drift_minor"], summary["ties_out"], summary["unmatched_count"],
)
report = to_csv(summary, rows)
if DRY_RUN:
log.info("Dry run, report generated but not written or annotated:\n%s", report)
return summary, rows
out_path = f"payout-{summary['payout_id']}.csv"
with open(out_path, "w", newline="") as f:
f.write(report)
log.info("Report written to %s", out_path)
for row in rows:
if row["status"] == "mismatch" and row["order_id"]:
write_note(
row["order_id"],
f"Payout reconciliation: order net does not match payout {summary['payout_id']} "
f"(drift {row['net_minor']} vs order total). Please review.",
)
return summary, rows
if __name__ == "__main__":
target_payout = os.environ.get("PAYOUT_ID")
if not target_payout:
raise SystemExit("Set PAYOUT_ID to the po_... id you want to reconcile.")
run(target_payout)
/**
* Build a per payout report that ties a Stripe payout to the WooCommerce orders behind it.
*
* A bank deposit is never the sum of the order totals you see in WooCommerce. Stripe groups
* many charges, refunds, and fees into one payout, converts everything to minor units, and
* settles a few days after the charge. Nothing in WooCommerce shows you that grouping. This
* script reads one payout's balance transactions from Stripe, matches each charge to its
* WooCommerce order by the saved PaymentIntent id, and builds a line by line report where the
* payout total, the sum of the matched order net amounts, and Stripe's own totals all agree to
* the cent. Any line that cannot be matched, or any payout that does not tie out, is flagged
* for a person to look at. Read only by default. Run once per payout, or on a schedule shortly
* after each payout lands.
*
* Guide: https://www.allanninal.dev/woocommerce/match-payouts-to-orders/
*/
import Stripe from "stripe";
import { writeFile } from "node:fs/promises";
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 TIE_OUT_TOLERANCE_MINOR = Number(process.env.TIE_OUT_TOLERANCE_MINOR || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Balance transaction types that represent a customer charge landing in the payout.
const CHARGE_TYPES = new Set(["charge", "payment"]);
export function sourceIntentId(balanceTxn) {
const source = balanceTxn.source;
if (source && typeof source === "object") return source.payment_intent || null;
return null;
}
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 orderAmountMinor(order) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since Math.round(x * 100) is wrong for those.
return Math.round(parseFloat(order.total) * 100);
}
/**
* Pure decision: given one balance transaction from a payout and the WooCommerce order
* it points to (or null), classify the line for the report. No I/O here, so this is
* fully unit testable.
*/
export function lineFor(balanceTxn, order) {
const netMinor = balanceTxn.net ?? 0;
const txnType = balanceTxn.type;
const intentId = sourceIntentId(balanceTxn);
const row = {
balanceTransactionId: balanceTxn.id,
type: txnType,
netMinor,
intentId,
orderId: order ? order.id : null,
};
if (!CHARGE_TYPES.has(txnType)) {
row.status = "not_a_charge";
row.note = `'${txnType}' line, included in the payout total but has no single order to match`;
return row;
}
if (!intentId) {
row.status = "unmatched";
row.note = "no PaymentIntent on this balance transaction";
return row;
}
if (!order) {
row.status = "orphan";
row.note = `no WooCommerce order has PaymentIntent ${intentId} on record`;
return row;
}
const orderMinor = orderAmountMinor(order);
const drift = orderMinor - netMinor;
if (Math.abs(drift) <= TIE_OUT_TOLERANCE_MINOR) {
row.status = "matched";
row.note = "order total matches the net amount in the payout";
} else {
row.status = "mismatch";
row.note = `order total and payout net disagree by ${drift} minor units`;
}
return row;
}
/** Pure roll up: does the report tie out to the cent for this payout. */
export function summarize(payout, rows) {
const matchedNet = rows
.filter((r) => r.status === "matched" || r.status === "mismatch")
.reduce((sum, r) => sum + r.netMinor, 0);
const otherNet = rows
.filter((r) => r.status === "not_a_charge")
.reduce((sum, r) => sum + r.netMinor, 0);
const accountedMinor = matchedNet + otherNet;
const payoutMinor = payout.amount ?? 0;
const drift = payoutMinor - accountedMinor;
const tiesOut = Math.abs(drift) <= TIE_OUT_TOLERANCE_MINOR;
const unmatchedCount = rows.filter((r) =>
r.status === "unmatched" || r.status === "orphan" || r.status === "mismatch"
).length;
return {
payoutId: payout.id,
payoutAmountMinor: payoutMinor,
accountedMinor,
driftMinor: drift,
tiesOut,
unmatchedCount,
};
}
async function* listPayoutTransactions(payoutId) {
for await (const txn of stripe.balanceTransactions.list({ payout: payoutId, limit: 100 })) {
yield txn;
}
}
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 getOrderByIntent(intentId) {
if (!intentId) return null;
const matches = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
for (const order of matches) {
if (intentIdOf(order) === intentId) return order;
}
return null;
}
async function writeNote(orderId, note) {
await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}
export async function buildReport(payoutId) {
const payout = await stripe.payouts.retrieve(payoutId);
const rows = [];
for await (const txn of listPayoutTransactions(payoutId)) {
const intentId = sourceIntentId(txn);
const order = intentId ? await getOrderByIntent(intentId) : null;
rows.push(lineFor(txn, order));
}
const summary = summarize(payout, rows);
return { summary, rows };
}
export function toCsv(summary, rows) {
const lines = [
`payout_id,${summary.payoutId}`,
`payout_amount_minor,${summary.payoutAmountMinor}`,
`accounted_minor,${summary.accountedMinor}`,
`drift_minor,${summary.driftMinor}`,
`ties_out,${summary.tiesOut}`,
"",
"balance_transaction_id,type,net_minor,intent_id,order_id,status,note",
];
for (const r of rows) {
lines.push([r.balanceTransactionId, r.type, r.netMinor, r.intentId, r.orderId, r.status, r.note].join(","));
}
return lines.join("\n");
}
export async function run(payoutId) {
const { summary, rows } = await buildReport(payoutId);
console.log(
`Payout ${summary.payoutId}: amount ${summary.payoutAmountMinor}, accounted ${summary.accountedMinor}, ` +
`drift ${summary.driftMinor}, ties out: ${summary.tiesOut}, ${summary.unmatchedCount} line(s) need review`
);
const report = toCsv(summary, rows);
if (DRY_RUN) {
console.log(`Dry run, report generated but not written or annotated:\n${report}`);
return { summary, rows };
}
const outPath = `payout-${summary.payoutId}.csv`;
await writeFile(outPath, report);
console.log(`Report written to ${outPath}`);
for (const row of rows) {
if (row.status === "mismatch" && row.orderId) {
await writeNote(
row.orderId,
`Payout reconciliation: order net does not match payout ${summary.payoutId} ` +
`(drift ${row.netMinor} vs order total). Please review.`
);
}
}
return { summary, rows };
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const payoutId = process.env.PAYOUT_ID;
if (!payoutId) {
console.error("Set PAYOUT_ID to the po_... id you want to reconcile.");
process.exit(1);
}
run(payoutId).catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The line classifier and the payout roll up are the parts most worth testing, because together they decide whether a payout is reported as clean or as needing review. Because we kept line_for and summarize pure, the tests need no network and no Stripe account. They just feed in plain objects and check the status and the totals.
from build_payout_report import line_for, summarize, intent_id_of, source_intent_id, order_amount_minor
def balance_txn(**over):
base = {
"id": "txn_1",
"type": "charge",
"net": 4850,
"source": {"payment_intent": "pi_1"},
}
base.update(over)
return base
def order(**over):
base = {"id": 501, "total": "50.00", "meta_data": [{"key": "_stripe_intent_id", "value": "pi_1"}]}
base.update(over)
return base
def test_matched_when_order_total_equals_net():
row = line_for(balance_txn(net=5000), order(total="50.00"))
assert row["status"] == "matched"
assert row["order_id"] == 501
def test_mismatch_when_order_total_disagrees_with_net():
row = line_for(balance_txn(net=4500), order(total="50.00"))
assert row["status"] == "mismatch"
assert "disagree" in row["note"]
def test_orphan_when_no_order_found():
row = line_for(balance_txn(), None)
assert row["status"] == "orphan"
def test_unmatched_when_balance_txn_has_no_intent():
row = line_for(balance_txn(source={"payment_intent": None}), None)
assert row["status"] == "unmatched"
def test_not_a_charge_for_fee_and_refund_lines():
assert line_for(balance_txn(type="stripe_fee", source=None), None)["status"] == "not_a_charge"
assert line_for(balance_txn(type="payment_refund", source=None), None)["status"] == "not_a_charge"
def test_tolerance_allows_one_cent_of_rounding():
row = line_for(balance_txn(net=4999), order(total="50.00"))
assert row["status"] == "matched"
def test_summarize_ties_out_when_charges_and_fees_cover_the_payout():
payout = {"id": "po_1", "amount": 9700}
rows = [
line_for(balance_txn(id="txn_1", net=5000), order(id=501, total="50.00")),
line_for(balance_txn(id="txn_2", net=4700, source={"payment_intent": "pi_2"}),
order(id=502, total="47.00", meta_data=[{"key": "_stripe_intent_id", "value": "pi_2"}])),
]
summary = summarize(payout, rows)
assert summary["ties_out"] is True
assert summary["drift_minor"] == 0
def test_summarize_flags_drift_when_payout_does_not_tie_out():
payout = {"id": "po_2", "amount": 10000}
rows = [line_for(balance_txn(net=5000), order(total="50.00"))]
summary = summarize(payout, rows)
assert summary["ties_out"] is False
assert summary["drift_minor"] == 5000
import { test } from "node:test";
import assert from "node:assert/strict";
import { lineFor, summarize, intentIdOf, sourceIntentId, orderAmountMinor } from "./build-payout-report.js";
const balanceTxn = (over = {}) => ({
id: "txn_1",
type: "charge",
net: 4850,
source: { payment_intent: "pi_1" },
...over,
});
const order = (over = {}) => ({
id: 501,
total: "50.00",
meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }],
...over,
});
test("matched when order total equals net", () => {
const row = lineFor(balanceTxn({ net: 5000 }), order({ total: "50.00" }));
assert.equal(row.status, "matched");
assert.equal(row.orderId, 501);
});
test("mismatch when order total disagrees with net", () => {
const row = lineFor(balanceTxn({ net: 4500 }), order({ total: "50.00" }));
assert.equal(row.status, "mismatch");
assert.match(row.note, /disagree/);
});
test("orphan when no order found", () => {
const row = lineFor(balanceTxn(), null);
assert.equal(row.status, "orphan");
});
test("not_a_charge for fee and refund lines", () => {
assert.equal(lineFor(balanceTxn({ type: "stripe_fee", source: null }), null).status, "not_a_charge");
assert.equal(lineFor(balanceTxn({ type: "payment_refund", source: null }), null).status, "not_a_charge");
});
test("tolerance allows one cent of rounding", () => {
const row = lineFor(balanceTxn({ net: 4999 }), order({ total: "50.00" }));
assert.equal(row.status, "matched");
});
test("summarize ties out when charges and fees cover the payout", () => {
const payout = { id: "po_1", amount: 9700 };
const rows = [
lineFor(balanceTxn({ id: "txn_1", net: 5000 }), order({ id: 501, total: "50.00" })),
lineFor(
balanceTxn({ id: "txn_2", net: 4700, source: { payment_intent: "pi_2" } }),
order({ id: 502, total: "47.00", meta_data: [{ key: "_stripe_intent_id", value: "pi_2" }] })
),
];
const summary = summarize(payout, rows);
assert.equal(summary.tiesOut, true);
assert.equal(summary.driftMinor, 0);
});
test("summarize flags drift when payout does not tie out", () => {
const payout = { id: "po_2", amount: 10000 };
const rows = [lineFor(balanceTxn({ net: 5000 }), order({ total: "50.00" }))];
const summary = summarize(payout, rows);
assert.equal(summary.tiesOut, false);
assert.equal(summary.driftMinor, 5000);
});
Case studies
The bookkeeper who reconciled by hand every month
A small store's bookkeeper spent half a day each month exporting orders, exporting the Stripe payout CSV, and matching them in a spreadsheet by amount and date, guessing at ties whenever two orders happened to add up to a similar number.
Running the report for each of the month's payouts replaced the spreadsheet entirely. Every charge line matched an order by PaymentIntent id instead of by a guessed amount, and the one payout that did not tie out turned out to be a dispute withheld from an unrelated order, found in minutes instead of missed for a quarter.
The order that landed in the wrong day's deposit
A customer checked out four minutes before Stripe's daily payout cutoff. The charge settled a day later than every other order placed that same afternoon, so it appeared in the next day's payout instead of the one the team expected when reconciling by date.
Because the report matches by payout id and PaymentIntent rather than by calendar day, that order showed up correctly matched and tied out in the payout it actually belonged to, with no manual chasing required.
After this runs per payout, a bank deposit stops being a mystery number and becomes a report you can hand to anyone: this payout is worth this much, these orders are inside it, these fees and refunds explain the rest, and it ties out to the cent. The handful of orders it cannot match become a short, specific list instead of a monthly spreadsheet exercise.
FAQ
Why does my bank deposit not match my WooCommerce order totals?
A Stripe payout is a batch of many charges, refunds, and fees settled a few days after the sale, converted to minor units and netted against each other. WooCommerce shows each order on its own and never shows the payout grouping, so the two numbers only agree once you match every payout line back to an order yourself.
How do I match a Stripe payout line back to a WooCommerce order?
Each balance transaction inside a payout can carry a PaymentIntent id. Read that id, then look up the WooCommerce order whose saved _stripe_intent_id meta or transaction_id matches it. Compare the order total to the balance transaction's net amount in minor units to confirm the match.
What does it mean for a payout to tie out?
A payout ties out when the sum of every matched charge's net amount, plus fees and refunds that are not tied to a single order, equals the payout's own total to within a cent. Any drift means a line was missed, a refund landed in a different payout, or an order needs a person to look at it.
Related field notes
Citations
On the problem:
- Stripe docs: how payouts work, including timing, batching, and currency conversion. docs.stripe.com/payouts
- Stripe docs: the balance transaction object, including
net,fee, andtype. docs.stripe.com/api/balance_transactions/object - WooCommerce docs: how the Stripe gateway records the order total and the saved PaymentIntent. woocommerce.com/document/stripe
On the solution:
- Stripe docs: list balance transactions, including filtering by a specific payout. docs.stripe.com/api/balance_transactions/list
- Stripe docs: retrieve a payout and read its own settled amount. docs.stripe.com/api/payouts/retrieve
- WooCommerce REST API: list and search orders, 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 untangle a payout for you?
If this saved you a spreadsheet marathon at month end, 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