Most payment developers eventually discover zero-decimal currencies the hard way -- through a bug. We discovered it when a test payment of 5,000 XOF showed up as $0.05 in the dashboard. That one bug taught us more about currency handling than any documentation ever could.
What Are Zero-Decimal Currencies?
Most currencies have subunits. The US dollar has cents: $50.00 is 5,000 cents. When Stripe processes $50.00, they expect the amount as 5000 (cents). When they process 50.00 EUR, they expect 5000 (cents).
But some currencies have no subunits -- or their subunits are so small they are never used in practice. 5,000 XOF is just 5,000 XOF. There are no "CFA centimes" in daily use. When you pass 5000 to a payment provider for XOF, you mean 5,000 XOF, not 50.00 XOF.
This distinction breaks every payment system that assumes "divide by 100 to display."
The Complete Zero-Decimal Set
0fee.dev maintains a definitive set of zero-decimal currencies:
pythonZERO_DECIMAL_CURRENCIES: set[str] = {
# African
"XOF", # West African CFA Franc (UEMOA: 8 countries)
"XAF", # Central African CFA Franc (CEMAC: 6 countries)
"GNF", # Guinean Franc
"UGX", # Ugandan Shilling
"TZS", # Tanzanian Shilling
"RWF", # Rwandan Franc
"BIF", # Burundian Franc
"DJF", # Djiboutian Franc
"KMF", # Comorian Franc
# Asian
"JPY", # Japanese Yen
"KRW", # South Korean Won
"VND", # Vietnamese Dong
# Other
"CLP", # Chilean Peso
"ISK", # Icelandic Krona
"PYG", # Paraguayan Guarani
"VUV", # Vanuatu Vatu
"XPF", # CFP Franc (French Pacific)
}This is not an exhaustive list of all zero-decimal currencies in the world, but it covers every currency we encounter through our 53+ providers. The set is referenced throughout the codebase for amount conversion, display formatting, and provider communication.
The Stripe Conversion Problem
Stripe is the most common provider that uses cent-based amounts for standard currencies. The conversion rules are:
| Currency | Amount Meaning | Stripe Expects | Conversion |
|---|---|---|---|
| USD $50.00 | 50 dollars | 5000 (cents) | Multiply by 100 |
| EUR 50.00 | 50 euros | 5000 (cents) | Multiply by 100 |
| XOF 5000 | 5000 francs | 5000 (as-is) | No conversion |
| JPY 5000 | 5000 yen | 5000 (as-is) | No conversion |
| GHS 50.00 | 50 cedis | 5000 (pesewas) | Multiply by 100 |
The conversion function:
pythondef to_provider_amount(amount: Decimal, currency: str, provider: str) -> int:
"""Convert our amount to what the provider expects."""
if provider == "stripe":
if currency.upper() in ZERO_DECIMAL_CURRENCIES:
# 5000 XOF -> 5000 (no conversion)
return int(round(float(amount)))
else:
# 50.00 USD -> 5000 (multiply by 100)
return int(round(float(amount) * 100))
elif provider in ("cinetpay", "paystack", "flutterwave"):
# These providers expect the amount as-is
return int(round(float(amount))) if currency in ZERO_DECIMAL_CURRENCIES \
else int(round(float(amount) * 100)) if provider == "paystack" \
else float(amount)
# ... other providersAnd the reverse, converting provider amounts back to our format:
pythondef from_provider_amount(amount: int, currency: str, provider: str) -> Decimal:
"""Convert provider amount back to our format."""
if provider == "stripe":
if currency.upper() in ZERO_DECIMAL_CURRENCIES:
# 5000 -> 5000 XOF (no conversion)
return Decimal(str(amount))
else:
# 5000 -> 50.00 USD (divide by 100)
return Decimal(str(amount)) / Decimal("100")
# ... other providersFloat-to-Int Precision: int(round(...))
The most insidious bug in currency handling is floating-point precision. Consider:
python# WRONG: Direct float-to-int
amount = 49.99
cents = int(amount * 100) # 4998! Not 4999!
# Why? Because:
49.99 * 100 = 4998.999999999999 (IEEE 754)
int(4998.999999999999) = 4998 # Truncation, not roundingThe fix is always int(round(...)):
python# CORRECT: Round before truncating
amount = 49.99
cents = int(round(amount * 100)) # 4999
# Or better yet, use Decimal throughout
from decimal import Decimal
amount = Decimal("49.99")
cents = int(amount * 100) # 4999 (exact)We enforce this pattern throughout the codebase:
pythondef safe_amount_to_int(amount: float | Decimal) -> int:
"""Safely convert a monetary amount to integer.
Always use this instead of bare int() on monetary values.
"""
return int(round(float(amount)))The $0.05 Bug (Session 017)
In Session 017, we caught a critical display bug. A 5,000 XOF payment was showing as $0.05 in the merchant dashboard. The root cause:
typescript// BUG: Dividing zero-decimal currency by 100
function formatAmount(amountInCents: number, currency: string): string {
const displayAmount = amountInCents / 100; // 5000 / 100 = 50.00
return `${currencySymbol(currency)}${displayAmount.toFixed(2)}`;
}
// 5000 XOF -> "CFA 50.00" (WRONG! Should be "CFA 5,000")But the bug was worse. The amount was being divided by 100 twice: once when stored, and once when displayed:
Provider returns: 5000 (XOF)
Storage: 5000 / 100 = 50.00 (WRONG -- treated as cents)
Display: 50.00 / 100 = 0.50 (DOUBLE WRONG)
Convert to USD: 0.50 * 0.0016 = $0.0008
Display: $0.00 (or $0.05 due to minimum display)The fix was straightforward but required touching every layer:
typescript// FIXED: Check zero-decimal before any division
function formatAmount(amount: number, currency: string): string {
if (ZERO_DECIMAL_CURRENCIES.has(currency)) {
// 5000 XOF -> "CFA 5,000"
return `${currencySymbol(currency)}${Math.round(amount).toLocaleString()}`;
}
// 5000 cents USD -> "$50.00"
const displayAmount = amount / 100;
return `${currencySymbol(currency)}${displayAmount.toFixed(2)}`;
}The Great /100 Purge (Session 066)
Session 017 fixed the immediate display bug, but we discovered in Session 066 that erroneous /100 divisions had spread throughout the codebase like a virus. Every developer instinct says "amounts from providers are in cents, divide by 100." When you work with African currencies daily, that instinct is wrong half the time.
The audit found /100 divisions in:
- Transaction storage -- amounts being divided when coming from CinetPay (which sends XOF as whole numbers)
- Dashboard charts -- revenue graphs showing 1/100th of actual volume
- Invoice calculations -- line item amounts being divided before summing
- Webhook processing -- provider callback amounts being divided before comparison
- Fee calculation -- platform fee computed on 1/100th of the actual amount
- Export/CSV -- downloaded transaction reports with wrong amounts
The fix was a systematic removal of every /100 that was applied unconditionally:
python# BEFORE (throughout codebase)
amount = provider_amount / 100 # Assumes cents for all currencies
# AFTER
amount = normalize_provider_amount(provider_amount, currency, provider)
def normalize_provider_amount(
raw_amount: int | float,
currency: str,
provider: str,
) -> Decimal:
"""Normalize a provider amount to our internal representation.
For zero-decimal currencies: no conversion.
For standard currencies: depends on provider format.
"""
if currency.upper() in ZERO_DECIMAL_CURRENCIES:
return Decimal(str(raw_amount))
# Standard currencies: check if provider sends cents or units
if provider in PROVIDERS_THAT_SEND_CENTS:
return Decimal(str(raw_amount)) / Decimal("100")
else:
return Decimal(str(raw_amount))
PROVIDERS_THAT_SEND_CENTS = {"stripe", "paystack"}
# Most African providers (CinetPay, Flutterwave, etc.) send unit amountsThe Frontend Zero-Decimal Set
The same set must exist on the frontend for display formatting:
typescriptexport const ZERO_DECIMAL_CURRENCIES = new Set([
'XOF', 'XAF', 'GNF', 'UGX', 'TZS', 'RWF', 'BIF', 'DJF', 'KMF',
'JPY', 'KRW', 'VND',
'CLP', 'ISK', 'PYG', 'VUV', 'XPF',
]);
export function formatCurrency(amount: number, currency: string): string {
const isZeroDecimal = ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase());
const symbol = getCurrencySymbol(currency);
if (isZeroDecimal) {
return `${symbol}${Math.round(amount).toLocaleString()}`;
}
return `${symbol}${amount.toFixed(2)}`;
}
export function parseCurrencyInput(input: string, currency: string): number {
const value = parseFloat(input.replace(/[^0-9.-]/g, ''));
if (isNaN(value)) return 0;
if (ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase())) {
return Math.round(value); // No decimals allowed
}
return Math.round(value * 100) / 100; // Two decimal places max
}Input Validation
The API validates amounts based on currency type:
pythondef validate_amount(amount: Decimal, currency: str) -> Decimal:
"""Validate and normalize a payment amount."""
if amount <= 0:
raise ValueError("Amount must be positive")
if currency.upper() in ZERO_DECIMAL_CURRENCIES:
# Must be a whole number
if amount != amount.to_integral_value():
raise ValueError(
f"{currency} is a zero-decimal currency. "
f"Amount must be a whole number (got {amount})"
)
return amount.to_integral_value()
else:
# Max two decimal places
if amount.as_tuple().exponent < -2:
raise ValueError(
f"Amount for {currency} can have at most 2 decimal places "
f"(got {amount})"
)
return amount.quantize(Decimal("0.01"))This catches errors at the API boundary before they propagate into the system. A request for 5000.50 XOF returns a clear validation error instead of silently truncating.
Provider-Specific Behavior
Different providers handle zero-decimal currencies differently, adding another layer of complexity:
| Provider | XOF 5000 | USD $50 | Notes |
|---|---|---|---|
| Stripe | Send 5000 | Send 5000 (cents) | Consistent: always integer |
| CinetPay | Send 5000 | N/A (Africa only) | Always whole amounts |
| Paystack | Send 5000 | Send 5000 (kobo) | Uses subunits for NGN |
| Flutterwave | Send 5000 | Send 50 (units) | Uses display amounts |
| PayPal | N/A | Send 50.00 (string) | String format, 2 decimals |
This inconsistency is why the to_provider_amount and from_provider_amount functions are provider-aware. There is no universal rule.
Testing Zero-Decimal Handling
We test zero-decimal handling explicitly:
pythonimport pytest
from decimal import Decimal
class TestZeroDecimalCurrencies:
def test_xof_no_conversion(self):
"""5000 XOF should stay 5000, not become 50.00."""
result = to_provider_amount(Decimal("5000"), "XOF", "stripe")
assert result == 5000
def test_usd_converts_to_cents(self):
"""50.00 USD should become 5000 cents."""
result = to_provider_amount(Decimal("50.00"), "USD", "stripe")
assert result == 5000
def test_xof_from_stripe(self):
"""Stripe returning 5000 for XOF should stay 5000."""
result = from_provider_amount(5000, "XOF", "stripe")
assert result == Decimal("5000")
def test_usd_from_stripe(self):
"""Stripe returning 5000 for USD should become 50.00."""
result = from_provider_amount(5000, "USD", "stripe")
assert result == Decimal("50.00")
def test_format_xof(self):
"""5000 XOF displays as CFA 5,000 not CFA 50.00."""
result = format_currency(5000, "XOF")
assert result == "CFA 5,000"
def test_format_usd(self):
"""50.00 USD displays as $50.00."""
result = format_currency(50.00, "USD")
assert result == "$50.00"
def test_float_precision(self):
"""49.99 * 100 should be 4999, not 4998."""
result = safe_amount_to_int(49.99 * 100)
assert result == 4999
def test_validate_xof_no_decimals(self):
"""XOF amounts must be whole numbers."""
with pytest.raises(ValueError, match="whole number"):
validate_amount(Decimal("5000.50"), "XOF")
def test_validate_usd_two_decimals(self):
"""USD amounts can have up to 2 decimal places."""
result = validate_amount(Decimal("50.99"), "USD")
assert result == Decimal("50.99")
@pytest.mark.parametrize("currency", ZERO_DECIMAL_CURRENCIES)
def test_all_zero_decimal_no_division(self, currency):
"""No zero-decimal currency should ever be divided by 100."""
amount = 5000
formatted = format_currency(amount, currency)
# Should show 5,000 or 5000, never 50.00
assert "50.00" not in formatted
assert "50" not in formatted or "5000" in formatted or "5,000" in formattedLessons Learned
- Zero-decimal currencies are not edge cases. In Africa, they are the norm. XOF and XAF alone cover 14 countries and hundreds of millions of people. Building a payment platform for Africa without handling zero-decimal currencies correctly is not building a payment platform for Africa.
- The /100 instinct is dangerous. Every payment tutorial, every Stripe example, every Stack Overflow answer teaches you to divide by 100. In a multi-currency system, that instinct must be replaced with a currency-aware function.
- Catch it at the boundary. Validate amounts at API ingestion and normalize immediately. A zero-decimal amount that enters the system as a float with decimals will cause problems at every layer it touches.
- Audit regularly. The Session 066 purge found issues that had been in production for weeks. Whenever you add a new display or calculation path, check whether it handles zero-decimal currencies.
- One set, shared everywhere. The
ZERO_DECIMAL_CURRENCIESset exists identically in Python and TypeScript. Any addition to one must be mirrored in the other. We keep them in sync manually -- a future improvement would be generating the TypeScript set from the Python source.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.