[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:
@@ -0,0 +1,2 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import models
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 ""
|
||||
@@ -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."
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user