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.

Python and Node.js Read only by default Ties out to the cent
A stack of ten us dollar bills
Photo by Jason Leung on Unsplash
The short answer

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.

WooCommerce orders #101 $50, #102 $30, #103 $20, each on its own Stripe ledger charges, fees, refunds, bundled and netted nothing links them One bank deposit a single net number Books do not tie out
Orders live one at a time in WooCommerce. Stripe bundles many of them into one payout. Nothing connects the two on its own, so the deposit never matches a simple sum of order totals.

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:

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.

The key insight

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.

One payout id po_... List balance transactions in it Read PaymentIntent match to the order Cents match the order? yes no, flag it Add to report rolls up to tie out
The report only reads. It never touches an order's status or amount, it builds a line by line explanation of the payout that either ties out to the cent or points at exactly what still needs a person.

Build it step by step

1

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.

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 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"
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 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"
2

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.

step2.py
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
step2.js
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;
  }
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

decide.py
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
decide.js
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;
}
5

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.

summarize.py
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),
    }
summarize.js
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,
  };
}
6

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.

Run it safe

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_payout_report.py
"""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-payout-report.js
/**
 * 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.

test_match_payout_line.py
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
match-payout.test.js
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

Month end close

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.

Split payout

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.

What good looks like

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:

  1. Stripe docs: how payouts work, including timing, batching, and currency conversion. docs.stripe.com/payouts
  2. Stripe docs: the balance transaction object, including net, fee, and type. docs.stripe.com/api/balance_transactions/object
  3. WooCommerce docs: how the Stripe gateway records the order total and the saved PaymentIntent. woocommerce.com/document/stripe

On the solution:

  1. Stripe docs: list balance transactions, including filtering by a specific payout. docs.stripe.com/api/balance_transactions/list
  2. Stripe docs: retrieve a payout and read its own settled amount. docs.stripe.com/api/payouts/retrieve
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes