dimesum

Inicio / Blog / Dinero

Dinero

Seis errores de dinero en gastos multidivisa

· 10 min de lectura ·

Una auditoría multidivisa halló seis defectos de divisa que las suites de pruebas daban por correctos, y la causa era una frase de un documento que había dejado de ser cierta en silencio.

Una sola frase obsoleta en un documento costó seis errores de dinero. Una auditoría multidivisa de Dimesum realizada el 2026-08-21 los encontró en código que nuestras suites de pruebas habían dado por correcto, incluida una página de reclamación que mostraba una deuda de ¥6,000 como -₹60.00. La frase era «los grupos están fijados a INR». Cierta hasta la W7, falsa desde el momento en que se lanzó, y todavía presente en FOLLOWUPS.md una semana después.

Una frase obsoleta es más peligrosa que la falta de documentación. Una auditoría posterior se la cree y se salta las rutas que cubre, de modo que una línea equivocada causa un daño que el silencio nunca podría causar. Un documento vacío te remite al código fuente. Uno equivocado te envía a un lugar completamente distinto.

Un pendiente que se cumplió es peor que uno abierto

Dimesum guarda el trabajo aplazado en FOLLOWUPS.md, una fila por decisión con el motivo por el que se aplazó. La entrada A17 decía que los grupos estaban fijados a INR, que un viaje al extranjero no podía registrarse en la divisa en la que ocurrió, y que la corrección dependía de una decisión de los fundadores. La W7 lanzó el soporte multidivisa de todos modos: un gasto puede estar en cualquier divisa, y los saldos se indexan por (group_id, member_id, currency) mediante la migración ledger/00005. Nadie tachó la fila.

La siguiente auditoría leyó A17, concluyó que esa área no estaba construida, y nunca abrió el código de liquidación, de reclamaciones ni de la página de destino. Tres rutas de dinero ciegas a la divisa siguieron lanzándose por la fuerza de una sola frase. Ningún problema difícil se interponía. El docstring de app/amount.py incluía la misma afirmación, así que a quien leyera se le decía dos veces.

La regla que adoptamos

Tacha un pendiente en el mismo commit que lo cierra. Una fila que describe código que dejó de funcionar así es un desvío que te aleja del archivo que necesitabas leer, lo que cuesta más que no decir nada.

El mismo 100 codificado a fuego apareció en tres archivos distintos

Aquí el dinero son unidades menores en int64 más un código ISO 4217, y los números de coma flotante nunca lo tocan. Los enteros son exactos por construcción, así que la única aritmética arriesgada que queda es la conversión entre unidades mayores y menores. La unidad menor no siempre es una centésima. El JPY no tiene ninguna, el KWD tiene tres decimales, y cada 100 codificado a fuego es una apuesta a que el usuario se quedó en casa.

El app/amount.py del sidecar de Python contenía el primer avistamiento, retirado antes de la auditoría como una mina más que como un defecto activo. Multiplicaba por 100 cualquier cifra que leyera de una frase, así que un recibo de ¥1,200 se convertía en 120,000 unidades menores. Eso se lee como ¥120,000. En una divisa sin unidad menor la constante infla la cuenta de alguien cien veces, y ahora la función consulta el exponente de la divisa solicitada en su lugar.

Un recibo de 1,200 yenes convertido por un multiplicador codificado a fuego de 100 y por la tabla de exponentes ISO 4217 ¥1,200 divisa: JPY ANTES menor = mayor x 100 constante en el módulo 120000 unidades menores se lee como ¥120,000 DESPUÉS menor = mayor x 10^exp exponente JPY = 0 1200 unidades menores se lee como ¥1,200
La trampa del exponente en una sola imagen. Un multiplicador constante de 100 es correcto para el INR y erróneo por un factor de 100 para el JPY, que no tiene unidad menor; la misma constante se desvía en diez para el KWD, que tiene tres decimales.

La auditoría encontró la misma constante en la página donde una persona decide si acepta un saldo. formatMinor, detrás de la página de reclamación en /m/{token}, dividía por 100 y pegaba un signo de rupia al principio. Una deuda de ¥6,000 se imprimía como -₹60.00: símbolo equivocado, una centésima del importe. Su hermano en JSON /v1/claims fallaba junto a ella, aplanando las filas por divisa de un miembro en una sola y conservando la divisa que el mapa escribiera en último lugar.

El tercer archivo es internal/ingestion/store.go, y lo encontró la W8 mientras ampliaba la detección de duplicados, no la auditoría. Su tolerancia de «más o menos una rupia» era la constante 100, una rupia contada en paise, una banda de más o menos ¥100 donde el JPY no tiene ninguna unidad menor. Los tres consultan ahora el exponente.

Comparar enteros pelados hacía que un yen pareciera una rupia

La capa de deduplicación de Dimesum comprueba una nueva captura contra los gastos recientes para que un recibo importado no cobre dos veces un pedido que alguien tecleó a mano. La consulta junto a esa banda de tolerancia no tenía ningún filtro de divisa. Así que ¥1,000 coincidía con ₹1,000, y una importación se ofrecía a reemplazar un gasto con el que no tenía nada que ver. Los grupos eran multidivisa desde ledger/00005, así que el caso era alcanzable y no teórico.

Las unidades menores peladas no son comparables entre divisas, y las tolerancias tampoco. La proyección ahora filtra por divisa, y deriva su banda de una unidad mayor de la divisa comparada.

Dos motores respondían «quién paga a quién», y el segundo era ciego a la divisa

El defecto caro es estructural, no aritmético. La ruta de escritura de liquidación planificaba sobre internal/platform/simplify, un segundo motor de flujo mínimo de caja cuya estructura Balance no tenía campo de divisa. La suma cero por divisa hace que la suma plana también sea cero, así que una deuda de ¥300,000 y una deuda de ₹500 se cancelaban hasta nada y ninguna salvaguarda rechazaba el plan. El plan entonces emparejaba a un acreedor en yenes con un deudor en rupias.

La escritura lo empeoraba. El servicio estampaba Currency: g.DefaultCurrency en cada liquidación, así que una deuda de ¥300,000 autorizaba un asiento en INR, y la deuda en yenes no podía saldarse en absoluto.

Los mismos cuatro saldos enrutados por el motor simplify ciego a la divisa y por el motor settle consciente de la divisa SALDOS Asha +¥300,000 Bhavna -¥300,000 Chetan +₹500 Dev -₹500 simplify: Balance{MemberID, Minor} sin divisa en la estructura, así que los cuatro se netean planos 300000 + (-300000) + 500 + (-500) = 0 plan: Bhavna paga a Chetan un deudor en yenes enviado a un acreedor en rupias settle: Balance{MemberID, money.Amount} particiona por divisa, luego enruta dentro de cada una JPY: Bhavna paga a Asha ¥300,000 INR: Dev paga a Chetan ₹500 la liquidación indica la divisa que salda simplify se elimina, no se arregla
Cuatro saldos, dos motores. La suma cero por divisa implica una suma plana de cero, así que un motor ciego a la divisa ve un grupo cuadrado y enruta con confianza un pago entre dos personas que no se deben nada.

simplify se elimina y /settle-plan se retira. internal/platform/settle es ahora el único motor: particiona las divisas en convertibles y no convertibles, convierte los saldos netos una sola vez, y enruta cada divisa no convertible en su propia denominación. Una liquidación indica la divisa que salda, y ese campo es obligatorio en cuanto un grupo tiene más de una.

Un tope de cero se leía como ausencia de tope

La salvaguarda de sobrepago rechaza un pago mayor que la deuda que salda. La salvaguarda leía outstanding > 0 && amount > outstanding, así que un tope de cero se saltaba la comparación por completo. Que alguien registre un pago contra una deuda que no existe es el único caso para el que existe la regla, y era el caso que pasaba sin problemas.

La corrección es un tipo, no una condición. OutstandingMinor es ahora un *int64, así que «nadie calculó esto» no puede escribirse igual que «la respuesta es cero». Nil se salta la comprobación y significa genuinamente desconocido; cualquier llamador con acceso al libro mayor pasa un número real.

Por qué una suite en verde no probaba nada

Cada fixture de estas rutas usaba INR. Una comparación ciega a la divisa es invisible bajo una prueba de una sola divisa, porque con una divisa no hay nada que confundir. Las suites no eran débiles, eran estrechas, y la auditoría que las habría ampliado había sido despachada por A17.

El verificador de extremo a extremo del libro mayor compartía la ceguera, que es la parte que conviene recordar. El verificador sumaba los apuntes por miembro sin agrupar por divisa, así que daba una falsa alarma en un grupo sano de dos divisas y sumaba cero en un grupo que estaba roto dos veces. Un falso negativo es la dirección peligrosa. Un comprobador construido sobre la misma suposición que el código siempre estará de acuerdo con el código.

Todos los defectos que encontró la auditoría multidivisa del 2026-08-21, lo que recibió un grupo en yenes, y lo que se lanzó.
DefectoDóndeLo que recibió un grupo en JPYCorrección
Una estructura Balance sin divisaplatform/simplifyun acreedor en yenes emparejado con un deudor en rupiassimplify eliminado; settle particiona por divisa
El valor por defecto del grupo estampado en cada liquidaciónservicio de liquidaciónuna deuda de ¥300,000 autorizaba un asiento en INRuna liquidación indica la divisa que salda
outstanding > 0 en la salvaguarda de sobrepagoservicio de liquidaciónun tope de cero se convertía en ausencia total de tope*int64: nil significa desconocido, cero significa cero
Dividir por 100 con un signo de rupiaformatMinor del gatewayuna deuda de ¥6,000 impresa como -₹60.00símbolo y decimales según la divisa
Saldos por divisa aplanados en una sola fila/v1/claimsla divisa que el mapa escribiera en último lugaruna entrada por divisa, coincidiendo con GET /balances
Apuntes sumados por miembro, con la divisa descartadaverificador de extremo a extremo del libro mayorun grupo doblemente roto reportado como cuadradoagrupar por miembro y divisa

Haz grep de la literal 100 en tu código de dinero

Búscala, esa constante, y toda comparación que ponga dos importes uno al lado del otro sin una divisa junto a ellos. Luego arregla lo más barato, lo que previene los siguientes seis: tacha un pendiente en el mismo commit que lo cierra. Y elimina un motor duplicado en lugar de repararlo. Dos respuestas a «quién paga a quién» es como una de ellas sigue equivocada.

Preguntas frecuentes

¿Qué es la división de gastos multidivisa?

La división de gastos multidivisa registra cada gasto en la divisa en la que ocurrió y mantiene un saldo separado por divisa en lugar de convertirlo todo a una. Dimesum indexa los saldos por grupo, miembro y divisa, así que una deuda en yenes y una deuda en rupias nunca se fusionan en un solo número. La conversión es una vista que se usa para un plan de liquidación, nunca un importe almacenado.

¿Por qué es peligroso un 100 codificado a fuego en el código de dinero?

Un 100 codificado a fuego supone que toda divisa tiene dos decimales, y varias no los tienen. El JPY no tiene unidad menor, así que multiplicar por 100 convierte un recibo de ¥1,200 en 120,000 unidades menores, una inflación de cien veces y no un error de redondeo. El KWD tiene tres decimales, así que la misma constante se desvía en diez. Consulta el exponente ISO 4217 en su lugar.

¿Cómo se evita que dos motores de liquidación se contradigan?

Elimina uno de los dos motores en lugar de reconciliarlos, porque una segunda respuesta a quién paga a quién es como la primera sigue equivocada. Dimesum ejecutaba simplify y settle en paralelo hasta que una auditoría descubrió que el primero no tenía divisa en su tipo de saldo, lo que permitía que un plan emparejara a un acreedor en yenes con un deudor en rupias. simplify se retiró y /settle-plan se dio de baja en lugar de parchearlo.

¿Por qué la suite de pruebas siguió en verde con seis errores de dinero?

La suite de pruebas siguió en verde porque cada fixture de las rutas afectadas usaba una sola divisa, y una comparación ciega a la divisa no puede fallar cuando solo hay una divisa. El verificador de extremo a extremo del libro mayor tenía la misma suposición: sumaba los apuntes por miembro sin agrupar por divisa, así que reportaba un grupo doblemente roto como cuadrado. Un comprobador construido sobre la propia suposición del código está de acuerdo con el código.

¿Qué hacer con una entrada de pendientes cuando la función se lanza?

Tacha una entrada de pendientes en el mismo commit que cierra el trabajo que describe. Un pendiente que se ha cumplido en silencio es peor que uno abierto, porque aleja a quien lea después del código que solía describir. Dimesum dejó una entrada que decía que los grupos estaban fijados a INR durante una semana después de lanzar el soporte multidivisa, y la siguiente auditoría se saltó tres rutas de dinero por su palabra.