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.99python# AFTER: Float storage preserves decimals
class Transaction(Base):
amount = Column(Float) # $4.99 stored as 4.99This migration affected every table that stored monetary amounts:
| Table | Columns Changed |
|---|---|
| transactions | amount, source_amount, destination_amount |
| invoices | amount, tax_amount, total_amount |
| invoice_items | amount, unit_price |
| fees | amount |
| wallet_transactions | amount |
| coupons | min_amount, max_discount |
| payment_methods | min_amount, max_amount |
| payment_links | amount |
| billing_cycles | total_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 2This 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) # WRONGEach 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 # CorrectCurrency-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"allowsenotation (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:
| Category | Files Updated |
|---|---|
| Backend API responses | 12 |
| Frontend components | 15 |
| SDK packages (8 SDKs) | 8 |
| Invoice/receipt generation | 4 |
| Webhook payloads | 3 |
| CSV/PDF exports | 3 |
| Analytics calculations | 2 |
| Test fixtures | 5+ |
| Documentation | 3+ |
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.