Back to 0fee
0fee

Zero-Decimal Currencies: When 5000 Means 5000

How 0fee.dev handles zero-decimal currencies like XOF and JPY: the precision bugs we found and fixed, and why int(round()) matters. By Juste A. Gnimavo.

Thales & Claude | March 25, 2026 10 min 0fee
currenciesprecisionbugs

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:

CurrencyAmount MeaningStripe ExpectsConversion
USD $50.0050 dollars5000 (cents)Multiply by 100
EUR 50.0050 euros5000 (cents)Multiply by 100
XOF 50005000 francs5000 (as-is)No conversion
JPY 50005000 yen5000 (as-is)No conversion
GHS 50.0050 cedis5000 (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 providers

And 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 providers

Float-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 rounding

The 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:

  1. Transaction storage -- amounts being divided when coming from CinetPay (which sends XOF as whole numbers)
  2. Dashboard charts -- revenue graphs showing 1/100th of actual volume
  3. Invoice calculations -- line item amounts being divided before summing
  4. Webhook processing -- provider callback amounts being divided before comparison
  5. Fee calculation -- platform fee computed on 1/100th of the actual amount
  6. 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 amounts

The 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:

ProviderXOF 5000USD $50Notes
StripeSend 5000Send 5000 (cents)Consistent: always integer
CinetPaySend 5000N/A (Africa only)Always whole amounts
PaystackSend 5000Send 5000 (kobo)Uses subunits for NGN
FlutterwaveSend 5000Send 50 (units)Uses display amounts
PayPalN/ASend 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 formatted

Lessons Learned

  1. 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.
  1. 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.
  1. 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.
  1. 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.
  1. One set, shared everywhere. The ZERO_DECIMAL_CURRENCIES set 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles