[ADD] l10n_br_pix: support payment via Pix QR codes

This is a standard developed by the Central Bank of Brazil and
provides QR codes (among other things) that allow customers to pay via
their bank app. It's popular and usage keeps increasing [1]. Pix codes
[2] are BR codes [3] which are based on EMV QR codes.

This commit adds support for static Pix codes. These contain all the
info needed to make a payment. Dynamic Pix codes are more complex and
not supported.

Two validators were used to verify correctness [4][5]. The resulting
Pix codes were also tested with the Caixa, Nubank and Itau bank
apps (thanks to INGO!).

Portuguese translations provided thanks to MADI.

This will be merged into l10n_br in master.

[1] https://www.bcb.gov.br/en/financialstability/pixstatistics
[2] https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf
[3] https://www.bcb.gov.br/content/config/Documents/BR_Code_MANUAL_Version_2_May_2020.pdf
[4] https://pix.nascent.com.br/
[5] https://openpix.com.br/qrcode/scanner/

task-3669412

closes odoo/odoo#156211

Signed-off-by: Josse Colpaert <jco@odoo.com>
This commit is contained in:
Joren Van Onder
2024-03-07 23:41:54 +00:00
parent 9db621fdd7
commit 6bd29670d0
9 changed files with 447 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+16
View File
@@ -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,
}
+109
View File
@@ -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 ""
+109
View File
@@ -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."
+2
View File
@@ -0,0 +1,2 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import res_partner_bank
@@ -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
)
+2
View File
@@ -0,0 +1,2 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_l10n_br_pix
@@ -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",
)
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_partner_bank_form_inherit_account" model="ir.ui.view">
<field name="name">res.partner.bank.form.inherit</field>
<field name="model">res.partner.bank</field>
<field name="inherit_id" ref="base.view_partner_bank_form"/>
<field name="arch" type="xml">
<field name="include_reference" position="after">
<p invisible="country_code != 'BR'">
<a href="https://www.odoo.com/documentation/17.0/applications/finance/fiscal_localizations/brazil.html" target='_blank'>Documentation</a>
</p>
</field>
</field>
</record>
</odoo>