Repository: brazilian-utils/brutils-python (python)

Issue:
Conversão de Moeda Real para Texto
**Seu pedido de recurso está relacionado a um problema? Por favor, descreva.**

Dado um número que representa um valor monetário em Reais, quero obter uma String correspondente a esse valor convertido em texto por extenso, seguindo as regras gramaticais e monetárias do Brasil.

Por exemplo, converter `1523.45` para `"Mil quinhentos e vinte e três reais e quarenta e cinco centavos"`.

**Descreva a solução que você gostaria**

* Uma função `convert_real_to_text`, que recebe um valor monetário (decimal - definindo precisão para 2 casas decimais) em Reais e retorna uma String que representa esse valor em texto.
* A função deve lidar com a parte inteira (reais) e a parte decimal (centavos).
* Parte
* Para valores sem centavos, a parte de "centavos" deve ser omitida no texto.
* Para valores com centavos, adicionar a frase `"e [número] centavos"` no final.
* A função deve tratar singular e plural corretamente para `"real"` e `"centavo"`.
* Valor 0.00 deve retornar `"Zero reais"`.
* Valores grandes (até trilhões) precisam ser formatados corretamente. Lidar com singular e plural para milhão/milhões, bilhão/bilhões e trilhão/trilhões.
* Para valores menores que um real, como centavos isolados, deve retornar apenas o valor dos centavos no singular ou plural.
Exemplo: `0.01` → `"Um centavo"`. E, `0.50` → `"Cinquenta centavos"`
* Valores negativos devem retornar uma string começando com "Menos". Exemplo: `-50.25` → `"Menos cinquenta reais e vinte e cinco centavos"`

**Descreva alternativas que você considerou**

1. Seguir até o passo 8 do [guia de contribuição](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md)

1. Como parte do passo 8, criar o arquivo: `brutils-python/brutils/currency.py`.

    ```python
    def convert_real_to_text(valor):
        """
        Converts a given monetary value in Brazilian Reais to its textual representation.

        This function takes a decimal number representing a monetary value in Reais 
        and converts it to a string with the amount written out in Brazilian Portuguese. It 
        handles both the integer part (Reais) and the fractional part (centavos), respecting 
        the correct grammar for singular and plural cases, as well as special cases like zero 
        and negative values.

        Args:
            valor (decimal): The monetary value to be converted into text. 
                - The integer part represents Reais.
                - The decimal part represents centavos.

        Returns:
            str: A string with the monetary value written out in Brazilian Portuguese.
                - Returns "Zero reais" for a value of 0.00.
                - Handles negative values, adding "Menos" at the beginning of the string.

        Example:
            >>> convert_real_to_text(1523.45)
            "Mil quinhentos e vinte e três reais e quarenta e cinco centavos"
            >>> convert_real_to_text(1.00)
            "Um real"
            >>> convert_real_to_text(0.50)
            "Cinquenta centavos"
            >>> convert_real_to_text(0.00)
            "Zero reais"
            >>> convert_real_to_text(-50.25)
            "Menos cinquenta reais e vinte e cinco centavos"
        """
    
    def convert_real_to_text(valor):
        # implementar a lógica da função aqui
    ```

    e adicionar o import da nova função no arquivo `/brutils/__init__.py`

    ```python
    from brutils.currency import (
        convert_real_to_text,
    )
    ```

1. Como parte do passo 9, criar o arquivo de teste: `brutils-python/tests/test_currency.py`

    ```python
    from unittest import TestCase
    from brutils.currency import convert_real_to_text

    class TestCurrency(TestCase):
        def test_convert_real_to_text(self):
            self.assertEqual(convert_real_to_text(0.00), "Zero reais")
            self.assertEqual(convert_real_to_text(0.01), "Um centavo")
            self.assertEqual(convert_real_to_text(0.50), "Cinquenta centavos")
            self.assertEqual(convert_real_to_text(1.00), "Um real")
            self.assertEqual(convert_real_to_text(-50.25), "Menos cinquenta reais e vinte e cinco centavos")
            self.assertEqual(convert_real_to_text(1523.45), "Mil quinhentos e vinte e três reais e quarenta e cinco centavos")
            self.assertEqual(convert_real_to_text(1000000.00), "Um milhão de reais")
            self.assertEqual(convert_real_to_text(2000000.00), "Dois milhões de reais")
            self.assertEqual(convert_real_to_text(1000000000.00), "Um bilhão de reais")
            self.assertEqual(convert_real_to_text(2000000000.00), "Dois bilhões de reais")
            self.assertEqual(convert_real_to_text(1000000000000.00), "Um trilhão de reais")
            self.assertEqual(convert_real_to_text(2000000000000.00), "Dois trilhões de reais")
            self.assertEqual(convert_real_to_text(1000000.45), "Um milhão de reais e quarenta e cinco centavos")
            self.assertEqual(convert_real_to_text(2000000000.99), "Dois bilhões de reais e noventa e nove centavos")
            self.assertEqual(convert_real_to_text(1234567890.50), "Um bilhão duzentos e trinta e quatro milhões quinhentos e sessenta e sete mil oitocentos e noventa reais e cinquenta centavos")

            # Valores próximos a zero
            self.assertEqual(convert_real_to_text(0.001), "Zero reais")
            self.assertEqual(convert_real_to_text(0.009), "Zero reais")

            # Valores negativos em milhões
            self.assertEqual(convert_real_to_text(-1000000.00), "Menos um milhão de reais")
            self.assertEqual(convert_real_to_text(-2000000.50), "Menos dois milhões de reais e cinquenta centavos")

            # Valores grandes com centavos
            self.assertEqual(convert_real_to_text(1000000000.01), "Um bilhão de reais e um centavo")
            self.assertEqual(convert_real_to_text(1000000000.99), "Um bilhão de reais e noventa e nove centavos")

            # implementar mais casos de teste aqui
    ```

1. Seguir os passos seguintes do [guia de contribuição](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md)

**Contexto adicional**

* Regras gramaticais e monetárias do Brasil devem ser seguidas.
* Fonte de consulta: [https://www.normaculta.com.br/numeros-por-extenso/]

Pull request description:
feat(387): Conversão de Moeda Real para Texto
## Descrição

- Resolve issue https://github.com/brazilian-utils/brutils-python/issues/387
- [Adiciona método monetário 'convert_real_to_text'](https://github.com/brazilian-utils/brutils-python/commit/7fb2bedb3bd2b52520363f528098872b7eda7b29)
- Adiciona tests unitários
- Atualiza docs e changelog

## Mudanças Propostas
<!--- Liste as principais alterações ou melhorias que estão sendo propostas neste Pull Request.

- Exemplo 1: Descrição da primeira alteração.
- Exemplo 2: Descrição da segunda alteração.
- ...-->

## Checklist de Revisão
<!--- Marque as caixas que se aplicam. Você pode deixar caixas desmarcadas se elas não se aplicarem.-->

- [x] Eu li o [Contributing.md](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md)
- [x] Os testes foram adicionados ou atualizados para refletir as mudanças (se aplicável).
- [x] Foi adicionada uma entrada no changelog / Meu PR não necessita de uma nova entrada no changelog.
- [x] A [documentação](https://github.com/brazilian-utils/brutils-python/blob/main/README.md) em português foi atualizada ou criada, se necessário.
- [x] Se feita a documentação, a atualização do [arquivo em inglês](https://github.com/brazilian-utils/brutils-python/blob/main/README_EN.md). <!---Permitido uso de Google Tradutor/ChatGPT. -->
- [x] Eu documentei as minhas mudanças no código, adicionando docstrings e comentários. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#8-fa%C3%A7a-as-suas-altera%C3%A7%C3%B5es)
- [x] O código segue as diretrizes de estilo e padrões de codificação do projeto.
- [x] Todos os testes passam. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#testes)
- [x] O Pull Request foi testado localmente. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#7-execute-o-brutils-localmente)
- [x] Não há conflitos de mesclagem.

## Comentários Adicionais (opcional)
<!--- Adicione qualquer informação adicional ou contexto que você acha importante para os revisores entenderem suas mudanças.-->
- Fiz uso da lib [num2words](https://github.com/savoirfairelinux/num2words/blob/master/num2words/lang_PT_BR.py) já que a mesma já estava sendo utilizada no projeto no método de `convert_date_to_text`. No entanto, ao rodar os testes notei que o retorno de `convert_real_to_text(0.01)` estava incorreto. O resultado trazido pela lib era `Zero reais e um centavo` enquanto buscávamos a resposta `Um centavo`. Portanto, utilizei apenas a lib para a conversão clássica de número para texto e implementei a parte monetária da solução de acordo com o descrito na issue https://github.com/brazilian-utils/brutils-python/issues/387. 
- Vou submeter um fix pra essa issue na lib num2words, mas enquanto isso, a solução no brutils-python estará segura com a implementação da parte monetária.

## Issue Relacionada
- https://github.com/brazilian-utils/brutils-python/issues/387. 

Closes #387


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da7eb34..45d4d6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Utilitário `get_municipality_by_code` [412](https://github.com/brazilian-utils/brutils-python/pull/412)
 - Utilitário `get_code_by_municipality_name` [#399](https://github.com/brazilian-utils/brutils-python/issues/399)
 - Utilitário `format_currency` [#426](https://github.com/brazilian-utils/brutils-python/issues/426)
+- Utilitário `convert_real_to_text` [#387](https://github.com/brazilian-utils/brutils-python/pull/525)
 
 ## [2.2.0] - 2024-09-12
 
diff --git a/README.md b/README.md
index 42a41b0..6bc35d0 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,7 @@ False
   - [is_holiday](#is_holiday)
 - [Monetário](#monetário)
   - [format\_currency](#format_currency)
+  - [convert\_real\_to\_text](#convert_real_to_text)
 
 ## CPF
 
@@ -1254,6 +1255,42 @@ Exemplo:
 None
 ```
 
+### convert_real_to_text
+
+Converte um valor monetário em reais para sua representação por extenso. Esta função recebe um número decimal representando um valor monetário em reais e o converte para uma string com o valor escrito por extenso em português do Brasil. Ela trata tanto a parte inteira (reais) quanto a parte fracionária (centavos), respeitando a gramática correta para os casos de singular e plural, bem como casos especiais como zero e valores negativos.
+
+Argumentos:
+- amount (decimal): O valor monetário a ser convertido por extenso.
+  - A parte inteira representa os reais.
+  - A parte decimal representa os centavos.
+  - 2 casas decimais.
+
+Retorna:
+- str: Uma string com o valor monetário escrito por extenso em português do Brasil.
+  - Retorna "Zero reais" para o valor 0,00.
+  - Retorna None se o valor for inválido ou absolutamente maior que 1 quatrilhão.
+  - Trata valores negativos, adicionando "Menos" no início da string.
+
+Limitações:
+  - Esta função pode perder precisão em ±1 centavo para casos em que o valor absoluto
+    ultrapasse trilhões devido a erros de arredondamento de ponto flutuante.
+
+Exemplo:
+
+```python
+>>> from brutils.currency import convert_real_to_text
+>>> convert_real_to_text(1523.45)
+'Mil, quinhentos e vinte e três reais e quarenta e cinco centavos'
+>>> convert_real_to_text(0.01)
+'Um centavo'
+>>> convert_real_to_text(0.00)
+'Zero reais'
+>>> convert_real_to_text(-50.25)
+'Menos cinquenta reais e vinte e cinco centavos'
+>>> convert_real_to_text("invalid")
+None
+```
+
 # Novos Utilitários e Reportar Bugs
 
 Caso queira sugerir novas funcionalidades ou reportar bugs, basta criar
diff --git a/README_EN.md b/README_EN.md
index 4f97c00..1e2d29d 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -97,6 +97,7 @@ False
   - [is_holiday](#is_holiday)
 - [Monetary](#monetary)
   - [format_currency](#format_currency)
+  - [convert\_real\_to\_text](#convert_real_to_text)
 
 ## CPF
 
@@ -1257,6 +1258,44 @@ Example:
 None
 ```
 
+### convert_real_to_text
+
+Converts a given monetary value in Brazilian Reais to its textual representation. It takes a decimal number representing a monetary value in Reais and converts it to a string with the amount written out in Brazilian Portuguese. It handles both the integer part (Reais) and the fractional part (centavos), respecting the correct grammar for singular and plural cases, as well as special cases like zero and negative values.
+
+Args:
+
+- amount (decimal): The monetary value to be converted into text.
+  - The integer part represents Reais.
+  - The decimal part represents centavos.
+  - 2 decimal places
+
+Returns:
+
+- str: A string with the monetary value written out in Brazilian Portuguese.
+  - Returns "Zero reais" for a value of 0.00.
+  - Returns None if the amount is invalid or absolutely greater than 1 quadrillion.
+  - Handles negative values, adding "Menos" at the beginning of the string.
+
+Limitations:
+  - This function may lose precision by ±1 cent for cases where the absolute value
+    is beyond trillions due to floating-point rounding errors.
+
+Example:
+
+```python
+>>> from brutils.currency import convert_real_to_text
+>>> convert_real_to_text(1523.45)
+'Mil, quinhentos e vinte e três reais e quarenta e cinco centavos'
+>>> convert_real_to_text(0.01)
+'Um centavo'
+>>> convert_real_to_text(0.00)
+'Zero reais'
+>>> convert_real_to_text(-50.25)
+'Menos cinquenta reais e vinte e cinco centavos'
+>>> convert_real_to_text("invalid")
+None
+```
+
 # Feature Request and Bug Report
 
 If you want to suggest new features or report bugs, simply create
diff --git a/brutils/__init__.py b/brutils/__init__.py
index 66b1d2d..3959792 100644
--- a/brutils/__init__.py
+++ b/brutils/__init__.py
@@ -21,7 +21,7 @@ from brutils.cpf import is_valid as is_valid_cpf
 from brutils.cpf import remove_symbols as remove_symbols_cpf
 
 # Currency
-from brutils.currency import format_currency
+from brutils.currency import convert_real_to_text, format_currency
 
 # Date imports
 from brutils.date import convert_date_to_text
@@ -133,4 +133,5 @@ __all__ = [
     "is_holiday",
     # Currency
     "format_currency",
+    "convert_real_to_text",
 ]
diff --git a/brutils/currency.py b/brutils/currency.py
index f731e1a..ca2d861 100644
--- a/brutils/currency.py
+++ b/brutils/currency.py
@@ -1,4 +1,7 @@
-from decimal import Decimal, InvalidOperation
+from decimal import ROUND_DOWN, Decimal, InvalidOperation
+from typing import Union
+
+from num2words import num2words
 
 
 def format_currency(value):  # type: (float) -> str | None
@@ -38,3 +41,92 @@ def format_currency(value):  # type: (float) -> str | None
         return formatted_value
     except InvalidOperation:
         return None
+
+
+def convert_real_to_text(amount: Decimal) -> Union[str, None]:
+    """
+    Converts a given monetary value in Brazilian Reais to its textual representation.
+
+    This function takes a decimal number representing a monetary value in Reais
+    and converts it to a string with the amount written out in Brazilian Portuguese. It
+    handles both the integer part (Reais) and the fractional part (centavos), respecting
+    the correct grammar for singular and plural cases, as well as special cases like zero
+    and negative values.
+
+    Args:
+        amount (decimal): The monetary value to be converted into text.
+            - The integer part represents Reais.
+            - The decimal part represents centavos.
+            - 2 decimal places
+
+    Returns:
+        str: A string with the monetary value written out in Brazilian Portuguese.
+            - Returns "Zero reais" for a value of 0.00.
+            - Returns None if the amount is invalid or absolutely greater than 1 quadrillion.
+            - Handles negative values, adding "Menos" at the beginning of the string.
+
+    Limitations:
+        - This function may lose precision by ±1 cent for cases where the absolute value
+          is beyond trillions due to floating-point rounding errors.
+
+    Example:
+        >>> convert_real_to_text(1523.45)
+        "Mil, quinhentos e vinte e três reais e quarenta e cinco centavos"
+        >>> convert_real_to_text(1.00)
+        "Um real"
+        >>> convert_real_to_text(0.50)
+        "Cinquenta centavos"
+        >>> convert_real_to_text(0.00)
+        "Zero reais"
+        >>> convert_real_to_text(-50.25)
+        "Menos cinquenta reais e vinte e cinco centavos"
+    """
+
+    try:
+        amount = Decimal(str(amount)).quantize(
+            Decimal("0.01"), rounding=ROUND_DOWN
+        )
+    except InvalidOperation:
+        return None
+
+    if amount.is_nan() or amount.is_infinite():
+        return None
+
+    if abs(amount) > Decimal("1000000000000000.00"):  # 1 quadrillion
+        return None
+
+    negative = amount < 0
+    amount = abs(amount)
+
+    reais = int(amount)
+    centavos = int((amount - reais) * 100)
+
+    parts = []
+
+    if reais > 0:
+        """
+        Note:
+        Although the `num2words` library provides a "to='currency'" feature, it has known
+        issues with the representation of "zero reais" and "zero centavos". Therefore, this
+        implementation uses only the traditional number-to-text conversion for better accuracy.
+        """
+        reais_text = num2words(reais, lang="pt_BR")
+        currency_text = "real" if reais == 1 else "reais"
+        conector = "de " if reais_text.endswith(("lhão", "lhões")) else ""
+        parts.append(f"{reais_text} {conector}{currency_text}")
+
+    if centavos > 0:
+        centavos_text = f"{num2words(centavos, lang='pt_BR')} {'centavo' if centavos == 1 else 'centavos'}"
+        if reais > 0:
+            parts.append(f"e {centavos_text}")
+        else:
+            parts.append(centavos_text)
+
+    if reais == 0 and centavos == 0:
+        parts.append("Zero reais")
+
+    result = " ".join(parts)
+    if negative:
+        result = f"Menos {result}"
+
+    return result.capitalize()


Test patch:
diff --git a/tests/test_currency.py b/tests/test_currency.py
index 7996f2f..4b99ec9 100644
--- a/tests/test_currency.py
+++ b/tests/test_currency.py
@@ -1,7 +1,7 @@
 from decimal import Decimal
 from unittest import TestCase
 
-from brutils.currency import format_currency
+from brutils.currency import convert_real_to_text, format_currency
 
 
 class TestFormatCurrency(TestCase):
@@ -25,3 +25,111 @@ class TestFormatCurrency(TestCase):
         assert format_currency("not a number") is None
         assert format_currency("09809,87") is None
         assert format_currency("897L") is None
+
+
+class TestConvertRealToText(TestCase):
+    def test_convert_real_to_text(self):
+        self.assertEqual(convert_real_to_text(0.00), "Zero reais")
+        self.assertEqual(convert_real_to_text(0.01), "Um centavo")
+        self.assertEqual(convert_real_to_text(0.50), "Cinquenta centavos")
+        self.assertEqual(convert_real_to_text(1.00), "Um real")
+        self.assertEqual(
+            convert_real_to_text(-50.25),
+            "Menos cinquenta reais e vinte e cinco centavos",
+        )
+        self.assertEqual(
+            convert_real_to_text(1523.45),
+            "Mil, quinhentos e vinte e três reais e quarenta e cinco centavos",
+        )
+        self.assertEqual(convert_real_to_text(1000000.00), "Um milhão de reais")
+        self.assertEqual(
+            convert_real_to_text(2000000.00), "Dois milhões de reais"
+        )
+        self.assertEqual(
+            convert_real_to_text(1000000000.00), "Um bilhão de reais"
+        )
+        self.assertEqual(
+            convert_real_to_text(2000000000.00), "Dois bilhões de reais"
+        )
+        self.assertEqual(
+            convert_real_to_text(1000000000000.00), "Um trilhão de reais"
+        )
+        self.assertEqual(
+            convert_real_to_text(2000000000000.00), "Dois trilhões de reais"
+        )
+        self.assertEqual(
+            convert_real_to_text(1000000.45),
+            "Um milhão de reais e quarenta e cinco centavos",
+        )
+        self.assertEqual(
+            convert_real_to_text(2000000000.99),
+            "Dois bilhões de reais e noventa e nove centavos",
+        )
+        self.assertEqual(
+            convert_real_to_text(1234567890.50),
+            "Um bilhão, duzentos e trinta e quatro milhões, quinhentos e sessenta e sete mil, oitocentos e noventa reais e cinquenta centavos",
+        )
+
+        # Almost zero values
+        self.assertEqual(convert_real_to_text(0.001), "Zero reais")
+        self.assertEqual(convert_real_to_text(0.009), "Zero reais")
+
+        # Negative milions
+        self.assertEqual(
+            convert_real_to_text(-1000000.00), "Menos um milhão de reais"
+        )
+        self.assertEqual(
+            convert_real_to_text(-2000000.50),
+            "Menos dois milhões de reais e cinquenta centavos",
+        )
+
+        # billions with cents
+        self.assertEqual(
+            convert_real_to_text(1000000000.01),
+            "Um bilhão de reais e um centavo",
+        )
+        self.assertEqual(
+            convert_real_to_text(1000000000.99),
+            "Um bilhão de reais e noventa e nove centavos",
+        )
+
+        self.assertEqual(
+            convert_real_to_text(999999999999.99),
+            "Novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos",
+        )
+
+        # trillions with cents
+        self.assertEqual(
+            convert_real_to_text(1000000000000.01),
+            "Um trilhão de reais e um centavo",
+        )
+        self.assertEqual(
+            convert_real_to_text(1000000000000.99),
+            "Um trilhão de reais e noventa e nove centavos",
+        )
+        self.assertEqual(
+            convert_real_to_text(9999999999999.99),
+            "Nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos",
+        )
+
+        # 1 quadrillion
+        self.assertEqual(
+            convert_real_to_text(1000000000000000.00),
+            "Um quatrilhão de reais",
+        )
+
+        # Edge cases should return None
+        self.assertIsNone(
+            convert_real_to_text("invalid_value")
+        )  # invalid value
+        self.assertIsNone(convert_real_to_text(None))  # None value
+        self.assertIsNone(
+            convert_real_to_text(-1000000000000001.00)
+        )  # less than -1 quadrillion
+        self.assertIsNone(
+            convert_real_to_text(-1000000000000001.00)
+        )  # more than 1 quadrillion
+        self.assertIsNone(convert_real_to_text(float("inf")))  # Infinity
+        self.assertIsNone(
+            convert_real_to_text(float("nan"))
+        )  # Not a number (NaN)