diff --git a/addons/l10n_br_pix/__init__.py b/addons/l10n_br_pix/__init__.py new file mode 100644 index 00000000000..d581b39cea7 --- /dev/null +++ b/addons/l10n_br_pix/__init__.py @@ -0,0 +1,2 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/addons/l10n_br_pix/__manifest__.py b/addons/l10n_br_pix/__manifest__.py new file mode 100644 index 00000000000..f22a86738a5 --- /dev/null +++ b/addons/l10n_br_pix/__manifest__.py @@ -0,0 +1,16 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + "name": "Brazil Pix QR codes", + "countries": ["br"], + "category": "Accounting/Localizations", + "description": """ +Implements Pix QR codes for Brazil. +""", + "depends": [ + "l10n_br", + "account_qr_code_emv", + ], + "data": ["views/res_bank_views.xml"], + "license": "LGPL-3", + "auto_install": True, +} diff --git a/addons/l10n_br_pix/i18n/l10n_br_pix.pot b/addons/l10n_br_pix/i18n/l10n_br_pix.pot new file mode 100644 index 00000000000..9989a6af60a --- /dev/null +++ b/addons/l10n_br_pix/i18n/l10n_br_pix.pot @@ -0,0 +1,109 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_br_pix +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-04 18:42+0000\n" +"PO-Revision-Date: 2024-03-04 18:42+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "%s is not a valid CPF or CNPJ (don't include periods or dashes)." +msgstr "" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "%s is not a valid email." +msgstr "" + +#. module: l10n_br_pix +#: model:ir.model,name:l10n_br_pix.model_res_partner_bank +msgid "Bank Accounts" +msgstr "" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__br_cpf_cnpj +msgid "CPF/CNPJ (BR)" +msgstr "" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "Can't generate a Pix QR code with a currency other than BRL." +msgstr "" + +#. module: l10n_br_pix +#: model_terms:ir.ui.view,arch_db:l10n_br_pix.view_partner_bank_form_inherit_account +msgid "Documentation" +msgstr "" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__email +msgid "Email Address" +msgstr "" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__mobile +msgid "Mobile Number" +msgstr "" + +#. module: l10n_br_pix +#: model:ir.model.fields,field_description:l10n_br_pix.field_account_setup_bank_manual_config__proxy_type +#: model:ir.model.fields,field_description:l10n_br_pix.field_res_partner_bank__proxy_type +msgid "Proxy Type" +msgstr "" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__br_random +msgid "Random Key (BR)" +msgstr "" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"The mobile number %s is invalid. It must start with +55, contain a 2 digit " +"territory or state code followed by a 9 digit number." +msgstr "" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"The proxy type must be Email Address, Mobile Number, CPF/CNPJ (BR) or Random" +" Key (BR) for Pix code generation." +msgstr "" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"The random key %s is invalid, the format looks like this: " +"71d6c6e1-64ea-4a11-9560-a10870c40ca2" +msgstr "" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"To generate a Pix code the proxy type for %s must be Email Address, Mobile " +"Number, CPF/CNPJ (BR) or Random Key (BR)." +msgstr "" diff --git a/addons/l10n_br_pix/i18n/pt_BR.po b/addons/l10n_br_pix/i18n/pt_BR.po new file mode 100644 index 00000000000..6d56db974b8 --- /dev/null +++ b/addons/l10n_br_pix/i18n/pt_BR.po @@ -0,0 +1,109 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_br_pix +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-04 18:42+0000\n" +"PO-Revision-Date: 2024-03-04 18:42+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "%s is not a valid CPF or CNPJ (don't include periods or dashes)." +msgstr "%s não é um CPF ou CNPJ válido (não inclua pontos ou travessões)." + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "%s is not a valid email." +msgstr "%s não é um e-mail válido" + +#. module: l10n_br_pix +#: model:ir.model,name:l10n_br_pix.model_res_partner_bank +msgid "Bank Accounts" +msgstr "Contas bancárias" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__br_cpf_cnpj +msgid "CPF/CNPJ (BR)" +msgstr "CPF/CNPJ" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "Can't generate a Pix QR code with a currency other than BRL." +msgstr "Não é possível gerar o código QR de Pix com moedas diferentes do BRL" + +#. module: l10n_br_pix +#: model_terms:ir.ui.view,arch_db:l10n_br_pix.view_partner_bank_form_inherit_account +msgid "Documentation" +msgstr "Documentação" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__email +msgid "Email Address" +msgstr "Endereço de e-mail" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__mobile +msgid "Mobile Number" +msgstr "Número de celular" + +#. module: l10n_br_pix +#: model:ir.model.fields,field_description:l10n_br_pix.field_account_setup_bank_manual_config__proxy_type +#: model:ir.model.fields,field_description:l10n_br_pix.field_res_partner_bank__proxy_type +msgid "Proxy Type" +msgstr "Tipo de proxy" + +#. module: l10n_br_pix +#: model:ir.model.fields.selection,name:l10n_br_pix.selection__res_partner_bank__proxy_type__br_random +msgid "Random Key (BR)" +msgstr "Chave aleatória" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"The mobile number %s is invalid. It must start with +55, contain a 2 digit " +"territory or state code followed by a 9 digit number." +msgstr "O número de celular %s é inválido. Ele deve começar com +55, conter o DDD de 2 dígitos e o número de 9 dígitos." + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"The proxy type must be Email Address, Mobile Number, CPF/CNPJ (BR) or Random" +" Key (BR) for Pix code generation." +msgstr "O tipo de proxy para geração do código Pix deve ser endereço de e-mail, número de celular, CPF/CNPF ou chave aleatória." + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"The random key %s is invalid, the format looks like this: " +"71d6c6e1-64ea-4a11-9560-a10870c40ca2" +msgstr "A chave aleatória %s é inválida. O formato deve se assemelhar a: 71d6c6e1-64ea-4a11-9560-a10870c40ca2" + +#. module: l10n_br_pix +#. odoo-python +#: code:addons/l10n_br_pix/models/res_partner_bank.py:0 +#, python-format +msgid "" +"To generate a Pix code the proxy type for %s must be Email Address, Mobile " +"Number, CPF/CNPJ (BR) or Random Key (BR)." +msgstr "Para gerar um código Pix, o tipo de proxy de %s deve ser endereço de e-mail, número de celular, CPF/CNPJ ou chave aleatória." diff --git a/addons/l10n_br_pix/models/__init__.py b/addons/l10n_br_pix/models/__init__.py new file mode 100644 index 00000000000..f83bf348204 --- /dev/null +++ b/addons/l10n_br_pix/models/__init__.py @@ -0,0 +1,2 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import res_partner_bank diff --git a/addons/l10n_br_pix/models/res_partner_bank.py b/addons/l10n_br_pix/models/res_partner_bank.py new file mode 100644 index 00000000000..cedcf7314bd --- /dev/null +++ b/addons/l10n_br_pix/models/res_partner_bank.py @@ -0,0 +1,124 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import re + +from odoo import models, fields, api, _ +from odoo.addons.mail.tools.mail_validation import mail_validate +from odoo.exceptions import ValidationError +from odoo.tools import float_repr + + +class ResPartnerBank(models.Model): + _inherit = "res.partner.bank" + + proxy_type = fields.Selection( + selection_add=[ + ("email", "Email Address"), + ("mobile", "Mobile Number"), + ("br_cpf_cnpj", "CPF/CNPJ (BR)"), + ("br_random", "Random Key (BR)"), + ], + ondelete={ + "email": "set default", + "mobile": "set default", + "br_cpf_cnpj": "set default", + "br_random": "set default", + }, + ) + + @api.constrains("proxy_type", "proxy_value", "partner_id") + def _check_br_proxy(self): + for bank in self.filtered(lambda bank: bank.country_code == "BR" and bank.proxy_type != "none"): + if bank.proxy_type not in ("email", "mobile", "br_cpf_cnpj", "br_random"): + raise ValidationError( + _( + "The proxy type must be Email Address, Mobile Number, CPF/CNPJ (BR) or Random Key (BR) for Pix code generation." + ) + ) + + value = bank.proxy_value + if bank.proxy_type == "email" and not mail_validate(value): + raise ValidationError(_("%s is not a valid email.", value)) + + if bank.proxy_type == "br_cpf_cnpj" and ( + not self.partner_id.check_vat_br(value) or any(not char.isdecimal() for char in value) + ): + raise ValidationError(_("%s is not a valid CPF or CNPJ (don't include periods or dashes).", value)) + + if bank.proxy_type == "mobile" and (not value or not value.startswith("+55") or len(value) != 14): + raise ValidationError( + _( + "The mobile number %s is invalid. It must start with +55, contain a 2 digit territory or state code followed by a 9 digit number.", + value, + ) + ) + + regex = r"%(char)s{8}-%(char)s{4}-%(char)s{4}-%(char)s{4}-%(char)s{12}" % {"char": "[a-fA-F0-9]"} + if bank.proxy_type == "br_random" and not re.fullmatch(regex, bank.proxy_value): + raise ValidationError( + _( + "The random key %s is invalid, the format looks like this: 71d6c6e1-64ea-4a11-9560-a10870c40ca2", + value, + ) + ) + + @api.depends("country_code") + def _compute_display_qr_setting(self): + """Override.""" + bank_br = self.filtered(lambda b: b.country_code == "BR") + bank_br.display_qr_setting = True + super(ResPartnerBank, self - bank_br)._compute_display_qr_setting() + + def _get_additional_data_field(self, comment): + """Override.""" + if self.country_code == "BR": + # Only include characters allowed by the Pix spec. + return self._serialize(5, re.sub(r"[^a-zA-Z0-9]", "", comment)) + return super()._get_additional_data_field(comment) + + def _get_qr_code_vals_list(self, *args, **kwargs): + """Override. Force the amount field to always have two decimals. Uppercase the merchant name and merchant city. + Although not specified explicitly in the spec, not uppercasing causes errors when scanning the code.""" + res = super()._get_qr_code_vals_list(*args, **kwargs) + if self.country_code == "BR": + res[5] = (res[5][0], float_repr(res[5][1], 2)) # amount + res[7] = (res[7][0], res[7][1].upper()) # merchant_name + res[8] = (res[8][0], res[8][1].upper()) # merchant_city + return res + + def _get_merchant_account_info(self): + """Override.""" + if self.country_code == "BR": + merchant_account_info_data = ( + (0, "br.gov.bcb.pix"), # GUI + (1, self.proxy_value), # key + ) + return 26, "".join(self._serialize(*val) for val in merchant_account_info_data) + + return super()._get_merchant_account_info() + + def _get_error_messages_for_qr(self, qr_method, debtor_partner, currency): + """Override.""" + if qr_method == "emv_qr" and self.country_code == "BR": + if currency.name != "BRL": + return _("Can't generate a Pix QR code with a currency other than BRL.") + return None + + return super()._get_error_messages_for_qr(qr_method, debtor_partner, currency) + + def _check_for_qr_code_errors( + self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication + ): + """Override.""" + if ( + qr_method == "emv_qr" + and self.country_code == "BR" + and self.proxy_type not in ("email", "mobile", "br_cpf_cnpj", "br_random") + ): + return _( + "To generate a Pix code the proxy type for %s must be Email Address, Mobile Number, CPF/CNPJ (BR) or Random Key (BR).", + self.display_name, + ) + + return super()._check_for_qr_code_errors( + qr_method, amount, currency, debtor_partner, free_communication, structured_communication + ) diff --git a/addons/l10n_br_pix/tests/__init__.py b/addons/l10n_br_pix/tests/__init__.py new file mode 100644 index 00000000000..43e08f7902e --- /dev/null +++ b/addons/l10n_br_pix/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_l10n_br_pix diff --git a/addons/l10n_br_pix/tests/test_l10n_br_pix.py b/addons/l10n_br_pix/tests/test_l10n_br_pix.py new file mode 100644 index 00000000000..261a50c883c --- /dev/null +++ b/addons/l10n_br_pix/tests/test_l10n_br_pix.py @@ -0,0 +1,68 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import Command +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.exceptions import ValidationError +from odoo.tests import tagged + + +@tagged("post_install_l10n", "post_install", "-at_install") +class TestL10nBrPix(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls, chart_template_ref="br"): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.partner_bank = cls.env["res.partner.bank"].create( + { + "acc_number": "123456789012345678", + "partner_id": cls.company_data["company"].partner_id.id, + "proxy_type": "br_random", + "proxy_value": "71d6c6e1-64ea-4a11-9560-a10870c40ca2", + "include_reference": True, + } + ) + + cls.invoice = cls.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": cls.partner_a.id, + "partner_bank_id": cls.partner_bank.id, + "invoice_line_ids": [ + Command.create({"quantity": 1, "price_unit": 12.30}) + ], # .30 to make sure we keep the trailing zero + } + ) + + def test_constraints(self): + self.partner_bank.write({"proxy_type": "email", "proxy_value": "a@example.com"}) + with self.assertRaises(ValidationError, msg="not a valid email"): + self.partner_bank.proxy_value = "example.com" + + self.partner_bank.write({"proxy_type": "br_cpf_cnpj", "proxy_value": "00740886967"}) + with self.assertRaises(ValidationError, msg="not a valid CPF"): + self.partner_bank.proxy_value = "444444321" + + self.partner_bank.write({"proxy_type": "mobile", "proxy_value": "+5561912345678"}) + with self.assertRaises(ValidationError, msg="The mobile number"): + self.partner_bank.proxy_value = "061912345678" + + self.partner_bank.write({"proxy_type": "br_random", "proxy_value": "71d6c6e1-64ea-4a11-9560-a10870c40ca2"}) + with self.assertRaises(ValidationError, msg="The random key"): + self.partner_bank.proxy_value = "not a random key" + + def test_get_qr_vals(self): + self.invoice.qr_code_method = "emv_qr" + demo_payment_reference = "NFe TÉST 0001" # É and spaces should be removed + + emv_qr_vals = self.invoice.partner_bank_id._get_qr_vals( + qr_method=self.invoice.qr_code_method, + amount=self.invoice.amount_residual, + currency=self.invoice.currency_id, + debtor_partner=self.invoice.partner_id, + free_communication=demo_payment_reference, + structured_communication=None, + ) + + qr_code_string = "".join(emv_qr_vals) + self.assertEqual( + qr_code_string, + "00020101021226580014br.gov.bcb.pix013671d6c6e1-64ea-4a11-9560-a10870c40ca2520400005303986540512.305802BR5914COMPANY_1_DATA62150511NFeTEST000163044CCF", + ) diff --git a/addons/l10n_br_pix/views/res_bank_views.xml b/addons/l10n_br_pix/views/res_bank_views.xml new file mode 100644 index 00000000000..b7e7644bcec --- /dev/null +++ b/addons/l10n_br_pix/views/res_bank_views.xml @@ -0,0 +1,15 @@ + + + + res.partner.bank.form.inherit + res.partner.bank + + + +

+ Documentation +

+
+
+
+