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.

Python and Node.js Runs once or on a schedule Safe by default (dry run)
Assorted banknotes
Photo by Annie Spratt on Unsplash
The short answer

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.

Order total PY5,000 Checkout code total * 100 no currency check amount is 100x Stripe charges amount: 500000 = PY500,000 Buyer charged 100x the price
The order total is correct. The checkout code multiplies every currency by 100 the same way, and for a zero decimal currency that turns the real price into 100 times too much.

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:

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

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.

List paid orders zero decimal currency Load the Stripe PaymentIntent Work out the correct amount Charged ~100x? yes no, report only Refund the overcharge + note
The script only refunds when the charge matches the specific 100x pattern for a zero decimal currency. Any other mismatch is reported, not auto refunded.

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 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.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

step2.py
# 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
step2.js
// 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());
}
3

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.

step3.py
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
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");

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++;
  }
}
4

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.

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

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.

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

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.

Run it safe

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.

fix_zero_decimal_overcharge.py
"""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()
fix-zero-decimal-overcharge.js
/**
 * 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.

test_zerodecimal_overcharge_decide.py
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
fix-zero-decimal-overcharge.test.js
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

Expansion to Japan

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.

Marketplace sync

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.

What good looks like

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:

  1. Stripe docs: how amounts work per currency, and the full list of zero decimal currencies. docs.stripe.com/currencies
  2. Stripe docs: the PaymentIntent amount field is always in the currency's smallest unit. docs.stripe.com/api/payment_intents/object
  3. WooCommerce docs: currency settings and how order totals are stored per store. woocommerce.com/document/managing-currency-settings

On the solution:

  1. Stripe API: create a partial refund for a specific amount on a charge or PaymentIntent. docs.stripe.com/api/refunds/create
  2. Stripe API: retrieve a PaymentIntent to read its actual charged and refunded amounts. docs.stripe.com/api/payment_intents/retrieve
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes