Programação Por Equipe DevBras

Capa do artigo: Valor por extenso em C#: reais e centavos sem perder um centavo no caminho

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

  1. Valide a entrada: não negativa, no máximo duas casas decimais, abaixo do limite suportado.
  2. Separe reais e centavos com aritmética de decimal.
  3. Quebre os reais em grupos de três dígitos: unidades, milhares, milhões, bilhões.
  4. Escreva cada grupo com a regra de centena e dezena, mais o nome da escala.
  5. Junte os grupos com a regra do "e" e acrescente "real", "reais" ou "de reais".
  6. 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 double ou float. 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ão double.
  • 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?

Avalie este artigo

Referência: https://help.market.com.br/csharp/extenso.htm