Si hay una categoria de error que definio la experiencia de desarrollo de 0fee.dev, son los errores de visualizacion de montos. Aparecieron en la sesion 017, resurgieron en la sesion 062 y no fueron completamente erradicados hasta la sesion 066 -- un intervalo de 49 sesiones durante el cual el mismo problema fundamental seguia manifestandose en nuevas formas.
La causa raiz era simple: confusion sobre si los montos se almacenaban en unidades mayores (dolares, euros) o unidades menores (centavos, centimos). Pero las causas raiz "simples" producen patrones de errores complejos cuando se propagan a traves de mas de 50 archivos.
El error formatAmount: sesion 017
La primera manifestacion aparecio en el panel de control. Una transaccion de $5,00 se mostraba como $0,05. Una transaccion de 5.000 XOF se mostraba como 50 XOF.
typescript// ANTES: El error (Sesion 017)
function formatAmount(amount: number, currency: string): string {
// Asumia que los montos se almacenaban en centavos
const displayAmount = amount / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(displayAmount);
}La funcion dividia por 100 porque el diseno original almacenaba montos en la unidad de moneda mas pequeña (centavos para USD, centimos para EUR). Pero en algun momento durante el desarrollo, el formato de almacenamiento cambio a unidades mayores ($5,00, no 500 centavos). La funcion formatAmount nunca fue actualizada.
typescript// DESPUES: Corregido
function formatAmount(amount: number, currency: string): string {
// Los montos se almacenan en unidades mayores (dolares, no centavos)
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
}Esta correccion resolvio el problema de visualizacion inmediato, pero fue el comienzo de una saga mas larga.
La migracion de entero a flotante: sesion 062
La sesion 062 revelo que el esquema de base de datos almacenaba montos como enteros en varias tablas. Esto funcionaba cuando los montos estaban en unidades menores (500 centavos = $5,00), pero despues del cambio a unidades mayores, el almacenamiento entero truncaba montos decimales:
python# ANTES: El almacenamiento entero truncaba decimales
class Transaction(Base):
amount = Column(Integer) # $4,99 almacenado como 4, no 4,99python# DESPUES: El almacenamiento flotante preserva decimales
class Transaction(Base):
amount = Column(Float) # $4,99 almacenado como 4,99Esta migracion afecto cada tabla que almacenaba montos monetarios:
| Tabla | Columnas cambiadas |
|---|---|
| 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 |
El mapa CURRENCY_DECIMALS
Diferentes monedas tienen diferentes posiciones decimales. USD tiene 2 (dolares y centavos). JPY tiene 0 (sin sub-unidad). BHD tiene 3 (dinar y fils). Esta informacion es esencial para la visualizacion correcta y la comunicacion correcta con proveedores:
python# utils/currency.py
CURRENCY_DECIMALS = {
# Monedas estandar de 2 decimales
"USD": 2, "EUR": 2, "GBP": 2, "CAD": 2, "AUD": 2,
"CHF": 2, "ZAR": 2, "NGN": 2, "KES": 2, "GHS": 2,
# Monedas sin decimales
"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,
# Monedas de tres decimales
"BHD": 3, "KWD": 3, "OMR": 3, "TND": 3, "LYD": 3,
}
def get_decimals(currency: str) -> int:
"""Obtener el numero de posiciones decimales para una moneda."""
return CURRENCY_DECIMALS.get(currency.upper(), 2) # Predeterminado a 2Este mapa es critico porque proveedores de pago como Stripe requieren montos en la unidad mas pequeña. Para USD, $5,00 se convierte en 500 (centavos). Para XOF, 5000 se queda en 5000 (sin sub-unidad). Para BHD, 5,000 se convierte en 5000 (fils).
to_provider_smallest_unit() y from_provider_smallest_unit()
La conversion entre nuestro formato de almacenamiento (unidades mayores) y el formato del proveedor (unidades mas pequeñas) necesitaba ser consistente y consciente de la moneda:
python# utils/currency.py
def to_provider_smallest_unit(amount: float, currency: str) -> int:
"""Convertir de unidades mayores a unidad mas pequeña para APIs de proveedores.
Ejemplos:
to_provider_smallest_unit(5.00, "USD") -> 500 (centavos)
to_provider_smallest_unit(5000, "XOF") -> 5000 (sin 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: """Convertir de la unidad mas pequeña del proveedor de vuelta a unidades mayores. BLANK Ejemplos: 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 ```
El int(round(...)) en to_provider_smallest_unit previene problemas de aritmetica de punto flotante. Sin round, 5.99 * 100 podria producir 598.9999999999999 en lugar de 599, que int() truncaria a 598 -- un error de un centavo que causaria discrepancias en montos de transacciones.
La gran purga de /100: sesion 066
Para la sesion 066, el codigo habia acumulado divisiones /100 en varios lugares -- algunas correctas (convirtiendo de la unidad mas pequeña del proveedor), algunas incorrectas (doble-dividiendo montos que ya estaban en unidades mayores). Una busqueda exhaustiva encontro divisiones erroneas en 8 archivos:
python# Ejemplo de divisiones /100 erroneas encontradas y eliminadas
# Archivo 1: dashboard/transactions.tsx
# Error: monto ya en dolares, dividido por 100 de nuevo
amount_display = transaction.amount / 100 # MAL: muestra $0,05 por $5
# Archivo 2: invoices/generate.py
# Error: monto de factura doble-convertido
invoice.total = sum(item.amount / 100 for item in items) # MAL
# Archivo 3: webhooks/payload.py
# Error: payload de webhook dividio el monto
payload["amount"] = transaction.amount / 100 # MAL
# Archivo 4: sdks/typescript/src/types.ts
# Error: SDK formateo monto incorrectamente
displayAmount: payment.amount / 100, # MAL
# Archivo 5: receipts/pdf.py
# Error: recibo mostraba monto incorrecto
receipt_amount = transaction.source_amount / 100 # MAL
# Archivo 6: analytics/stats.py
# Error: calculo de volumen diario era 100x demasiado bajo
daily_volume = sum(tx.amount / 100 for tx in transactions) # MAL
# Archivo 7: billing/fee_calculator.py
# Error: comision calculada sobre 1/100 del monto real
fee = (transaction.amount / 100) * 0.0099 # MAL
# Archivo 8: exports/csv.py
# Error: exportacion CSV dividio montos
row["amount"] = str(transaction.amount / 100) # MALCada uno de estos era un /100 que no deberia haber estado ahi. La correccion fue eliminar todos y usar el monto directamente:
python# DESPUES: Uso directo (los montos ya estan en unidades mayores)
amount_display = transaction.amount # Correcto
invoice.total = sum(item.amount for item in items) # Correcto
payload["amount"] = transaction.amount # CorrectoRedondeo consciente de la moneda
Mostrar montos requiere redondeo consciente de la moneda:
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);
}
// Ejemplos:
// formatAmount(5.99, "USD") -> "$5.99"
// formatAmount(5000, "XOF") -> "XOF 5,000" (sin decimales)
// formatAmount(5.995, "BHD") -> "BHD 5.995" (3 decimales)
// formatAmount(1000, "JPY") -> "JP¥1,000" (sin decimales)Teclado movil: type="text" con inputmode="decimal"
El campo de entrada de monto en la pagina de checkout requirio un tratamiento de teclado movil especifico. Usar type="number" causa problemas:
- En iOS, el teclado numerico carece de separador decimal en algunas configuraciones regionales
type="number"permite notacione(1e5 = 100000)- Los ceros iniciales se comportan inconsistentemente entre navegadores
- Desplazar-para-cambiar-valor es una interaccion no deseada en movil
html<!-- ANTES: type="number" con problemas en movil -->
<input type="number" step="0.01" min="0" />
<!-- DESPUES: type="text" con pista de teclado decimal -->
<input
type="text"
inputmode="decimal"
pattern="[0-9]*[.,]?[0-9]*"
placeholder="0.00"
on:input={handleAmountInput}
/>typescript// Manejador de entrada personalizado para campos de monto
function handleAmountInput(event: Event) {
const input = event.target as HTMLInputElement;
let value = input.value;
// Permitir solo digitos, un punto decimal y una coma
value = value.replace(/[^0-9.,]/g, '');
// Normalizar coma a punto (para teclados europeos)
value = value.replace(',', '.');
// Permitir solo un punto decimal
const parts = value.split('.');
if (parts.length > 2) {
value = parts[0] + '.' + parts.slice(1).join('');
}
// Limitar posiciones decimales segun moneda
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;
}El atributo inputmode="decimal" indica a los navegadores moviles que muestren un teclado numerico con separador decimal. Combinado con type="text", da control total sobre la validacion de entrada sin las peculiaridades de type="number".
El conteo de actualizacion de mas de 50 archivos
Las correcciones de errores de visualizacion de montos tocaron mas de 50 archivos en todo el codigo:
| Categoria | Archivos actualizados |
|---|---|
| Respuestas de API backend | 12 |
| Componentes de frontend | 15 |
| Paquetes SDK (8 SDKs) | 8 |
| Generacion de facturas/recibos | 4 |
| Payloads de webhook | 3 |
| Exportaciones CSV/PDF | 3 |
| Calculos de analiticas | 2 |
| Fixtures de prueba | 5+ |
| Documentacion | 3+ |
Lo que aprendimos
Decide el formato de almacenamiento de montos antes de escribir la primera linea. La decision de mayor impacto es: ¿almacenas montos en unidades mayores (dolares) o unidades menores (centavos)? Elige uno, documentalo prominentemente y aplicalo en todas partes. Nosotros elegimos unidades mayores demasiado tarde y pasamos semanas corrigiendo las inconsistencias.
Las monedas sin decimales rompran tus suposiciones. Si tu codigo tiene amount / 100 en cualquier parte, esta mal para XOF, XAF, JPY y una docena de otras monedas. Construye funciones conscientes de la moneda desde el principio.
type="number" no es adecuado para entradas de moneda en movil. El enfoque inputmode="decimal" da una mejor experiencia movil con mas control sobre la validacion.
La aritmetica de punto flotante necesita redondeo explicito. 5.99 * 100 no es 599 en punto flotante IEEE 754. Siempre usa round() antes de convertir a enteros para APIs de proveedores.
Un /100 en el codigo es una bandera roja. Despues de la estandarizacion del formato de moneda, cualquier /100 aplicado a un monto es sospechoso. Ahora tratamos /100 en codigo relacionado con montos como una bandera roja de revision de codigo que requiere justificacion explicita.
Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.