Programming Languages Bug Report

Customers in Japan and Korea charged 100x: zero-decimal currencies multiplied by 100

We launched local pricing in JPY, KRW and VND and a customer in Osaka was charged 398,000 yen for a 3,980 yen course. Our code multiplied every price by 100 before sending it to Stripe. How we found it, refunded it and built a per-currency exponent table with tests.

Daniel Tran Daniel Tran @fun_dev · January 20, 2026 · 8 min read · 14 Views
Banner: customers in Japan and Korea charged 100 times the price because of zero-decimal currencies

We sell online language courses. In December we launched local-currency pricing for Japan, South Korea and Vietnam, with prices set by our marketing team in yen, won and dong rather than converted from USD on the fly. Three days into the launch, support received an email from a customer in Osaka with a screenshot of her card statement: 398,000 JPY for a course our checkout page showed as 3,980 JPY.

Our own database said the order total was 3980.00 JPY. Stripe's Dashboard said the PaymentIntent amount was ¥398,000. The API request log for that PaymentIntent made it plain:

POST /v1/payment_intents
  amount=398000
  currency=jpy
  automatic_payment_methods[enabled]=true
  metadata[order_id]=ord_8f21c0

Reproducing it

In Stripe test mode, any checkout in a zero-decimal currency reproduced it:

python manage.py shell -c "
from billing.checkout import create_payment_intent
from courses.models import Price
pi = create_payment_intent(Price.objects.get(currency='JPY', course__slug='japanese-n5'))
print(pi.amount, pi.currency)"
# 398000 jpy

The same call for a EUR price returned 3990 eur, which is correct for 39.90 EUR.

What we ruled out

  • A wrong price in the catalogue. The prices table held 3980.00 for that course in JPY, and the checkout page rendered it correctly.
  • Currency conversion. We were not converting at all for local prices; the price was set in yen.
  • A Stripe display issue. The amount on the customer's card statement matched the Stripe Dashboard, so the charge really was 398,000 yen.

Root cause

Our checkout code was written when we only sold in USD and EUR. It converted the decimal price to the provider's integer amount with one line: int(price.amount * 100). Stripe expects amounts in the currency's smallest unit, and for zero-decimal currencies such as JPY, KRW and VND that unit is the whole currency. Multiplying by 100 charged one hundred times the price. Most attempts were declined by issuers for exceeding limits or by Radar, which is why the first two days looked like "Japanese cards have a high decline rate". Thirty-one payments went through: 24 in JPY, 5 in KRW and 2 in VND.

What is happening under the hood

Payment APIs avoid floating point by taking an integer in the currency's minor unit. The number of minor-unit digits (the exponent) comes from ISO 4217, but each provider documents its own rules and exceptions.

Currency ISO 4217 minor units Stripe amount for one unit of the currency Our buggy value
USD 2 100 100 (correct by luck)
EUR 2 100 100 (correct by luck)
JPY 0 1 100
KRW 0 1 100
VND 0 1 100
ISK 0 100 (Stripe special case: two-decimal representation, decimals always 00) n/a
KWD 3 Check provider docs before supporting n/a

Stripe's documentation lists its zero-decimal currencies explicitly (BIF, CLP, DJF, GNF, JPY, KMF, KRW, MGA, PYG, RWF, UGX, VND, VUV, XAF, XOF and XPF) and has a special-cases section where the API representation differs from ISO 4217. ISK is the clearest example: zero decimals in ISO, but sent to Stripe as a two-decimal value. UGX appears in the zero-decimal list and also has a special-case note requiring a two-decimal value ending in 00. That kind of nuance is exactly why the exponent cannot be derived from a single rule.

The same mistake in the other direction affects reading amounts back. Our receipt emails divided Stripe webhook amounts by 100, so even correctly charged JPY orders would have shown ¥39.80.

Environment

Component Version / value
OS Ubuntu 24.04 LTS
Runtime Python 3.13
Framework Django 5.2 LTS
Payments SDK stripe-python 14.1
Price storage DecimalField(max_digits=12, decimal_places=2) plus currency CHAR(3)
Currencies live USD, EUR, GBP, then JPY, KRW, VND from 9 December 2025

Minimal reproduction

# billing/checkout.py (before)
def create_payment_intent(price):
    return stripe.PaymentIntent.create(
        amount=int(price.amount * 100),     # assumes every currency has 2 decimals
        currency=price.currency.lower(),
        automatic_payment_methods={"enabled": True},
    )

Who is affected

Any integration that hard-codes a factor of 100 when converting to or from minor units: checkout, refunds, partial captures, invoices, webhook processing, analytics and payout reports. Multi-provider setups are especially exposed because providers do not always agree with each other or with ISO 4217 on edge cases.

The fix

We introduced a single module that owns currency exponents, with an explicit allowlist. A currency that is not listed cannot be charged.

# billing/money.py
from decimal import Decimal, ROUND_HALF_UP

# ISO 4217 minor units for currencies we sell in.
ISO_MINOR_UNITS = {"USD": 2, "EUR": 2, "GBP": 2, "JPY": 0, "KRW": 0, "VND": 0}

# Stripe's API exponent where it differs from ISO 4217. Empty today; ISK and UGX would go here
# (exponent 2, decimals always 00) if we ever enable them, after testing in test mode.
STRIPE_EXPONENT_OVERRIDES: dict[str, int] = {}


class UnsupportedCurrency(ValueError):
    pass


def iso_exponent(currency: str) -> int:
    try:
        return ISO_MINOR_UNITS[currency.upper()]
    except KeyError:
        raise UnsupportedCurrency(currency) from None


def to_stripe_amount(amount: Decimal, currency: str) -> int:
    cur = currency.upper()
    iso = iso_exponent(cur)
    quantum = Decimal(1).scaleb(-iso)                  # 1, 0.01 or 0.001
    if amount.quantize(quantum) != amount:
        raise ValueError(f"{amount} has more precision than {cur} allows")
    exponent = STRIPE_EXPONENT_OVERRIDES.get(cur, iso)
    return int((amount * (Decimal(10) ** exponent)).to_integral_value(rounding=ROUND_HALF_UP))


def from_stripe_amount(value: int, currency: str) -> Decimal:
    cur = currency.upper()
    exponent = STRIPE_EXPONENT_OVERRIDES.get(cur, iso_exponent(cur))
    return Decimal(value).scaleb(-exponent)
-        amount=int(price.amount * 100),
+        amount=to_stripe_amount(price.amount, price.currency),

Refunds, receipts and the webhook handler switched to from_stripe_amount. The admin price form now validates against iso_exponent, so nobody can enter 3980.50 JPY.

For the 31 affected customers we refunded the difference immediately (99 percent of each charge, leaving the correct price), emailed an apology in their language with the refund ID, and gave each a free month. Two had already disputed the charge; we accepted both disputes and refunded the remaining 1 percent instead of contesting them.

Alternatives and trade-offs

  • Use a money library. Libraries such as py-moneyed or babel carry ISO 4217 data and remove hand-maintained tables. We still keep the provider override table, because no generic library knows Stripe's special cases.
  • Store integer minor units everywhere. Storing 3980 with an exponent alongside it avoids decimals in the database. It is a bigger migration; we kept DecimalField and validate precision at the edges.
  • Derive exponents from CLDR via Intl.NumberFormat on the frontend. CLDR display digits are for formatting, not for payment APIs, and they do not always match ISO 4217 or provider rules.

How to test the fix

Table-driven tests per currency, in both directions:

import pytest
from decimal import Decimal
from billing.money import to_stripe_amount, from_stripe_amount, UnsupportedCurrency

@pytest.mark.parametrize("amount,currency,expected", [
    (Decimal("39.90"), "EUR", 3990),
    (Decimal("19.99"), "USD", 1999),
    (Decimal("3980"),  "JPY", 3980),
    (Decimal("45000"), "KRW", 45000),
    (Decimal("1190000"), "VND", 1190000),
])
def test_to_stripe_amount(amount, currency, expected):
    assert to_stripe_amount(amount, currency) == expected
    assert from_stripe_amount(expected, currency) == amount

def test_rejects_fractional_yen():
    with pytest.raises(ValueError):
        to_stripe_amount(Decimal("3980.50"), "JPY")

def test_unknown_currency_is_refused():
    with pytest.raises(UnsupportedCurrency):
        to_stripe_amount(Decimal("100"), "KWD")
pytest billing/tests/test_money.py -q

We also ran one real test-mode checkout per enabled currency and compared the amount shown on our page with the PaymentIntent amount returned by Stripe.

Confirming in production

  • A query comparing each order total with the PaymentIntent amount (converted with from_stripe_amount) runs hourly. Since the fix it has found zero mismatches across all six currencies.
  • The JPY authorisation rate rose from 21 percent during the bug to 93 percent, in line with EUR. The "high decline rate" had been entirely our bug.
  • Receipt emails for JPY orders show ¥3,980, matching the card statement.

Regression guard

  • The parametrised test runs in CI, and a test asserts that every currency enabled in the price catalogue has an entry in ISO_MINOR_UNITS; enabling a new currency without one fails the build.
  • A lint rule rejects the literal patterns * 100 and / 100 inside the billing package.
  • An alert fires if any single PaymentIntent exceeds 20 times the most expensive catalogue price in that currency.

Security implications

No data was exposed, but overcharging is a consumer-protection and chargeback risk: disputes count against card network monitoring thresholds. The reverse bug (dividing by 100 for a zero-decimal currency) would undercharge and could be abused once noticed. The amount sent to the provider must always be computed server side from the catalogue, never accepted from the browser, so a client cannot pick its own amount or currency.

Prevention checklist

  • Never hard-code a factor of 100; look up the exponent per currency.
  • Keep an explicit allowlist of supported currencies with ISO and provider exponents.
  • Test every enabled currency in both directions, including refunds and webhook parsing.
  • Reconcile order totals against provider amounts automatically.
  • Read the provider's special cases before enabling a new currency.

References

Add a label

#django #python #stripe #currency #iso-4217
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.