Seis bugs de dinheiro em despesas multimoeda
Uma auditoria do Dimesum em 2026-08-21 encontrou seis defeitos de moeda que os testes deixavam passar, causados por uma frase em um doc que silenciosamente deixou de ser verdadeira.
Uma frase desatualizada em um doc custou seis bugs de dinheiro. Uma auditoria multimoeda do Dimesum em 2026-08-21 os encontrou em código pelo qual nossas suítes de teste vinham passando no verde, incluindo uma página de cobrança que exibia uma dívida de ¥6,000 como -₹60.00. A frase era "os grupos estão fixados em INR". Verdadeira até a W7, falsa no instante em que entrou no ar, e ainda presente em FOLLOWUPS.md uma semana depois.
Uma frase desatualizada é mais perigosa do que a ausência de documentação. Uma auditoria posterior acredita nela e pula os caminhos que ela cobre, então uma linha errada causa um dano que o silêncio nunca causaria. Um doc vazio manda você para o código-fonte. Um errado manda você para outro lugar completamente.
Um follow-up que se tornou verdade é pior do que um em aberto
O Dimesum guarda o trabalho adiado em FOLLOWUPS.md, uma linha por decisão com o motivo de ter sido adiada. A entrada A17 dizia que os grupos estavam fixados em INR, que uma viagem ao exterior não podia ser registrada na moeda em que aconteceu, e que a correção dependia de uma decisão dos fundadores. A W7 lançou o multimoeda mesmo assim: uma despesa pode estar em qualquer moeda, e os saldos são chaveados por (group_id, member_id, currency) pela migração ledger/00005. Ninguém riscou a linha.
A auditoria seguinte leu a A17, concluiu que a área não tinha sido construída, e nunca abriu o código de liquidação, de cobrança ou da página. Três caminhos de dinheiro cegos à moeda continuaram indo ao ar com base em uma única frase. Nenhum problema difícil estava no caminho. A docstring de app/amount.py trazia a mesma afirmação, então o leitor era avisado duas vezes.
Risque um follow-up no mesmo commit que o encerra. Uma linha que descreve um código que deixou de funcionar daquele jeito é um desvio para longe do arquivo que você precisava ler, o que custa mais do que não dizer nada.
O mesmo 100 hardcoded apareceu em três arquivos diferentes
Aqui o dinheiro é int64 em unidades menores mais um código ISO 4217, e floats nunca encostam nele. Inteiros são exatos por construção, então a única aritmética arriscada que sobra é a conversão entre unidades maiores e menores. A unidade menor nem sempre é um centésimo. O JPY não tem nenhuma, o KWD tem três casas decimais, e todo 100 hardcoded é uma aposta de que o usuário ficou em casa.
O app/amount.py do sidecar em Python teve a primeira aparição, retirada antes da auditoria como uma mina terrestre e não como um defeito ativo. Ele multiplicava por 100 qualquer valor que lesse de uma frase, então um recibo de ¥1,200 virava 120,000 unidades menores. Isso é lido como ¥120,000. Em uma moeda sem unidade menor a constante infla a conta de alguém cem vezes, e agora a função consulta o expoente da moeda da requisição em vez disso.
A auditoria encontrou a mesma constante na página onde uma pessoa decide se aceita um saldo. O formatMinor, por trás da página de cobrança em /m/{token}, dividia por 100 e colava um símbolo de rúpia na frente. Uma dívida de ¥6,000 era exibida como -₹60.00: símbolo errado, um centésimo do valor. Seu irmão em JSON, /v1/claims, falhava junto, achatando as linhas por moeda de um membro em uma só e mantendo a moeda que o mapa escreveu por último.
O terceiro arquivo é internal/ingestion/store.go, e foi a W8 que o encontrou ao ampliar a detecção de duplicatas, não a auditoria. Sua tolerância de "mais ou menos uma rúpia" era a constante 100, uma rúpia contada em paise, uma faixa de mais ou menos ¥100 onde o JPY não tem nenhuma unidade menor. Os três leem o expoente agora.
Comparar inteiros puros fez um iene parecer uma rúpia
A camada de deduplicação do Dimesum compara uma nova captura com despesas recentes para que um recibo importado não cobre em dobro um lançamento que alguém digitou à mão. A consulta ao lado daquela faixa de tolerância não tinha nenhum filtro de moeda. Então ¥1,000 correspondia a ₹1,000, e uma importação se oferecia para substituir uma despesa com a qual não tinha nada a ver. Os grupos eram multimoeda desde ledger/00005, então o caso era alcançável e não teórico.
Unidades menores puras não são comparáveis entre moedas, e as tolerâncias também não. A projeção agora filtra por moeda, e deriva sua faixa de uma unidade maior da moeda comparada.
Dois motores respondiam "quem paga a quem", e o segundo era cego à moeda
O defeito caro é estrutural, não aritmético. O caminho de escrita da liquidação planejava sobre internal/platform/simplify, um segundo motor de fluxo de caixa mínimo cujo struct Balance não tinha campo de moeda. A soma zero por moeda faz a soma plana também dar zero, então uma dívida de ¥300,000 e uma dívida de ₹500 se anulavam em nada e nenhuma proteção recusava o plano. O plano então juntava um credor em ienes com um devedor em rúpias.
A escrita piorava tudo. O serviço carimbava Currency: g.DefaultCurrency em toda liquidação, então uma dívida de ¥300,000 autorizava um lançamento em INR, e a dívida em ienes não podia ser quitada de forma alguma.
O simplify foi apagado e o /settle-plan foi aposentado. O internal/platform/settle é o único motor agora: ele particiona as moedas em conversíveis e não conversíveis, converte os saldos líquidos uma vez, e roteia cada moeda não conversível em sua própria denominação. Uma liquidação declara a moeda que quita, e esse campo é obrigatório assim que um grupo tem mais de uma.
Um limite de zero foi lido como nenhum limite
A proteção contra pagamento em excesso recusa um pagamento maior do que a dívida que ele quita. A proteção lia outstanding > 0 && amount > outstanding, então um limite de zero pulava a comparação por completo. Alguém registrando um pagamento contra uma dívida que não existe é justamente o caso para o qual a regra existe, e foi o caso que passou batido.
A correção é um tipo, não uma condição. OutstandingMinor agora é um *int64, então "ninguém calculou isto" não pode ser escrito do mesmo jeito que "a resposta é zero". Nil pula a verificação e significa genuinamente desconhecido; qualquer chamador com acesso ao ledger passa um número real.
Por que uma suíte no verde não provava nada
Toda fixture nesses caminhos usava INR. Uma comparação cega à moeda é invisível sob um teste de moeda única, porque com uma só moeda não há nada a confundir. As suítes não eram fracas, eram estreitas, e a auditoria que as teria ampliado tinha sido dispensada pela A17.
O verificador ponta a ponta do ledger compartilhava a cegueira, e essa é a parte que vale guardar. O verificador somava os lançamentos por membro sem agrupar por moeda, então dava alarme falso em um grupo saudável de duas moedas e somava zero em um grupo que estava quebrado duas vezes. Um falso negativo é a direção perigosa. Um verificador construído sobre a mesma suposição do código sempre concordará com o código.
| Defeito | Onde | O que um grupo JPY recebeu | Correção |
|---|---|---|---|
Um struct Balance sem moeda nele | platform/simplify | um credor em ienes juntado a um devedor em rúpias | simplify apagado; settle particiona por moeda |
| O padrão do grupo carimbado em toda liquidação | serviço de liquidação | uma dívida de ¥300,000 autorizava um lançamento em INR | uma liquidação declara a moeda que quita |
outstanding > 0 na proteção contra pagamento em excesso | serviço de liquidação | um limite de zero virava nenhum limite | *int64: nil significa desconhecido, zero significa zero |
| Dividir por 100 com um símbolo de rúpia | formatMinor do gateway | uma dívida de ¥6,000 exibida como -₹60.00 | símbolo e casas decimais da moeda |
| Saldos por moeda achatados em uma linha | /v1/claims | a moeda que o mapa escreveu por último | uma entrada por moeda, igual a GET /balances |
| Lançamentos somados por membro, moeda descartada | verificador ponta a ponta do ledger | um grupo quebrado duas vezes reportado como equilibrado | agrupar por membro e moeda |
Faça grep do literal 100 no seu código de dinheiro
Procure por essa constante, e por toda comparação que coloca dois valores lado a lado sem uma moeda ao lado deles. Depois conserte a coisa mais barata, a que evita os próximos seis: risque um follow-up no mesmo commit que o encerra. E apague um motor duplicado em vez de repará-lo. Duas respostas para "quem paga a quem" é como uma delas continua errada.
Perguntas frequentes
O que é divisão de despesas em várias moedas?
A divisão de despesas multimoeda registra cada despesa na moeda em que ela aconteceu e mantém um saldo separado por moeda, em vez de converter tudo em uma só. O Dimesum chaveia os saldos por grupo, membro e moeda, então uma dívida em ienes e uma dívida em rúpias nunca se fundem em um único número. A conversão é uma visão usada para um plano de liquidação, nunca um valor armazenado.
Por que usar 100 fixo no código de dinheiro é perigoso?
Um 100 hardcoded pressupõe que toda moeda tem duas casas decimais, e várias não têm. O JPY não tem unidade menor, então multiplicar por 100 transforma um recibo de ¥1,200 em 120,000 unidades menores, uma inflação de cem vezes e não um erro de arredondamento. O KWD tem três casas decimais, então a mesma constante erra por dez. Leia o expoente do ISO 4217 em vez disso.
Como evitar que dois motores de liquidação se contradigam?
Apague um dos dois motores em vez de reconciliá-los, porque uma segunda resposta para quem paga a quem é como a primeira continua errada. O Dimesum rodava simplify e settle lado a lado até uma auditoria descobrir que o primeiro não tinha moeda no seu tipo de saldo, o que permitia a um plano juntar um credor em ienes com um devedor em rúpias. O simplify foi removido e o /settle-plan aposentado, não remendado.
Por que a suíte de testes continuou no verde mesmo com seis bugs de dinheiro?
A suíte de testes continuou no verde porque toda fixture nos caminhos afetados usava uma única moeda, e uma comparação cega à moeda não pode falhar quando só existe uma moeda. O verificador ponta a ponta do ledger tinha a mesma suposição: somava os lançamentos por membro sem agrupar por moeda, então reportava um grupo quebrado duas vezes como equilibrado. Um verificador construído sobre a própria suposição do código concorda com o código.
O que fazer com uma anotação de follow-up depois que o recurso vai ao ar?
Risque uma anotação de follow-up no mesmo commit que encerra o trabalho que ela descreve. Um follow-up que se tornou verdade silenciosamente é pior do que um em aberto, porque aponta um leitor posterior para longe do código que costumava descrever. O Dimesum deixou uma anotação dizendo que os grupos estavam fixados em INR por uma semana depois que o multimoeda foi ao ar, e a auditoria seguinte pulou três caminhos de dinheiro confiando nela.
Posts populares
- O livro-razão append-only que mantém saldos exatos9 min de leitura
- Por que editar uma despesa deve refazer a divisão9 min de leitura
- Acerto de contas: menos transferências no grupo5 min de leitura
- Como dividir uma conta detalhada de restaurante10 min de leitura
- Como dividir o aluguel de forma justa6 min de leitura