Repair Fees, payouts, and accounting
Presentment vs settlement currency
The buyer paid one currency and you settled another. WooCommerce stores a clean order total in the currency the buyer saw at checkout, and never asks what Stripe actually did with the money after that. Stripe can convert the charge into your payout currency at its own rate, on its own schedule, and nothing in WooCommerce writes that conversion down. Here is why the two numbers quietly disagree, and a small script that finds every affected order and makes the exchange explicit.
The order total is stored in the presentment currency, the currency the buyer chose or was shown at checkout. Stripe can settle that same charge into a different settlement currency, the currency your Stripe balance actually pays out in, converting it at its own exchange rate. WooCommerce has no field for that conversion, so your books show one number while your bank shows another. Run a small Python or Node.js job on a schedule that reads the Stripe balance transaction behind each paid order's PaymentIntent, checks whether the settlement currency differs from the order's presentment currency, and when it does, writes the real settled amount, currency, and exchange rate onto the order as meta plus a clear note. It never touches the order total or the order status. Full code, tests, and a dry run guard are below.
The problem in plain words
Say your store's default currency is EUR, but you also sell to buyers who pay in USD, GBP, or whatever currency your checkout is set up to show them. The order in WooCommerce records the total the buyer agreed to pay, in that presentment currency, and that is the number your order emails, your admin list, and your reports all use.
Stripe processes the charge in that same presentment currency, but your Stripe account has one settlement currency, the currency your balance and your payouts are actually held in. If a buyer pays in a currency your account does not settle in directly, Stripe converts the charge to your settlement currency using its own exchange rate at the moment of the charge, then takes its processing fee out of the converted amount. WooCommerce never asks Stripe for that number. It only ever sees the original order total in the presentment currency, so it has no idea the amount that landed in your account was smaller, larger, or in a different currency than what the order says.
Why it happens
Stripe's own documentation is explicit that the presentment currency, what the customer is charged in, and the settlement currency, the currency your balance holds and pays out in, are two separate concepts, and a charge only carries an exchange rate when they differ. A few reasons this trips up a WooCommerce store:
- The store accepts several currencies at checkout through WooCommerce Multi Currency or Stripe's own currency presentment, but the Stripe account only settles in one currency, so every foreign currency order gets converted somewhere Stripe controls, not WooCommerce.
- The order total field in WooCommerce is fixed the moment the order is placed. It is not a live link to Stripe, so it never updates when the charge later settles at a different value.
- The exchange rate lives on the Stripe balance transaction, one level below the charge and two levels below the PaymentIntent, so it is easy to write a report that reads the PaymentIntent amount and never notices the settlement side exists at all.
- Accounting teams reconcile payouts against order totals in the store's display currency, and the mismatch only surfaces as a mystery gap once someone tries to tie the bank deposit back to specific orders.
This is a known source of confusion in cross border WooCommerce stores, and Stripe's guidance on multi currency settlement exists specifically because merchants keep asking why their payout does not match their sales report. See the citations at the end for the exact references.
The order total in WooCommerce is the presentment amount, what the buyer agreed to pay. The Stripe balance transaction behind the charge is the settlement amount, what your account actually received, in whatever currency your account settles in. These are allowed to be different numbers in different currencies at the same time, and neither one is wrong. The fix is not to change the order total. It is to write the real settlement amount, currency, and exchange rate onto the order so your accounting has both numbers side by side.
The fix, as a flow
We do not touch the live checkout and we do not change the order total or the order status. We add a job that runs on a schedule, walks recent paid orders, and for each one asks Stripe for the balance transaction behind its PaymentIntent. If the settlement currency on that balance transaction differs from the order's presentment currency and Stripe reported a real exchange rate, we write the settled amount, the settlement currency, and the exchange rate onto the order as meta, plus a note so anyone looking at the order sees both sides of the conversion.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true" // start safe, change to false to write
Walk recent paid orders
Ask WooCommerce for orders that are Processing or Completed within your lookback window, paging through the results. These are the only orders worth checking, since an unpaid order has no settled charge behind it yet.
import os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
def paid_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
yield order
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
async function woo(path) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
headers: { "Content-Type": "application/json", Authorization: AUTH },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
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++;
}
}
Read the Stripe balance transaction behind the order
Find the PaymentIntent id in order meta _stripe_intent_id, or fall back to transaction_id when it looks like a PaymentIntent id. Expand the intent's latest charge and its balance transaction in one call, since the balance transaction is where the settlement currency, the settled amount, and the exchange rate actually live. The PaymentIntent's own amount is still in the presentment currency, so it is not enough on its own.
import stripe
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_balance_transaction(intent_id):
if not intent_id:
return None
try:
pi = stripe.PaymentIntent.retrieve(intent_id, expand=["latest_charge.balance_transaction"])
except stripe.error.InvalidRequestError:
return None
charge = pi.get("latest_charge")
if not charge or isinstance(charge, str):
return None
bt = charge.get("balance_transaction")
if not bt or isinstance(bt, str):
return None
return bt
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function getBalanceTransaction(intentId) {
if (!intentId) return null;
let pi;
try {
pi = await stripe.paymentIntents.retrieve(intentId, { expand: ["latest_charge.balance_transaction"] });
} catch {
return null;
}
const charge = pi.latest_charge;
if (!charge || typeof charge === "string") return null;
const bt = charge.balance_transaction;
if (!bt || typeof bt === "string") return null;
return bt;
}
Decide, with one pure function
Keep the decision in its own function that takes an order and a balance transaction and returns an action. It never makes a network call, so it is easy to test with plain objects. Skip orders that are not paid yet. Skip orders that already carry the settlement meta, so the job never writes the same order twice. Flag the rare case where the currencies differ but Stripe reported no exchange rate, since that needs a person to look. Otherwise, record the settlement.
PAID_STATUSES = {"processing", "completed"}
SETTLEMENT_META_KEY = "_stripe_settlement_amount"
def has_settlement_recorded(order):
return any(m.get("key") == SETTLEMENT_META_KEY for m in order.get("meta_data") or [])
def order_amount_minor(order):
# The order total in minor units, in the order's own presentment currency.
return round(float(order["total"]) * 100)
def decide(order, balance_transaction):
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state")
if has_settlement_recorded(order):
return ("skip", "settlement already recorded")
if balance_transaction is None:
return ("orphan", "no Stripe balance transaction found for a paid order")
settlement_currency = balance_transaction["currency"]
presentment_currency = order["currency"]
if settlement_currency.lower() == presentment_currency.lower():
return ("same-currency", "presentment and settlement currency match, nothing to reconcile")
exchange_rate = balance_transaction.get("exchange_rate")
if not exchange_rate:
return ("mismatch", "currencies differ but Stripe reported no exchange rate")
return ("record", "presentment and settlement currency differ, recording the real settled amount")
const PAID_STATUSES = new Set(["processing", "completed"]);
const SETTLEMENT_META_KEY = "_stripe_settlement_amount";
export function hasSettlementRecorded(order) {
return (order.meta_data || []).some((m) => m.key === SETTLEMENT_META_KEY);
}
export function orderAmountMinor(order) {
// The order total in minor units, in the order's own presentment currency.
return Math.round(parseFloat(order.total) * 100);
}
export function decide(order, balanceTransaction) {
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
if (hasSettlementRecorded(order)) return ["skip", "settlement already recorded"];
if (!balanceTransaction) return ["orphan", "no Stripe balance transaction found for a paid order"];
const settlementCurrency = balanceTransaction.currency;
const presentmentCurrency = order.currency;
if (settlementCurrency.toLowerCase() === presentmentCurrency.toLowerCase()) {
return ["same-currency", "presentment and settlement currency match, nothing to reconcile"];
}
const exchangeRate = balanceTransaction.exchange_rate;
if (!exchangeRate) {
return ["mismatch", "currencies differ but Stripe reported no exchange rate"];
}
return ["record", "presentment and settlement currency differ, recording the real settled amount"];
}
Record the settlement, not a status change
When the action is record, write the settled amount, the settlement currency, and the exchange rate onto the order as meta, then add a note so anyone opening the order sees both the presentment total and what actually settled. Nothing here touches status or total. This is purely additive information for reporting and reconciliation.
SETTLEMENT_CURRENCY_META_KEY = "_stripe_settlement_currency"
EXCHANGE_RATE_META_KEY = "_stripe_exchange_rate"
def record_settlement(order_id, balance_transaction):
note = (
f"Recorded settlement: {balance_transaction['amount'] / 100:.2f} "
f"{balance_transaction['currency'].upper()} at exchange rate "
f"{balance_transaction['exchange_rate']}. The order total is in a different "
f"presentment currency than what Stripe actually settled."
)
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"meta_data": [
{"key": SETTLEMENT_META_KEY, "value": balance_transaction["amount"]},
{"key": SETTLEMENT_CURRENCY_META_KEY, "value": balance_transaction["currency"]},
{"key": EXCHANGE_RATE_META_KEY, "value": str(balance_transaction["exchange_rate"])},
]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": note},
auth=AUTH, timeout=30,
).raise_for_status()
const SETTLEMENT_CURRENCY_META_KEY = "_stripe_settlement_currency";
const EXCHANGE_RATE_META_KEY = "_stripe_exchange_rate";
async function recordSettlement(orderId, balanceTransaction) {
const note =
`Recorded settlement: ${(balanceTransaction.amount / 100).toFixed(2)} ` +
`${balanceTransaction.currency.toUpperCase()} at exchange rate ` +
`${balanceTransaction.exchange_rate}. The order total is in a different ` +
`presentment currency than what Stripe actually settled.`;
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: SETTLEMENT_META_KEY, value: balanceTransaction.amount },
{ key: SETTLEMENT_CURRENCY_META_KEY, value: balanceTransaction.currency },
{ key: EXCHANGE_RATE_META_KEY, value: String(balanceTransaction.exchange_rate) },
],
}),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would record. Read the output, trust it, then switch it off to let it write. Since a recorded order is skipped on later runs, running it daily is safe and cheap.
Always start with DRY_RUN=true. This job writes order meta and notes on real orders, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.
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 skips any order that already carries the settlement meta.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Record the real settlement currency and amount behind each cross-currency order.
A buyer can check out in one currency (the presentment currency, what WooCommerce
shows and stores as the order total) while Stripe actually settles the charge into
your payout currency (the settlement currency) at its own exchange rate. WooCommerce
never sees that conversion, so your order total and your accounting books disagree
with what Stripe actually paid out. This walks recent paid orders, reads the Stripe
balance transaction behind each charge, and when the presentment currency does not
match the settlement currency, writes the settled amount, currency, and exchange
rate onto the order as meta so reports reconcile. It only writes orders that do not
already have the settlement meta recorded, so it is safe to run again and again.
Read only by default. Run on a schedule.
"""
import os
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_settlement_currency")
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SETTLEMENT_META_KEY = "_stripe_settlement_amount"
SETTLEMENT_CURRENCY_META_KEY = "_stripe_settlement_currency"
EXCHANGE_RATE_META_KEY = "_stripe_exchange_rate"
PAID_STATUSES = {"processing", "completed"}
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 has_settlement_recorded(order):
return any(m.get("key") == SETTLEMENT_META_KEY for m in order.get("meta_data") or [])
def order_amount_minor(order):
"""Order total in minor units, in the order's presentment currency."""
return round(float(order["total"]) * 100)
def decide(order, balance_transaction):
"""Pure decision: what to do with this order given its Stripe balance transaction.
balance_transaction looks like a Stripe BalanceTransaction: it carries the
settled `amount` and `currency` (the payout currency) plus, when the charge
was presented in a different currency, an `exchange_rate`. The presentment
amount and currency live on the order itself (order["total"], order["currency"]).
"""
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state")
if has_settlement_recorded(order):
return ("skip", "settlement already recorded")
if balance_transaction is None:
return ("orphan", "no Stripe balance transaction found for a paid order")
settlement_currency = balance_transaction["currency"]
presentment_currency = order["currency"]
if settlement_currency.lower() == presentment_currency.lower():
return ("same-currency", "presentment and settlement currency match, nothing to reconcile")
exchange_rate = balance_transaction.get("exchange_rate")
if not exchange_rate:
return ("mismatch", "currencies differ but Stripe reported no exchange rate")
return ("record", "presentment and settlement currency differ, recording the real settled amount")
def get_balance_transaction(intent_id):
if not intent_id:
return None
try:
pi = stripe.PaymentIntent.retrieve(intent_id, expand=["latest_charge.balance_transaction"])
except stripe.error.InvalidRequestError:
return None
charge = pi.get("latest_charge")
if not charge or isinstance(charge, str):
return None
bt = charge.get("balance_transaction")
if not bt or isinstance(bt, str):
return None
return bt
def paid_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
yield order
page += 1
def record_settlement(order_id, balance_transaction):
note = (
f"Recorded settlement: {balance_transaction['amount'] / 100:.2f} "
f"{balance_transaction['currency'].upper()} at exchange rate "
f"{balance_transaction['exchange_rate']}. The order total is in a different "
f"presentment currency than what Stripe actually settled."
)
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"meta_data": [
{"key": SETTLEMENT_META_KEY, "value": balance_transaction["amount"]},
{"key": SETTLEMENT_CURRENCY_META_KEY, "value": balance_transaction["currency"]},
{"key": EXCHANGE_RATE_META_KEY, "value": str(balance_transaction["exchange_rate"])},
]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": note},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
recorded = 0
for order in paid_orders():
balance_transaction = get_balance_transaction(intent_id_of(order))
action, reason = decide(order, balance_transaction)
if action == "orphan":
log.warning("Order %s: %s", order["id"], reason)
continue
if action in ("skip", "same-currency", "mismatch"):
if action == "mismatch":
log.warning("Order %s: %s", order["id"], reason)
continue
log.info("Order %s: %s. %s", order["id"], reason, "would record" if DRY_RUN else "recording")
if not DRY_RUN:
record_settlement(order["id"], balance_transaction)
recorded += 1
log.info("Done. %d order(s) %s.", recorded, "to record" if DRY_RUN else "recorded")
if __name__ == "__main__":
run()
/**
* Record the real settlement currency and amount behind each cross-currency order.
*
* A buyer can check out in one currency (the presentment currency, what WooCommerce
* shows and stores as the order total) while Stripe actually settles the charge into
* your payout currency (the settlement currency) at its own exchange rate. WooCommerce
* never sees that conversion, so the order total and your accounting books disagree
* with what Stripe actually paid out. This walks recent paid orders, reads the Stripe
* balance transaction behind each charge, and when the presentment currency does not
* match the settlement currency, writes the settled amount, currency, and exchange
* rate onto the order as meta so reports reconcile. It only writes orders that do not
* already have the settlement meta recorded, so it is safe to run again and again.
* Read only by default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/presentment-vs-settlement-currency/
*/
import Stripe from "stripe";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SETTLEMENT_META_KEY = "_stripe_settlement_amount";
const SETTLEMENT_CURRENCY_META_KEY = "_stripe_settlement_currency";
const EXCHANGE_RATE_META_KEY = "_stripe_exchange_rate";
const PAID_STATUSES = new Set(["processing", "completed"]);
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 hasSettlementRecorded(order) {
return (order.meta_data || []).some((m) => m.key === SETTLEMENT_META_KEY);
}
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
/**
* Pure decision: what to do with this order given its Stripe balance transaction.
*
* balanceTransaction looks like a Stripe BalanceTransaction: it carries the settled
* `amount` and `currency` (the payout currency) plus, when the charge was presented
* in a different currency, an `exchange_rate`. The presentment amount and currency
* live on the order itself (order.total, order.currency).
*/
export function decide(order, balanceTransaction) {
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
if (hasSettlementRecorded(order)) return ["skip", "settlement already recorded"];
if (!balanceTransaction) return ["orphan", "no Stripe balance transaction found for a paid order"];
const settlementCurrency = balanceTransaction.currency;
const presentmentCurrency = order.currency;
if (settlementCurrency.toLowerCase() === presentmentCurrency.toLowerCase()) {
return ["same-currency", "presentment and settlement currency match, nothing to reconcile"];
}
const exchangeRate = balanceTransaction.exchange_rate;
if (!exchangeRate) {
return ["mismatch", "currencies differ but Stripe reported no exchange rate"];
}
return ["record", "presentment and settlement currency differ, recording the real settled amount"];
}
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 getBalanceTransaction(intentId) {
if (!intentId) return null;
let pi;
try {
pi = await stripe.paymentIntents.retrieve(intentId, { expand: ["latest_charge.balance_transaction"] });
} catch {
return null;
}
const charge = pi.latest_charge;
if (!charge || typeof charge === "string") return null;
const bt = charge.balance_transaction;
if (!bt || typeof bt === "string") return null;
return bt;
}
async function* 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 recordSettlement(orderId, balanceTransaction) {
const note =
`Recorded settlement: ${(balanceTransaction.amount / 100).toFixed(2)} ` +
`${balanceTransaction.currency.toUpperCase()} at exchange rate ` +
`${balanceTransaction.exchange_rate}. The order total is in a different ` +
`presentment currency than what Stripe actually settled.`;
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: SETTLEMENT_META_KEY, value: balanceTransaction.amount },
{ key: SETTLEMENT_CURRENCY_META_KEY, value: balanceTransaction.currency },
{ key: EXCHANGE_RATE_META_KEY, value: String(balanceTransaction.exchange_rate) },
],
}),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
export async function run() {
let recorded = 0;
for await (const order of paidOrders()) {
const balanceTransaction = await getBalanceTransaction(intentIdOf(order));
const [action, reason] = decide(order, balanceTransaction);
if (action === "orphan") { console.warn(`Order ${order.id}: ${reason}`); continue; }
if (action === "skip" || action === "same-currency" || action === "mismatch") {
if (action === "mismatch") console.warn(`Order ${order.id}: ${reason}`);
continue;
}
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would record" : "recording"}`);
if (!DRY_RUN) await recordSettlement(order.id, balanceTransaction);
recorded++;
}
console.log(`Done. ${recorded} order(s) ${DRY_RUN ? "to record" : "recorded"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders get new financial meta written to them. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from record_settlement_currency import decide, intent_id_of, has_settlement_recorded
def balance_transaction(**over):
base = {"amount": 4520, "currency": "usd", "exchange_rate": 0.904}
base.update(over)
return base
def order(**over):
base = {"status": "processing", "total": "50.00", "currency": "eur", "meta_data": []}
base.update(over)
return base
def test_record_when_currencies_differ_and_rate_present():
assert decide(order(), balance_transaction())[0] == "record"
def test_skip_when_order_not_paid():
assert decide(order(status="pending"), balance_transaction())[0] == "skip"
def test_skip_when_already_recorded():
o = order(meta_data=[{"key": "_stripe_settlement_amount", "value": 4520}])
assert decide(o, balance_transaction())[0] == "skip"
def test_orphan_when_no_balance_transaction():
assert decide(order(), None)[0] == "orphan"
def test_same_currency_when_presentment_matches_settlement():
o = order(currency="usd")
bt = balance_transaction(currency="usd", exchange_rate=None)
assert decide(o, bt)[0] == "same-currency"
def test_mismatch_when_currencies_differ_but_no_exchange_rate():
o = order(currency="eur")
bt = balance_transaction(currency="usd", exchange_rate=None)
assert decide(o, bt)[0] == "mismatch"
def test_intent_id_from_meta():
o = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
assert intent_id_of(o) == "pi_123"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, hasSettlementRecorded } from "./record-settlement-currency.js";
const balanceTransaction = (over = {}) => ({ amount: 4520, currency: "usd", exchange_rate: 0.904, ...over });
const order = (over = {}) => ({ status: "processing", total: "50.00", currency: "eur", meta_data: [], ...over });
test("record when currencies differ and rate present", () => {
assert.equal(decide(order(), balanceTransaction())[0], "record");
});
test("skip when order not paid", () => {
assert.equal(decide(order({ status: "pending" }), balanceTransaction())[0], "skip");
});
test("skip when already recorded", () => {
const o = order({ meta_data: [{ key: "_stripe_settlement_amount", value: 4520 }] });
assert.equal(decide(o, balanceTransaction())[0], "skip");
});
test("orphan when no balance transaction", () => {
assert.equal(decide(order(), null)[0], "orphan");
});
test("same-currency when presentment matches settlement", () => {
const o = order({ currency: "usd" });
const bt = balanceTransaction({ currency: "usd", exchange_rate: null });
assert.equal(decide(o, bt)[0], "same-currency");
});
test("mismatch when currencies differ but no exchange rate", () => {
const o = order({ currency: "eur" });
const bt = balanceTransaction({ currency: "usd", exchange_rate: null });
assert.equal(decide(o, bt)[0], "mismatch");
});
test("intentIdOf from meta", () => {
assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});
Case studies
The payout that never matched the sales report
A store based in Ireland sold to UK buyers in GBP while its Stripe account settled in EUR. Every GBP order recorded a clean order total in pounds, but the euro amount that hit the bank was smaller and moved a little every day with the exchange rate. Finance spent hours each month trying to tie the payout to the sales report and always came up short by a different amount.
Running the job in dry run surfaced every GBP order with its exact settled euro amount and rate. Turning it on for real left a clear trail on each order, and the monthly close stopped being a guessing game.
The new currency nobody told accounting about
A store added a second presentment currency to widen its market, but nobody updated the reconciliation spreadsheet to expect it. The first week of orders in the new currency all looked correct in WooCommerce and all looked wrong once the payout landed, because nothing had ever recorded the settlement side.
Once the job ran on a daily schedule, every order in the new currency carried its own settlement amount and rate from day one, and the spreadsheet update became a formality instead of a scramble.
After this runs on a schedule, every cross currency order carries both numbers: what the buyer paid, and what actually settled into your account. A payout that does not match your sales report stops being a mystery, because the exchange rate that explains the gap is sitting right there on the order. Keep it running even after a busy season ends, since new currencies and new buyers keep showing up.
FAQ
Why does my Stripe payout not match the WooCommerce order total?
The buyer paid in the presentment currency, the currency WooCommerce shows and stores as the order total. Stripe can settle that charge into a different settlement currency, your payout currency, at its own exchange rate. WooCommerce never records that conversion, so the order total and the amount that actually reached your bank are two different numbers in two different currencies.
Is it safe to write the settlement amount onto the order with a script?
Yes, when the script only reads the Stripe balance transaction behind the order's own PaymentIntent, only writes orders that are already paid, and skips any order that already has the settlement meta recorded. It never changes the order total or the order status. Start in dry run mode to see the list before it writes.
How do I compare a presentment amount to a settlement amount without rounding bugs?
Keep both amounts in minor units, cents for most currencies, for as long as possible. Convert the WooCommerce order total to minor units and compare it directly to the balance transaction's amount in minor units, rather than comparing formatted currency strings, which is where rounding mistakes creep in.
Related field notes
Citations
On the problem:
- Stripe docs: presentment currency and settlement currency, and how a charge carries an exchange rate when they differ. docs.stripe.com/currencies
- Stripe docs: multi currency settlement and how your payout currency is fixed per Stripe account. docs.stripe.com/payouts
- WooCommerce docs: the order total field and how it is set at the time the order is placed. woocommerce.com/document/managing-orders
On the solution:
- Stripe API: retrieve a balance transaction and read its amount, currency, and exchange_rate fields. docs.stripe.com/api/balance_transactions/retrieve
- Stripe API: expand a PaymentIntent's latest_charge and balance_transaction in one request. docs.stripe.com/api/expanding_objects
- WooCommerce REST API: update an order's meta_data and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this untangle your payout report?
If this saved you a pile of accounting confusion or a long month end close, 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