Repair Charges and money
Zero decimal currency charged 100x
A store selling in yen or won looks fine in the WooCommerce admin, the order total reads PY5,000, but Stripe shows a charge of PY500,000. Nobody typed an extra two zeros. The checkout code did that, by treating every currency as if it has cents. Here is why zero decimal currencies get multiplied by 100 and a small script that finds every order this happened to and refunds the exact overcharge.
Stripe expects the amount you send it in the smallest unit of the currency. For USD, EUR, and most currencies that is cents, so you multiply the total by 100. JPY, KRW, VND, and a short list of other currencies have no smaller unit, so the total should be sent to Stripe as is. Code that always multiplies by 100, no matter the currency, charges every zero decimal order 100 times its real price. Run a small Python or Node.js script that checks recent paid orders in those currencies, compares the Stripe charge to the correct amount, and refunds the difference. Full code, tests, and a dry run guard are below.
The problem in plain words
Most currencies work in two levels. A US dollar has cents, a euro has cents, a British pound has pence. When a payment processor like Stripe asks for an "amount," it wants that smaller unit, not the big one. So $50.00 becomes 5000, because that is 5000 cents.
A handful of currencies do not have that smaller unit at all. The Japanese yen is the classic example. There is no such thing as one hundredth of a yen in daily use, so Stripe treats JPY, and currencies like it, as zero decimal. For those, the amount you send Stripe is just the number itself. PY5,000 is sent as 5000, not 500000.
The bug shows up when a plugin, a custom checkout, or a manual integration always does amount = total * 100 without checking the currency first. For USD that line is correct. For JPY it turns a PY5,000 purchase into a PY500,000 charge, one hundred times too much, and the customer's card statement is the first place anyone notices.
Why it happens
Stripe's own documentation is clear that "amount" is always the smallest currency unit, and that a short list of currencies are zero decimal, meaning they have no smaller unit at all. A few reasons this rule gets missed in real integrations:
- A custom checkout or a payment plugin hardcodes
amount = round(total * 100)as a shortcut, since that formula is correct for USD, EUR, and most of the currencies a developer tests with first. - A store adds a new currency for an overseas market, like JPY for Japan or KRW for Korea, and nobody revisits the amount conversion logic that was written for a dollar based store.
- The zero decimal list is easy to forget because it is short and unusual. JPY, KRW, VND, CLP, and a dozen others are on it, but most currencies a developer is familiar with are not.
- WooCommerce shows the order total exactly as entered, PY5,000, so the store owner sees the right number in the admin and has no reason to suspect the amount sent to Stripe was different.
This is a known and reported class of bug across payment integrations that assume every currency has cents. See the citations at the end for the exact currency list and how Stripe documents it.
The Stripe charge is the ground truth for how much money actually moved. If a paid order is in a zero decimal currency and the Stripe amount is close to exactly 100 times the order total, that is not a coincidence, it is the signature of this exact bug. A script that recognizes that pattern can find every affected order and refund precisely the extra 99 out of every 100 units taken.
The fix, as a flow
We do not change the checkout while it is live. We add a script that looks at recent paid orders in the zero decimal currencies, reads the matching Stripe PaymentIntent, and works out what the charge should have been. When the actual charge is about 100 times too high, we refund the difference and leave a clear note on the order. Anything that does not match that specific pattern is reported for a human to check instead of being touched automatically.
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="30"
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="30"
export DRY_RUN="true" // start safe, change to false to write
Keep the zero decimal currency list in one place
Stripe publishes the exact list of zero decimal currencies. JPY and KRW are the ones most stores run into, but the full list is longer. Put it in one constant so the rest of the code, and your tests, always agree on which currencies have no smaller unit.
# https://docs.stripe.com/currencies#zero-decimal
ZERO_DECIMAL_CURRENCIES = {
"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg",
"rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf",
}
def is_zero_decimal(currency):
return (currency or "").lower() in ZERO_DECIMAL_CURRENCIES
// https://docs.stripe.com/currencies#zero-decimal
const ZERO_DECIMAL_CURRENCIES = new Set([
"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg",
"rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf",
]);
function isZeroDecimal(currency) {
return ZERO_DECIMAL_CURRENCIES.has((currency || "").toLowerCase());
}
Load recent paid orders and their PaymentIntent
Use the WooCommerce REST API to page through orders that are Processing or Completed in your lookback window. Read the Stripe PaymentIntent id from order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent id. Then fetch that PaymentIntent from Stripe, which is the source of truth for what was actually charged.
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"])
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 paid_orders(lookback_days):
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");
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* paidOrders(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo orders returned ${res.status}`);
const batch = await res.json();
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Decide, with one pure function
Keep the decision in its own function that takes an order and a Stripe PaymentIntent and returns an action plus the exact overcharge in minor units. The rule only fires for a very specific shape of problem: a paid order, a zero decimal currency, a succeeded charge, and an amount that lands close to exactly 100 times the correct total. Anything else is skipped or flagged as a mismatch for a person to review, never auto refunded.
PAID_STATUSES = {"processing", "completed"}
def expected_minor_units(order_total, currency):
total = float(order_total)
if is_zero_decimal(currency):
return round(total)
return round(total * 100)
def decide(order, intent):
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state", 0)
if not is_zero_decimal(order.get("currency")):
return ("skip", "not a zero decimal currency", 0)
if intent is None:
return ("skip", "no Stripe PaymentIntent on this order", 0)
if intent.get("status") != "succeeded":
return ("skip", "Stripe payment did not succeed", 0)
charged = intent.get("amount_received", 0)
expected = expected_minor_units(order["total"], order.get("currency"))
if charged <= expected:
return ("ok", "charge matches the order total", 0)
if expected <= 0 or abs(charged - expected * 100) > max(1, expected // 100):
return ("mismatch", "overcharged but not by the 100x pattern", 0)
already_refunded = intent.get("amount_refunded", 0) or 0
overcharge = charged - expected
remaining = overcharge - already_refunded
if remaining <= 0:
return ("ok", "overcharge already refunded", 0)
return ("refund", "charged 100x the zero decimal total", remaining)
const PAID_STATUSES = new Set(["processing", "completed"]);
export function expectedMinorUnits(orderTotal, currency) {
const total = parseFloat(orderTotal);
if (isZeroDecimal(currency)) return Math.round(total);
return Math.round(total * 100);
}
export function decide(order, intent) {
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state", 0];
if (!isZeroDecimal(order.currency)) return ["skip", "not a zero decimal currency", 0];
if (!intent) return ["skip", "no Stripe PaymentIntent on this order", 0];
if (intent.status !== "succeeded") return ["skip", "Stripe payment did not succeed", 0];
const charged = intent.amount_received || 0;
const expected = expectedMinorUnits(order.total, order.currency);
if (charged <= expected) return ["ok", "charge matches the order total", 0];
if (expected <= 0 || Math.abs(charged - expected * 100) > Math.max(1, Math.floor(expected / 100))) {
return ["mismatch", "overcharged but not by the 100x pattern", 0];
}
const alreadyRefunded = intent.amount_refunded || 0;
const overcharge = charged - expected;
const remaining = overcharge - alreadyRefunded;
if (remaining <= 0) return ["ok", "overcharge already refunded", 0];
return ["refund", "charged 100x the zero decimal total", remaining];
}
Refund exactly the overcharge and leave a note
When the action is refund, issue a Stripe refund for the overcharge amount only, not the full charge, since the buyer still owes the correct price. Tag the refund with a reason and metadata so it is easy to find later, then add an order note explaining what happened, in plain words a shop manager can read.
def refund_overcharge(order, intent, overcharge_minor):
charge_id = intent.get("latest_charge") or intent["id"]
stripe.Refund.create(
payment_intent=intent["id"] if charge_id == intent["id"] else None,
charge=charge_id if charge_id != intent["id"] else None,
amount=overcharge_minor,
reason="duplicate",
metadata={"reason": "zero_decimal_currency_100x_overcharge", "order_id": str(order["id"])},
)
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Refunded a {overcharge_minor} unit overcharge caused by treating "
f"{order.get('currency')} as a two decimal currency. Stripe PaymentIntent "
f"{intent['id']}."},
auth=AUTH, timeout=30,
).raise_for_status()
async function refundOvercharge(order, intent, overchargeMinor) {
const chargeId = intent.latest_charge || intent.id;
await stripe.refunds.create({
...(chargeId === intent.id ? { payment_intent: intent.id } : { charge: chargeId }),
amount: overchargeMinor,
reason: "duplicate",
metadata: { reason: "zero_decimal_currency_100x_overcharge", order_id: String(order.id) },
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Refunded a ${overchargeMinor} unit overcharge caused by treating ` +
`${order.currency} as a two decimal currency. Stripe PaymentIntent ${intent.id}.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would refund and for how much. Read the output, check a few orders by hand in the Stripe dashboard, then switch it off to let it write.
Always start with DRY_RUN=true. This script issues real refunds, so you want to see its exact plan, order by order, before it acts. A refund cannot be undone the way a status change can, so treat the dry run report as required reading, not an optional step.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, keeps the zero decimal currency list in one place, respects the dry run flag, and only refunds when the specific 100x pattern is present, never for a general amount mismatch.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find and refund WooCommerce orders in a zero decimal currency (JPY and friends)
that were charged 100x too much on Stripe.
Stripe expects "amount" in the smallest unit of the currency. For two decimal
currencies like USD that is cents, so $50.00 is 5000. Zero decimal currencies such
as JPY, KRW, and VND have no smaller unit, so PY5000 is just 5000, not 500000. Code
that always multiplies the order total by 100 before sending it to Stripe overcharges
every zero decimal order by a factor of 100. This walks recent orders in the given
currencies, compares what Stripe actually charged to what the order should have cost,
and refunds the difference. Read only by default. Run once, or 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("fix_zero_decimal_overcharge")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# https://docs.stripe.com/currencies#zero-decimal
ZERO_DECIMAL_CURRENCIES = {
"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg",
"rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf",
}
PAID_STATUSES = {"processing", "completed"}
def is_zero_decimal(currency):
return (currency or "").lower() in ZERO_DECIMAL_CURRENCIES
def expected_minor_units(order_total, currency):
"""What Stripe's "amount" should be for this order total in this currency.
Zero decimal currencies use the total as is (PY5000 -> 5000). Every other
currency uses the total times 100 (rounded) the usual way ($50.00 -> 5000).
"""
total = float(order_total)
if is_zero_decimal(currency):
return round(total)
return round(total * 100)
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 decide(order, intent):
"""Pure decision: does this order need an overcharge refund, and for how much?
Returns a tuple of (action, reason, overcharge_minor). overcharge_minor is the
amount, in the intent's own minor units, that should be refunded. It is 0 unless
action is "refund".
"""
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state", 0)
if not is_zero_decimal(order.get("currency")):
return ("skip", "not a zero decimal currency", 0)
if intent is None:
return ("skip", "no Stripe PaymentIntent on this order", 0)
if intent.get("status") != "succeeded":
return ("skip", "Stripe payment did not succeed", 0)
charged = intent.get("amount_received", 0)
expected = expected_minor_units(order["total"], order.get("currency"))
if charged <= expected:
return ("ok", "charge matches the order total", 0)
# A 100x overcharge lands very close to charged / 100 == expected. Require
# that ratio (within a small tolerance) so we only touch the bug this script
# targets, not some unrelated pricing mismatch.
if expected <= 0 or abs(charged - expected * 100) > max(1, expected // 100):
return ("mismatch", "overcharged but not by the 100x pattern", 0)
already_refunded = intent.get("amount_refunded", 0) or 0
overcharge = charged - expected
remaining = overcharge - already_refunded
if remaining <= 0:
return ("ok", "overcharge already refunded", 0)
return ("refund", "charged 100x the zero decimal total", remaining)
def get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
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 refund_overcharge(order, intent, overcharge_minor):
charge_id = intent.get("latest_charge") or intent["id"]
stripe.Refund.create(
payment_intent=intent["id"] if charge_id == intent["id"] else None,
charge=charge_id if charge_id != intent["id"] else None,
amount=overcharge_minor,
reason="duplicate",
metadata={"reason": "zero_decimal_currency_100x_overcharge", "order_id": str(order["id"])},
)
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Refunded a {overcharge_minor} unit overcharge caused by treating "
f"{order.get('currency')} as a two decimal currency. Stripe PaymentIntent "
f"{intent['id']}."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
for order in paid_orders():
intent = get_intent(intent_id_of(order))
action, reason, overcharge_minor = decide(order, intent)
if action == "mismatch":
log.warning("Order %s: %s", order["id"], reason)
continue
if action != "refund":
continue
log.warning(
"Order %s: %s. Overcharge is %s minor units. %s",
order["id"], reason, overcharge_minor, "would refund" if DRY_RUN else "refunding",
)
if not DRY_RUN:
refund_overcharge(order, intent, overcharge_minor)
fixed += 1
log.info("Done. %d order(s) %s.", fixed, "to refund" if DRY_RUN else "refunded")
if __name__ == "__main__":
run()
/**
* Find and refund WooCommerce orders in a zero decimal currency (JPY and friends)
* that were charged 100x too much on Stripe.
*
* Stripe expects "amount" in the smallest unit of the currency. For two decimal
* currencies like USD that is cents, so $50.00 is 5000. Zero decimal currencies such
* as JPY, KRW, and VND have no smaller unit, so PY5000 is just 5000, not 500000. Code
* that always multiplies the order total by 100 before sending it to Stripe overcharges
* every zero decimal order by a factor of 100. This walks recent orders in the given
* currencies, compares what Stripe actually charged to what the order should have cost,
* and refunds the difference. Read only by default. Run once, or on a schedule.
*/
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 || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// https://docs.stripe.com/currencies#zero-decimal
const ZERO_DECIMAL_CURRENCIES = new Set([
"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg",
"rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf",
]);
const PAID_STATUSES = new Set(["processing", "completed"]);
export function isZeroDecimal(currency) {
return ZERO_DECIMAL_CURRENCIES.has((currency || "").toLowerCase());
}
export function expectedMinorUnits(orderTotal, currency) {
const total = parseFloat(orderTotal);
if (isZeroDecimal(currency)) return Math.round(total);
return Math.round(total * 100);
}
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 decide(order, intent) {
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state", 0];
if (!isZeroDecimal(order.currency)) return ["skip", "not a zero decimal currency", 0];
if (!intent) return ["skip", "no Stripe PaymentIntent on this order", 0];
if (intent.status !== "succeeded") return ["skip", "Stripe payment did not succeed", 0];
const charged = intent.amount_received || 0;
const expected = expectedMinorUnits(order.total, order.currency);
if (charged <= expected) return ["ok", "charge matches the order total", 0];
// A 100x overcharge lands very close to charged / 100 == expected. Require
// that ratio (within a small tolerance) so we only touch the bug this script
// targets, not some unrelated pricing mismatch.
if (expected <= 0 || Math.abs(charged - expected * 100) > Math.max(1, Math.floor(expected / 100))) {
return ["mismatch", "overcharged but not by the 100x pattern", 0];
}
const alreadyRefunded = intent.amount_refunded || 0;
const overcharge = charged - expected;
const remaining = overcharge - alreadyRefunded;
if (remaining <= 0) return ["ok", "overcharge already refunded", 0];
return ["refund", "charged 100x the zero decimal total", remaining];
}
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 getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
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 refundOvercharge(order, intent, overchargeMinor) {
const chargeId = intent.latest_charge || intent.id;
await stripe.refunds.create({
...(chargeId === intent.id ? { payment_intent: intent.id } : { charge: chargeId }),
amount: overchargeMinor,
reason: "duplicate",
metadata: { reason: "zero_decimal_currency_100x_overcharge", order_id: String(order.id) },
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Refunded a ${overchargeMinor} unit overcharge caused by treating ` +
`${order.currency} as a two decimal currency. Stripe PaymentIntent ${intent.id}.`,
}),
});
}
export async function run() {
let fixed = 0;
for await (const order of paidOrders()) {
const intent = await getIntent(intentIdOf(order));
const [action, reason, overchargeMinor] = decide(order, intent);
if (action === "mismatch") {
console.warn(`Order ${order.id}: ${reason}`);
continue;
}
if (action !== "refund") continue;
console.warn(
`Order ${order.id}: ${reason}. Overcharge is ${overchargeMinor} minor units. ` +
`${DRY_RUN ? "would refund" : "refunding"}`
);
if (!DRY_RUN) await refundOvercharge(order, intent, overchargeMinor);
fixed++;
}
console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to refund" : "refunded"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders get a real refund. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action and the refund amount.
from fix_zero_decimal_overcharge import decide, is_zero_decimal, expected_minor_units
def intent(**over):
base = {"id": "pi_1", "status": "succeeded", "amount_received": 500000, "amount_refunded": 0}
base.update(over)
return base
def jpy_order(**over):
base = {"status": "processing", "currency": "JPY", "total": "5000"}
base.update(over)
return base
def test_refund_when_jpy_order_charged_100x():
action, reason, overcharge = decide(jpy_order(), intent())
assert action == "refund"
assert overcharge == 495000
def test_ok_when_jpy_order_charged_the_right_amount():
order = jpy_order()
charge = intent(amount_received=5000)
assert decide(order, charge)[0] == "ok"
def test_skip_when_currency_is_not_zero_decimal():
order = {"status": "processing", "currency": "USD", "total": "50.00"}
assert decide(order, intent(amount_received=5000))[0] == "skip"
def test_mismatch_when_overcharge_is_not_the_100x_pattern():
action, reason, overcharge = decide(jpy_order(), intent(amount_received=5100))
assert action == "mismatch"
assert overcharge == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./fix-zero-decimal-overcharge.js";
const intent = (over = {}) => ({
id: "pi_1", status: "succeeded", amount_received: 500000, amount_refunded: 0, ...over,
});
const jpyOrder = (over = {}) => ({ status: "processing", currency: "JPY", total: "5000", ...over });
test("refund when JPY order charged 100x", () => {
const [action, , overcharge] = decide(jpyOrder(), intent());
assert.equal(action, "refund");
assert.equal(overcharge, 495000);
});
test("ok when JPY order charged the right amount", () => {
assert.equal(decide(jpyOrder(), intent({ amount_received: 5000 }))[0], "ok");
});
test("skip when currency is not zero decimal", () => {
const order = { status: "processing", currency: "USD", total: "50.00" };
assert.equal(decide(order, intent({ amount_received: 5000 }))[0], "skip");
});
test("mismatch when overcharge is not the 100x pattern", () => {
const [action, , overcharge] = decide(jpyOrder(), intent({ amount_received: 5100 }));
assert.equal(action, "mismatch");
assert.equal(overcharge, 0);
});
Case studies
The store that added yen and forgot the cents math
A shop that had run USD only for years added JPY to sell directly to Japanese customers. The developer copied the existing checkout code, which multiplied every total by 100 before sending it to Stripe. It worked in testing because the test card was charged in USD by habit. The first real yen order went through Stripe at 100 times its listed price.
The script found every affected JPY order from the two weeks since launch, refunded the exact overcharge on each one, and the team fixed the currency check in the checkout the same day.
The KRW orders synced from a spreadsheet
A store imported orders from an offline sales channel into WooCommerce and pushed the matching charge to Stripe through a small internal tool. The tool's amount conversion was written once, for USD, and never updated when the team started supporting KRW for a Korean reseller.
Running the script in dry run mode surfaced twelve affected orders with the exact refund amount for each, which made the conversation with the reseller's finance team straightforward instead of a dispute.
After this runs, every zero decimal order that was overcharged gets exactly its overcharge back, with a note on the order explaining why. The checkout bug still needs a real code fix so it stops happening, but this script closes out the orders that were already affected without guessing or over refunding.
FAQ
Why did my JPY or KRW order get charged 100 times too much?
Stripe expects amounts in the smallest unit of the currency. For USD that is cents, so the total is multiplied by 100. JPY, KRW, and a handful of other currencies have no smaller unit, so the total should be sent as is. Code that always multiplies by 100 overcharges every zero decimal order by a factor of 100.
How do I know which currencies are zero decimal?
Stripe publishes the exact list in its currency docs. Common ones are JPY, KRW, VND, CLP, and XOF. The fix script keeps this list in one place so it is easy to check and update.
Is it safe to run a script that issues refunds automatically?
Yes, when it only acts on a very specific pattern: a zero decimal currency, a succeeded Stripe charge, and an amount that is almost exactly 100 times the order total. Anything else is left alone and reported as a mismatch. Start in dry run mode to review the list of refunds before it writes.
Related field notes
Citations
On the problem:
- Stripe docs: how amounts work per currency, and the full list of zero decimal currencies. docs.stripe.com/currencies
- Stripe docs: the PaymentIntent amount field is always in the currency's smallest unit. docs.stripe.com/api/payment_intents/object
- WooCommerce docs: currency settings and how order totals are stored per store. woocommerce.com/document/managing-currency-settings
On the solution:
- Stripe API: create a partial refund for a specific amount on a charge or PaymentIntent. docs.stripe.com/api/refunds/create
- Stripe API: retrieve a PaymentIntent to read its actual charged and refunded amounts. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: list orders by status 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 catch an overcharge for you?
If this saved a customer's trust or a chargeback, 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