Un cliente en Tokio compra una suscripción de 19,99 USD. Tu código multiplica por el tipo USD/JPY, obtiene 2942,82785, lo guarda en la base de datos y lo envía a tu pasarela de pago. La pasarela lo rechaza — o peor, lo acepta y cobra 100 veces de más. El yen no tiene decimales, y tu código nunca lo preguntó.
El redondeo de divisas es uno de esos problemas que parecen triviales hasta que llegan a producción. No es una cuestión de formato: es una cuestión de corrección. Cada divisa tiene su propio número de decimales, la aritmética de coma flotante corrompe el dinero en silencio, y en el momento en que conviertes entre divisas introduces una decisión de redondeo que hay que tomar de forma deliberada. Esta guía cubre las reglas que realmente importan: cuántos decimales tiene cada divisa, por qué debes almacenar los importes como enteros, qué modo de redondeo elegir y cómo redondear una conversión FX para que tu libro mayor siga cuadrando a fin de mes.
Unidades menores: ¿cuántos decimales tiene cada divisa?
La unidad menor de una divisa es su subdivisión transaccionable más pequeña. Para el dólar estadounidense es el centavo, así que USD tiene dos decimales y 19,99 USD son 1999 centavos. Esta es la representación que esperan las pasarelas de pago, y la que debería usar tu base de datos.
ISO 4217 — el mismo estándar que te da códigos de tres letras como USD y JPY — también asigna a cada divisa un exponente de unidad menor. La mayoría de los desarrolladores asume que ese exponente siempre es 2. No lo es, y esa suposición es el error más caro de esta categoría.
Divisas sin decimales
Estas divisas no tienen subunidad en circulación, así que el importe que envías es el número entero de unidades:
- JPY — yen japonés
- KRW — won surcoreano
- VND — dong vietnamita
- CLP — peso chileno
- ISK — corona islandesa
- XAF / XOF / XPF — francos CFA y CFP
- UGX — chelín ugandés
- PYG — guaraní paraguayo
- RWF, GNF, KMF, DJF, VUV — y varias otras divisas de denominación pequeña
Si tratas el JPY como una divisa de dos decimales y multiplicas por 100 antes de enviarlo a la pasarela, acabas de cobrar a tu cliente 100 veces el importe previsto.
Divisas con tres decimales
Siete divisas se subdividen en milésimas en lugar de centésimas:
- KWD — dinar kuwaití (1000 fils)
- BHD — dinar bahreiní (1000 fils)
- OMR — rial omaní (1000 baisa)
- JOD — dinar jordano (1000 fils)
- TND — dinar tunecino (1000 milímes)
- IQD — dinar iraquí (1000 fils)
- LYD — dinar libio (1000 dirhams)
Aquí el fallo va en la dirección contraria: si tratas el KWD como dos decimales, cobras una décima parte de lo que pretendías. Una factura de KWD 12,500 se convierte en KWD 1,250.
Incluso hay entradas de cuatro decimales en ISO 4217 — la unidad de fomento chilena (CLF) y la unidad previsional uruguaya (UYW). Son unidades contables indexadas más que efectivo, pero si tu sistema acepta códigos ISO arbitrarios, tiene que sobrevivir a ellas.
Cuando el estándar y tu pasarela no coinciden
Esta es la trampa que atrapa a los equipos que hicieron todo lo demás bien. Los proveedores de pago a veces se desvían de ISO 4217 por motivos operativos. Adyen, por ejemplo, documenta que CLP, CVE, IDR e ISK toman en su API un número de decimales distinto al que especifica el estándar: la ISK tiene cero decimales bajo ISO 4217, pero debe enviarse con dos decimales a Adyen.
La regla: tu tabla de redondeo es una propiedad del sistema con el que hablas, no una constante universal. Mantén una tabla por integración, inicialízala desde ISO 4217 y sobreescríbela por proveedor donde su documentación lo indique. Nunca escribas 100 a fuego.
Nunca almacenes dinero como float
Antes de cualquier discusión sobre redondeo, la base. La coma flotante binaria no puede representar exactamente la mayoría de las fracciones decimales:
0.1 + 0.2 // 0.30000000000000004
1.005 * 100 // 100.49999999999999
19.99 * 147.2150 // 2942.8278499999997Esos dígitos finales no son cosméticos. Pásalos por Math.round() en el momento equivocado y obtendrás un importe desviado en una unidad menor, lo que basta para que falle la conciliación.
Dos reglas cubren casi todos los casos:
- Almacena los importes como enteros en unidades menores. Una columna
amount_minor BIGINTmás una columnacurrency CHAR(3).{ amount_minor: 1999, currency: "USD" }no es ambiguo y coincide con lo que ya esperan Stripe, Adyen y la mayoría de las pasarelas. - Haz la aritmética con enteros o con un tipo decimal.
decimal.Decimalde Python,BigDecimalde Java,NUMERICde PostgreSQL, o una librería monetaria de JavaScript que envuelva aritmética entera. Reserva los floats para el propio tipo de cambio, y aun así solo hasta el punto de la multiplicación.
Si estás diseñando esta capa desde cero, nuestra guía sobre diseño de libro mayor multidivisa cubre las decisiones de esquema con más profundidad.
El flujo de conversión: unidades menores dentro, unidades menores fuera
La conversión de divisas tiene exactamente cuatro pasos, y el redondeo pertenece al paso tres — una sola vez, al final.
- Convierte el importe origen de unidades menores a un valor decimal.
- Multiplica por el tipo de cambio a precisión completa.
- Redondea al exponente de unidad menor de la divisa destino.
- Vuelve a convertir a unidades menores enteras.
Aquí está en JavaScript, con la tabla por divisa haciendo el trabajo:
// Minor unit exponents. Seed from ISO 4217, override per payment provider.
const MINOR_UNITS = {
USD: 2, EUR: 2, GBP: 2, CHF: 2, CAD: 2, AUD: 2, CNY: 2, INR: 2,
JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, XAF: 0, XOF: 0, XPF: 0,
KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, IQD: 3, LYD: 3,
};
function exponentFor(currency) {
const e = MINOR_UNITS[currency];
if (e === undefined) throw new Error(`Unknown minor unit for ${currency}`);
return e;
}
/**
* Convert an integer minor-unit amount from one currency to another.
* Returns an integer in the target currency's minor units.
*/
function convertMinor(amountMinor, from, to, rate) {
const fromExp = exponentFor(from);
const toExp = exponentFor(to);
const decimalAmount = amountMinor / 10 ** fromExp; // 1999 -> 19.99
const converted = decimalAmount * rate; // full precision, no rounding yet
return Math.round(converted * 10 ** toExp); // single rounding step
}
convertMinor(1999, "USD", "JPY", 147.2150); // 2943 (¥2,943)
convertMinor(1999, "USD", "KWD", 0.30590); // 6115 (KWD 6.115)
convertMinor(1999, "USD", "EUR", 0.9241); // 1847 (€18.47)Fíjate en lo que la función no hace: nunca redondea el tipo, nunca redondea un valor intermedio y nunca asume dos decimales. Math.round aquí es half-up para positivos — está bien para un checkout, pero lee la siguiente sección antes de usarlo para algo regulado.
Elegir un modo de redondeo
"Redondear a dos decimales" no es una especificación. Hay al menos cinco formas defendibles de resolver un empate, y a los sistemas financieros les importa cuál elijas.
| Modo | 2,5 → | 3,5 → | −2,5 → | Uso típico |
|---|---|---|---|---|
| Half up (mitad arriba) | 3 | 4 | −3 | Precios al consumidor, totales de checkout |
| Half even (del banquero) | 2 | 4 | −2 | Contabilidad, intereses, impuestos, reporting |
| Half down (mitad abajo) | 2 | 3 | −2 | Raro; a veces en código financiero heredado |
| Ceiling (hacia arriba) | 3 | 4 | −2 | Comisiones que nunca debes cobrar de menos |
| Floor (hacia abajo / truncar) | 2 | 3 | −3 | Pagos que nunca debes abonar de más |
Math.round() con números positivos. Es intuitivo y apropiado para un precio que el cliente está a punto de ver.Half even, también llamado redondeo del banquero, envía las mitades exactas al dígito par más cercano. A lo largo de muchas transacciones cancela el sesgo alcista sistemático que introduce el half-up, y por eso es el predeterminado en los sistemas contables, en el módulo decimal de Python y en el propio IEEE 754. Si estás agregando miles de importes convertidos en un informe de ingresos, el half-up inflará el total en silencio; el half-even no.
Ceiling y floor existen para riesgos asimétricos. Un marketplace que paga a vendedores puede aplicar floor a cada pago para no distribuir nunca más de lo que tiene; la diferencia va a una cuenta de redondeo.
Python hace la elección explícita, que es la ergonomía correcta:
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3}
def convert_minor(amount_minor: int, src: str, dst: str,
rate: str, mode=ROUND_HALF_EVEN) -> int:
"""Convert integer minor units to integer minor units, exactly once."""
src_exp, dst_exp = MINOR_UNITS[src], MINOR_UNITS[dst]
amount = Decimal(amount_minor) / (Decimal(10) ** src_exp)
converted = amount * Decimal(rate) # rate passed as a string, not a float
quantum = Decimal(1).scaleb(-dst_exp) # 0.01, 1, or 0.001
rounded = converted.quantize(quantum, rounding=mode)
return int(rounded.scaleb(dst_exp))
convert_minor(1999, "USD", "JPY", "147.2150") # 2943
convert_minor(1999, "USD", "KWD", "0.30590") # 6115Pasar el tipo como cadena a Decimal importa. Decimal(0.9241) hereda el error del float; Decimal("0.9241") no.
Tres errores de redondeo que cuestan dinero real
1. Redondear el tipo antes de multiplicar
Los tipos de cambio suelen arrastrar de cuatro a seis decimales significativos, y truncarlos no es inofensivo. Toma USD/JPY a 147,2150 y una transferencia de 10.000 USD:
- Tipo completo:
10000 × 147.2150 = ¥1.472.150 - Tipo redondeado a dos decimales (147,21):
10000 × 147.21 = ¥1.472.100
Una discrepancia de ¥50 en una sola transacción, puramente por formatear el tipo antes de usarlo. Almacena el tipo con la precisión que devuelve tu proveedor, redondea solo el importe resultante y persiste el tipo exacto que usaste junto a la transacción para auditoría. Nuestra guía sobre de dónde obtienen sus datos las API de tipos de cambio explica por qué esa precisión es significativa desde el principio.
2. Redondear dos veces en una conversión de varios tramos
Si enrutas USD → EUR → JPY y redondeas en el paso EUR, has tirado precisión que la segunda multiplicación amplifica. Convirtiendo 12,34 USD con USD/EUR a 0,9241 y EUR/JPY a 159,3063:
- Directo:
12.34 × 147.2150 = 1816.63→ ¥1.817 - Vía un tramo EUR redondeado:
12.34 × 0.9241 = 11.4034→ redondeado a 11,40 € →11.40 × 159.3063 = 1816.09→ ¥1.816
Un yen, por un paso de redondeo innecesario. En una tanda de pagos de cincuenta mil transacciones, eso es un ticket de conciliación. Siempre que haya un par directo disponible, úsalo; cuando tengas que triangular, mantén el intermedio a precisión completa. Consulta tipos de cambio cruzados explicados para la mecánica.
3. Líneas que no suman el total
Redondea cada línea de una factura de forma independiente y las partes no siempre sumarán el total redondeado. El caso clásico es un reparto:
$10.00 split three ways
10.00 / 3 = 3.3333...
→ 3.33 + 3.33 + 3.33 = 9.99 ✗ one cent missingLa solución es la asignación, no el redondeo. Redondea el total una vez y luego distribúyelo entre las partes, repartiendo el resto de unidad menor en unidad menor:
/**
* Split an integer minor-unit total into `n` parts whose sum is exactly the total.
* Remainder units are distributed to the earliest parts (largest-remainder method).
*/
function allocate(totalMinor, ratios) {
const sum = ratios.reduce((a, b) => a + b, 0);
const shares = ratios.map(r => Math.floor((totalMinor * r) / sum));
let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);
for (let i = 0; remainder > 0; i = (i + 1) % shares.length, remainder--) {
shares[i] += 1;
}
return shares;
}
allocate(1000, [1, 1, 1]); // [334, 333, 333] → sums to exactly 1000
allocate(9247, [3, 2, 1]); // [4624, 3082, 1541] → sums to exactly 9247Aplica el mismo patrón tras una conversión FX: convierte y redondea el total de la factura, y luego asigna ese total entre las líneas. Las líneas siempre cuadrarán, porque se derivaron del total en lugar de calcularse por separado. Esto importa sobre todo en la facturación multidivisa y en la facturación SaaS con prorrateo, donde una desviación de un céntimo aparece en un PDF que ve el cliente.
El redondeo de efectivo es una regla aparte
La unidad menor de una divisa te dice el importe más pequeño que puede registrarse. No siempre te dice el importe más pequeño que puede pagarse en efectivo. Varios países retiraron sus monedas más pequeñas y redondean los pagos en efectivo en caja:
- Suiza — el efectivo se redondea a los 0,05 CHF más cercanos
- Canadá — la moneda de un centavo se retiró en 2013; el efectivo se redondea a los 5 centavos más cercanos
- Suecia — el efectivo se redondea a la corona entera más cercana
- Países Bajos — el efectivo se redondea a los 5 céntimos más cercanos
Es crucial: esto se aplica al pago en efectivo, no a la factura. Una factura suiza de 12,32 CHF se sigue registrando como 12,32; solo la liquidación en efectivo se redondea a 12,30, y la diferencia de 0,02 se contabiliza como ajuste por redondeo. Si construyes software de punto de venta, modela el redondeo de efectivo como un paso separado y posterior aplicado al pago — nunca lo incrustes en el importe almacenado, o tus transacciones electrónicas y en efectivo no coincidirán.
El formato es el último paso, no el cálculo
Una vez hecha la aritmética, delega la visualización en un formateador consciente del locale. Intl.NumberFormat ya conoce el número de decimales, la posición del símbolo y los separadores de cada divisa:
function formatMinor(amountMinor, currency, locale = "en-US") {
const exp = exponentFor(currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountMinor / 10 ** exp);
}
formatMinor(1999, "USD"); // "$19.99"
formatMinor(2943, "JPY", "ja-JP"); // "¥2,943"
formatMinor(6115, "KWD"); // "KWD 6.115"
formatMinor(1847, "EUR", "de-DE"); // "18,47 €"Dos notas prácticas. Primera: las instancias de Intl.NumberFormat son caras de construir — cachea una por par locale-divisa en lugar de crear una por fila. Segunda: la división por 10 ** exp de la última línea es el único sitio donde un float debería tocar un valor monetario, y solo porque el resultado se convierte inmediatamente en cadena.
Poniéndolo todo junto con la API de Finexly
Obtén el tipo a precisión completa, convierte una vez, redondea una vez y guarda el tipo que usaste:
curl "https://api.finexly.com/v1/latest?base=USD&symbols=JPY,KWD,EUR" \
-H "Authorization: Bearer YOUR_API_KEY"{
"success": true,
"base": "USD",
"timestamp": 1755244800,
"rates": {
"JPY": 147.2150,
"KWD": 0.30590,
"EUR": 0.9241
}
}async function quote(amountMinor, from, to) {
const res = await fetch(
`https://api.finexly.com/v1/latest?base=${from}&symbols=${to}`,
{ headers: { Authorization: `Bearer ${process.env.FINEXLY_API_KEY}` } }
);
const data = await res.json();
const rate = data.rates[to];
return {
amount_minor: convertMinor(amountMinor, from, to, rate),
currency: to,
rate, // persist the exact rate used
rate_timestamp: data.timestamp, // and when it was captured
};
}
await quote(1999, "USD", "JPY");
// { amount_minor: 2943, currency: "JPY", rate: 147.215, rate_timestamp: 1755244800 }Guardar rate y rate_timestamp en la fila de la transacción es lo que hace que una disputa tenga respuesta seis meses después. Todos los detalles de endpoints y parámetros están en la documentación de la API de Finexly, y si cacheas tipos entre peticiones, nuestras notas sobre caché y manejo de errores cubren los compromisos de frescura.
Una lista de comprobación de pruebas
Los errores de dinero se esconden en los casos que nadie prueba. Como mínimo, cubre:
- Un destino sin decimales — convierte a JPY o KRW y comprueba que el resultado no tiene parte fraccionaria.
- Un destino de tres decimales — convierte a KWD o BHD y comprueba que sobreviven los tres decimales.
- Mitades exactas — comprueba tu modo de redondeo elegido, en ambas direcciones, incluidos los negativos.
- Deriva de ida y vuelta — convierte USD → EUR → USD y comprueba que el resultado está dentro de una unidad menor, no que sea igual.
- Invariancia de la asignación — comprueba que las partes repartidas siempre suman exactamente el total, de 1 a 100 partes.
- Códigos de divisa desconocidos — comprueba que el código lanza un error en lugar de asumir dos decimales en silencio.
- Importes muy grandes — comprueba que no hay pérdida de precisión más allá de
Number.MAX_SAFE_INTEGERen JavaScript; usaBigIntsi manejas IDR o VND a escala.
Preguntas frecuentes
¿Cuántos decimales tiene cada divisa?
La mayoría tiene dos. Alrededor de dos docenas no tienen ninguno — incluidos JPY, KRW, VND, CLP e ISK — y siete tienen tres: KWD, BHD, OMR, JOD, TND, IQD y LYD. ISO 4217 es la fuente autorizada, pero revisa también la tabla de tu proveedor de pagos, porque algunos se desvían por motivos operativos.
¿Debo redondear los tipos de cambio o los importes convertidos?
Solo los importes convertidos. Mantén el tipo con la precisión completa que devuelve tu proveedor, multiplica y luego redondea el resultado una sola vez a la unidad menor de la divisa destino. Redondear un tipo antes de multiplicar introduce un error proporcional al tamaño de la transacción.
¿Cuál es la diferencia entre half-up y el redondeo del banquero?
Half-up siempre aleja del cero una mitad exacta (2,5 → 3). El redondeo del banquero — mitad al par — la envía al dígito par más cercano (2,5 → 2, 3,5 → 4), lo que elimina el sesgo alcista sistemático al agregar muchos importes. Usa half-up para precios mostrados a clientes y half-even para contabilidad y reporting.
¿Por qué mis líneas convertidas no suman el total convertido?
Porque cada línea se redondeó de forma independiente y los errores se acumulan. Redondea el total una vez y luego asigna ese total entre las líneas con un reparto por mayor resto. Las partes sumarán el total por construcción.
¿Puedo simplemente almacenar dinero como float con dos decimales?
No. La coma flotante binaria no puede representar exactamente valores como 0,1, así que los errores se acumulan entre sumas y multiplicaciones y acaban invirtiendo una decisión de redondeo. Almacena enteros en unidades menores, o usa un tipo decimal exacto. No es una preocupación teórica: es la causa raíz más común de los fallos de conciliación de un céntimo.
Consigue tipos a precisión completa
Un redondeo correcto empieza con un tipo en el que puedas confiar y con precisión que no tiraste. Consigue tu clave API gratuita de Finexly — sin tarjeta de crédito. Empieza con 1.000 peticiones gratuitas al mes en más de 170 divisas, prueba el conversor de divisas para verificar tus cálculos y revisa los planes de precios cuando crezca tu volumen.
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →