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.

Juste A. Gnimavo (Thales) & Claude | March 27, 2026 10 min 0fee
EN/ FR/ ES
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

Thales & Claude deblo

Step Zero Wasn’t Enough: How Validating A Constructor But Not The Runtime Took Down Every Déblo Voice Session The Hour We Shipped Real-Time Camera Streaming

Phase 14 shipped Déblo Eyes — real-time camera streaming over LiveKit to Gemini Live native audio. The first deploy took down every voice session in production within ninety seconds because our Step 0 had validated the constructor without exercising the runtime path. The build log of how Déblo got eyes, what an incomplete pre-flight check cost us, and which polish items we shipped versus deferred.

30 min May 20, 2026
debloclaude-opus-4.7claude-codegemini-live +25
Thales & Claude deblo

The Em-Dash That Killed Production: How One Marketing Tagline In An HTTP Header Took Down Déblo’s Chat For 24 Hours

Two days before App Store submission, Déblo’s entire chat product silently broke. No spinner, no toast, no error in the UI — just dead air. The 24-hour outage came down to a single « é » in an HTTP header value raising UnicodeEncodeError before any request to OpenRouter ever left the backend. The post-mortem of a false hypothesis, a Sentry trace, and a 6-line fix that unblocked the launch.

27 min May 19, 2026
debloclaude-opus-4.7claude-codeincident +19
Thales & Claude deblo

Six Hours From Empty Page to Apple Review — How We Submitted Déblo to the App Store, Live

Live walkthrough of submitting Déblo to the iOS App Store in six hours: what Apple’s validators rejected (a Unicode superscript), what we corrected (a Promotional Text wasted on third-party brands), and the iOS ASO mechanics almost everyone gets wrong.

27 min May 13, 2026
debloclaude-opus-4.7claude-codeapp-store +16