Valor por extenso em C#: reais e centavos sem perder um centavo no caminho
Escreva valores em reais por extenso a partir de decimal, com cem e cento, mil sem "um", "de reais" nos milhões redondos e testes que pegam os erros de concordância.
O recibo saiu com "R$ 1.020,01 (mil e vinte reais)". O centavo sumiu na frase, e o cliente perguntou qual dos dois valia. A função de extenso recebia um double e separava os centavos com (int)((valor - reais) * 100). Em ponto flutuante, 1020,01 menos 1020 dá 0,00999..., vezes 100 dá 0,999..., e a conversão para inteiro trunca para zero. A resposta curta: a função de extenso recebe decimal, o mesmo tipo do dinheiro no sistema, recusa valores com mais de duas casas e é testada com os casos que quebram a concordância.
Este guia mostra uma implementação completa em C#, explica as regras de português que ela segue e lista os testes que pegam quase todos os bugs. O exemplo atravessa o texto com os valores do recibo da loja: 1.020,01, 1.200.000,00 e 0,50.
Por que o centavo some
double e float guardam números em base 2. Frações como 0,01 não têm representação exata em binário, então o valor guardado é uma aproximação, um pouco acima ou um pouco abaixo. Somar, subtrair e multiplicar acumula esse desvio. Truncar no fim transforma "quase 1" em 0.
O decimal do C# guarda números em base 10, com escala explícita. 1020,01 é exatamente 1020,01, e (valor - 1020) * 100 é exatamente 1. Se o sistema já guarda dinheiro em decimal (ou em centavos inteiros, como em dinheiro não mora em ponto flutuante), a função de extenso precisa receber esse mesmo valor, sem passar por double no meio.
As regras de português que a função precisa seguir
- Cem e cento: exatamente 100 é "cem"; de 101 a 199 é "cento e ...".
- Mil sem "um": 1.000 é "mil", não "um mil". Já 1.000.000 é "um milhão".
- Singular e plural: "um real", "dois reais", "um centavo", "dois centavos", "um milhão", "dois milhões".
- "De reais" nos milhões redondos: "um milhão de reais", "dois bilhões de reais". Se houver milhar ou unidade depois, some o "de": "um milhão e duzentos mil reais".
- O "e" entre grupos: dentro da centena, sempre ("trezentos e quarenta e cinco"). Entre grupos de milhar, o último grupo leva "e" quando é menor que 100 ou centena redonda: "mil e vinte", "mil e cem", mas "mil duzentos e trinta e quatro".
- Reais e centavos: unidos por "e": "mil e vinte reais e um centavo". Sem reais, só os centavos: "cinquenta centavos".
Há variações aceitas, como "quatorze" ou "catorze" e o uso de vírgula entre grupos grandes. Escolha uma forma, documente e mantenha. Em Portugal o texto muda em alguns pontos ("dezasseis", e "bilião" é outra ordem de grandeza), então a função deve deixar claro que é português do Brasil.
Como implementar passo a passo
- Valide a entrada: não negativa, no máximo duas casas decimais, abaixo do limite suportado.
- Separe reais e centavos com aritmética de
decimal. - Quebre os reais em grupos de três dígitos: unidades, milhares, milhões, bilhões.
- Escreva cada grupo com a regra de centena e dezena, mais o nome da escala.
- Junte os grupos com a regra do "e" e acrescente "real", "reais" ou "de reais".
- Acrescente os centavos e teste.
O código
using System;
using System.Collections.Generic;
public static class Extenso
{
private static readonly string[] Unidades =
{
"zero", "um", "dois", "três", "quatro", "cinco", "seis", "sete", "oito", "nove",
"dez", "onze", "doze", "treze", "quatorze", "quinze", "dezesseis", "dezessete", "dezoito", "dezenove"
};
private static readonly string[] Dezenas =
{
"", "", "vinte", "trinta", "quarenta", "cinquenta", "sessenta", "setenta", "oitenta", "noventa"
};
private static readonly string[] Centenas =
{
"", "cento", "duzentos", "trezentos", "quatrocentos", "quinhentos",
"seiscentos", "setecentos", "oitocentos", "novecentos"
};
public static string EmReais(decimal valor)
{
if (valor < 0)
throw new ArgumentOutOfRangeException(nameof(valor), "Valor negativo.");
if (valor != decimal.Round(valor, 2))
throw new ArgumentException("Valor com mais de duas casas decimais.", nameof(valor));
if (valor >= 1_000_000_000_000m)
throw new ArgumentOutOfRangeException(nameof(valor), "Valor acima do suportado.");
long reais = (long)decimal.Truncate(valor);
int centavos = (int)((valor - reais) * 100);
if (reais == 0 && centavos == 0)
return "zero real";
var partes = new List<string>();
if (reais > 0)
{
bool milhoesRedondos = reais >= 1_000_000 && reais % 1_000_000 == 0;
string moeda = reais == 1 ? "real" : "reais";
partes.Add(Inteiro(reais) + (milhoesRedondos ? " de " : " ") + moeda);
}
if (centavos > 0)
{
partes.Add(Inteiro(centavos) + (centavos == 1 ? " centavo" : " centavos"));
}
return string.Join(" e ", partes);
}
private static string Inteiro(long n)
{
var grupos = new List<int>();
while (n > 0)
{
grupos.Add((int)(n % 1000));
n /= 1000;
}
var blocos = new List<(string Texto, int Valor)>();
for (int i = grupos.Count - 1; i >= 0; i--)
{
int g = grupos[i];
if (g == 0) continue;
string texto = i switch
{
0 => Centena(g),
1 => g == 1 ? "mil" : Centena(g) + " mil",
2 => g == 1 ? "um milhão" : Centena(g) + " milhões",
_ => g == 1 ? "um bilhão" : Centena(g) + " bilhões",
};
blocos.Add((texto, g));
}
string resultado = blocos[0].Texto;
for (int k = 1; k < blocos.Count; k++)
{
bool ultimo = k == blocos.Count - 1;
int v = blocos[k].Valor;
bool comE = ultimo && (v < 100 || v % 100 == 0);
resultado += (comE ? " e " : " ") + blocos[k].Texto;
}
return resultado;
}
private static string Centena(int n)
{
if (n == 100) return "cem";
int c = n / 100;
int resto = n % 100;
if (c == 0) return Dezena(resto);
if (resto == 0) return Centenas[c];
return Centenas[c] + " e " + Dezena(resto);
}
private static string Dezena(int n)
{
if (n < 20) return Unidades[n];
int d = n / 10;
int u = n % 10;
return u == 0 ? Dezenas[d] : Dezenas[d] + " e " + Unidades[u];
}
}
Repare na segunda validação: em vez de arredondar em silêncio um valor como 10,005, a função recusa. Quem chama decide o arredondamento, com a mesma regra usada no cálculo do valor, e o número impresso e a frase continuam iguais.
O que sai para os valores do recibo
1020.01 => mil e vinte reais e um centavo
1200000.00 => um milhão e duzentos mil reais
0.50 => cinquenta centavos
1000000 => um milhão de reais
1234567.89 => um milhão duzentos e trinta e quatro mil quinhentos e sessenta e sete reais e oitenta e nove centavos
Os testes que pegam quase todo bug
Atributos do C# não aceitam decimal como constante, então os casos entram como texto e são convertidos com cultura invariante:
using System.Globalization;
using Xunit;
public class ExtensoTestes
{
[Theory]
[InlineData("0", "zero real")]
[InlineData("0.01", "um centavo")]
[InlineData("1", "um real")]
[InlineData("2", "dois reais")]
[InlineData("100", "cem reais")]
[InlineData("101", "cento e um reais")]
[InlineData("1000", "mil reais")]
[InlineData("1001", "mil e um reais")]
[InlineData("1020.01", "mil e vinte reais e um centavo")]
[InlineData("1000000", "um milhão de reais")]
[InlineData("2000000", "dois milhões de reais")]
[InlineData("1200000", "um milhão e duzentos mil reais")]
public void EscreveOValorCerto(string valor, string esperado)
{
var numero = decimal.Parse(valor, CultureInfo.InvariantCulture);
Assert.Equal(esperado, Extenso.EmReais(numero));
}
}
Zero, um, dois, cem, cento e um, mil, mil e um, um milhão e dois milhões cobrem plural, "cem/cento", "mil sem um" e "de reais". O 1020,01 é o teste de regressão do recibo.
Onde gerar e mostrar
Gere o extenso no servidor, na mesma camada que formata o número, a partir do mesmo valor. Recibo e contrato mostram os dois juntos: "R$ 1.020,01 (mil e vinte reais e um centavo)". Se o navegador gerar a frase com outra função e outro tipo numérico, cedo ou tarde os dois divergem. Nunca deixe a pessoa editar o extenso à mão num documento que tem valor legal.
Confira também o caminho até a função. O valor sai do banco numa coluna DECIMAL(12,2) e chega como decimal pelo driver; até aí, tudo exato. O risco aparece quando ele passa por um JSON lido como double, por uma planilha importada ou por um Convert.ToDouble esquecido num serviço intermediário. Procure no código todas as chamadas a EmReais e siga a variável para trás até a origem. Se em algum ponto ela virou double, o centavo já pode estar errado antes de a função começar.
Erros comuns
- Receber
doubleoufloat. O centavo é truncado e a frase diz um valor diferente do número. - Arredondar dentro da função. A frase usa uma regra de arredondamento diferente da do cálculo.
- "Um mil reais" ou "cento reais". Erro de português que aparece no documento do cliente.
- Esquecer o "de" nos milhões redondos. "Um milhão reais" no contrato.
- Testar só valores "bonitos". Os bugs moram em 1, 100, 1.000 e nos centavos.
- Gerar número e extenso em lugares diferentes. Uma mudança numa das funções faz os dois divergirem.
Checklist
- A função recebe
decimal, nãodouble. - Valores com mais de duas casas são recusados, não arredondados em silêncio.
- 100 vira "cem" e 101 vira "cento e um".
- 1.000 vira "mil", sem "um".
- Milhões redondos levam "de reais".
- Existe teste para 0, 1, 2, 100, 101, 1.000, 1.001, um milhão e um valor com centavos.
- Número e extenso são gerados no mesmo lugar, a partir do mesmo valor.
Passe 1020,01 para a função de extenso do seu sistema e leia a frase em voz alta. O centavo apareceu, e qual tipo numérico chega nessa função hoje?
Referência: https://help.market.com.br/csharp/extenso.htm