Back to 0fee
0fee

Amount Display Bugs: When 5 USD Shows as 0.05

The amount display bugs that plagued 0fee.dev: wrong divisions, integer vs float, and how we fixed them across 50+ files. By Juste A. Gnimavo.

Thales & Claude | March 25, 2026 8 min 0fee
currencybugsamount-displayformattingfintech

If there is one category of bug that defined the 0fee.dev development experience, it is amount display bugs. They appeared in Session 017, resurfaced in Session 062, and were not fully eradicated until Session 066 -- a 49-session span during which the same fundamental problem kept manifesting in new forms.

The root cause was simple: confusion about whether amounts were stored in major units (dollars, euros) or minor units (cents, centimes). But "simple" root causes produce complex bug patterns when they propagate through 50+ files.

The formatAmount Bug: Session 017

The first manifestation appeared in the dashboard. A transaction for $5.00 displayed as $0.05. A transaction for 5,000 XOF displayed as 50 XOF.

typescript// BEFORE: The bug (Session 017)
function formatAmount(amount: number, currency: string): string {
    // Assumed amounts were stored in cents
    const displayAmount = amount / 100;
    return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: currency,
    }).format(displayAmount);
}

The function divided by 100 because the original design stored amounts in the smallest currency unit (cents for USD, centimes for EUR). But somewhere during development, the storage format changed to major units ($5.00, not 500 cents). The formatAmount function was never updated.

typescript// AFTER: Fixed
function formatAmount(amount: number, currency: string): string {
    // Amounts are stored in major units (dollars, not cents)
    return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: currency,
    }).format(amount);
}

This fix resolved the immediate display issue, but it was the beginning of a longer saga.

The Integer to Float Migration: Session 062

Session 062 revealed that the database schema stored amounts as integers in several tables. This worked when amounts were in minor units (500 cents = $5.00), but after the switch to major units, integer storage truncated decimal amounts:

python# BEFORE: Integer storage truncated decimals
class Transaction(Base):
    amount = Column(Integer)  # $4.99 stored as 4, not 4.99
python# AFTER: Float storage preserves decimals
class Transaction(Base):
    amount = Column(Float)  # $4.99 stored as 4.99

This migration affected every table that stored monetary amounts:

TableColumns Changed
transactionsamount, source_amount, destination_amount
invoicesamount, tax_amount, total_amount
invoice_itemsamount, unit_price
feesamount
wallet_transactionsamount
couponsmin_amount, max_discount
payment_methodsmin_amount, max_amount
payment_linksamount
billing_cyclestotal_fees, total_volume

The CURRENCY_DECIMALS Map

Different currencies have different decimal places. USD has 2 (dollars and cents). JPY has 0 (no sub-unit). BHD has 3 (dinar and fils). This information is essential for correct display and correct provider communication:

python# utils/currency.py
CURRENCY_DECIMALS = {
    # Standard 2-decimal currencies
    "USD": 2, "EUR": 2, "GBP": 2, "CAD": 2, "AUD": 2,
    "CHF": 2, "ZAR": 2, "NGN": 2, "KES": 2, "GHS": 2,

    # Zero-decimal currencies
    "XOF": 0, "XAF": 0, "JPY": 0, "KRW": 0, "VND": 0,
    "CLP": 0, "PYG": 0, "UGX": 0, "RWF": 0, "BIF": 0,
    "DJF": 0, "GNF": 0, "KMF": 0, "MGA": 0,

    # Three-decimal currencies
    "BHD": 3, "KWD": 3, "OMR": 3, "TND": 3, "LYD": 3,
}

def get_decimals(currency: str) -> int:
    """Get the number of decimal places for a currency."""
    return CURRENCY_DECIMALS.get(currency.upper(), 2)  # Default to 2

This map is critical because payment providers like Stripe require amounts in the smallest unit. For USD, $5.00 becomes 500 (cents). For XOF, 5000 stays 5000 (no sub-unit). For BHD, 5.000 becomes 5000 (fils).

to_provider_smallest_unit() and from_provider_smallest_unit()

The conversion between our storage format (major units) and provider format (smallest units) needed to be consistent and currency-aware:

python# utils/currency.py
def to_provider_smallest_unit(amount: float, currency: str) -> int:
    """Convert from major units to smallest unit for provider APIs.

    Examples:
        to_provider_smallest_unit(5.00, "USD") -> 500    (cents)
        to_provider_smallest_unit(5000, "XOF") -> 5000   (no conversion)
        to_provider_smallest_unit(5.000, "BHD") -> 5000  (fils)
    """
    decimals = get_decimals(currency)
    multiplier = 10 ** decimals
    return int(round(amount * multiplier))

def from_provider_smallest_unit(amount: int, currency: str) -> float: """Convert from provider's smallest unit back to major units. BLANK Examples: from_provider_smallest_unit(500, "USD") -> 5.00 from_provider_smallest_unit(5000, "XOF") -> 5000.0 from_provider_smallest_unit(5000, "BHD") -> 5.000 """ decimals = get_decimals(currency) divisor = 10 ** decimals return amount / divisor ```

The int(round(...)) in to_provider_smallest_unit prevents floating-point arithmetic issues. Without round, 5.99 * 100 could produce 598.9999999999999 instead of 599, which int() would truncate to 598 -- a one-cent error that would cause transaction amount mismatches.

The Great /100 Purge: Session 066

By Session 066, the codebase had accumulated /100 divisions in various places -- some correct (converting from provider's smallest unit), some incorrect (double-dividing amounts that were already in major units). A comprehensive search found erroneous divisions across 8 files:

python# Example of erroneous /100 divisions found and removed

# File 1: dashboard/transactions.tsx
# Bug: amount already in dollars, divided by 100 again
amount_display = transaction.amount / 100  # WRONG: shows $0.05 for $5

# File 2: invoices/generate.py
# Bug: invoice amount double-converted
invoice.total = sum(item.amount / 100 for item in items)  # WRONG

# File 3: webhooks/payload.py
# Bug: webhook payload divided amount
payload["amount"] = transaction.amount / 100  # WRONG

# File 4: sdks/typescript/src/types.ts
# Bug: SDK formatted amount incorrectly
displayAmount: payment.amount / 100,  # WRONG

# File 5: receipts/pdf.py
# Bug: receipt showed wrong amount
receipt_amount = transaction.source_amount / 100  # WRONG

# File 6: analytics/stats.py
# Bug: daily volume calculation was 100x too low
daily_volume = sum(tx.amount / 100 for tx in transactions)  # WRONG

# File 7: billing/fee_calculator.py
# Bug: fee calculated on 1/100th of the actual amount
fee = (transaction.amount / 100) * 0.0099  # WRONG

# File 8: exports/csv.py
# Bug: CSV export divided amounts
row["amount"] = str(transaction.amount / 100)  # WRONG

Each of these was a /100 that should not have been there. The fix was to remove all of them and use the amount directly:

python# AFTER: Direct usage (amounts are already in major units)
amount_display = transaction.amount  # Correct
invoice.total = sum(item.amount for item in items)  # Correct
payload["amount"] = transaction.amount  # Correct

Currency-Aware Rounding

Displaying amounts requires currency-aware rounding:

typescript// utils/format.ts
function formatAmount(amount: number, currency: string): string {
    const decimals = getCurrencyDecimals(currency);

    return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: currency,
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
    }).format(amount);
}

// Examples:
// formatAmount(5.99, "USD")  -> "$5.99"
// formatAmount(5000, "XOF")  -> "XOF 5,000"    (no decimals)
// formatAmount(5.995, "BHD") -> "BHD 5.995"    (3 decimals)
// formatAmount(1000, "JPY")  -> "JP\u00a51,000" (no decimals)

Mobile Keyboard: type="text" With inputmode="decimal"

The amount input field on the checkout page required a specific mobile keyboard treatment. Using type="number" causes issues:

  • On iOS, the number keyboard lacks a decimal separator in some locales
  • type="number" allows e notation (1e5 = 100000)
  • Leading zeros behave inconsistently across browsers
  • Scroll-to-change-value is an unwanted interaction on mobile
html<!-- BEFORE: type="number" with mobile issues -->
<input type="number" step="0.01" min="0" />

<!-- AFTER: type="text" with decimal keyboard hint -->
<input
    type="text"
    inputmode="decimal"
    pattern="[0-9]*[.,]?[0-9]*"
    placeholder="0.00"
    on:input={handleAmountInput}
/>
typescript// Custom input handler for amount fields
function handleAmountInput(event: Event) {
    const input = event.target as HTMLInputElement;
    let value = input.value;

    // Allow only digits, one decimal point, and one comma
    value = value.replace(/[^0-9.,]/g, '');

    // Normalize comma to period (for European keyboards)
    value = value.replace(',', '.');

    // Allow only one decimal point
    const parts = value.split('.');
    if (parts.length > 2) {
        value = parts[0] + '.' + parts.slice(1).join('');
    }

    // Limit decimal places based on currency
    if (parts.length === 2) {
        const maxDecimals = getCurrencyDecimals(selectedCurrency);
        parts[1] = parts[1].slice(0, maxDecimals);
        value = parts.join('.');
    }

    input.value = value;
    amount = parseFloat(value) || 0;
}

The inputmode="decimal" attribute tells mobile browsers to show a numeric keyboard with a decimal separator. Combined with type="text", it gives full control over input validation without the quirks of type="number".

The 50+ File Update Count

The amount display bug fixes touched over 50 files across the entire codebase:

CategoryFiles Updated
Backend API responses12
Frontend components15
SDK packages (8 SDKs)8
Invoice/receipt generation4
Webhook payloads3
CSV/PDF exports3
Analytics calculations2
Test fixtures5+
Documentation3+

What We Learned

Decide on amount storage format before writing line one. The single most impactful decision is: do you store amounts in major units (dollars) or minor units (cents)? Choose one, document it prominently, and enforce it everywhere. We chose major units too late and spent weeks fixing the inconsistencies.

Zero-decimal currencies will break your assumptions. If your code has amount / 100 anywhere, it is wrong for XOF, XAF, JPY, and a dozen other currencies. Build currency-aware functions from the start.

type="number" is unsuitable for currency inputs on mobile. The inputmode="decimal" approach gives a better mobile experience with more control over validation.

Floating-point arithmetic needs explicit rounding. 5.99 * 100 is not 599 in IEEE 754 floating point. Always use round() before converting to integers for provider APIs.

A /100 in the codebase is a red flag. After the currency format standardization, any /100 applied to an amount is suspicious. We now treat /100 in amount-related code as a code review red flag that requires explicit justification.


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