From 3b24c950c612bdb2632805d9e1c4b485777a95de Mon Sep 17 00:00:00 2001 From: Neto Date: Mon, 19 Sep 2022 20:32:20 -0300 Subject: [PATCH 1/3] [IMP] l10n_br_base: add pix fields for partners --- l10n_br_base/__manifest__.py | 5 +- l10n_br_base/demo/res_partner_pix_demo.xml | 32 ++++ l10n_br_base/models/__init__.py | 1 + l10n_br_base/models/res_partner.py | 22 +++ l10n_br_base/models/res_partner_bank.py | 52 +++++++ l10n_br_base/models/res_partner_pix.py | 150 +++++++++++++++++++ l10n_br_base/security/ir.model.access.csv | 2 + l10n_br_base/tests/__init__.py | 2 + l10n_br_base/tests/test_partner_bank.py | 36 +++++ l10n_br_base/tests/test_valid_pix.py | 114 ++++++++++++++ l10n_br_base/views/res_partner_bank_view.xml | 20 +++ requirements.txt | 2 + 12 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 l10n_br_base/demo/res_partner_pix_demo.xml create mode 100644 l10n_br_base/models/res_partner_pix.py create mode 100644 l10n_br_base/tests/test_partner_bank.py create mode 100644 l10n_br_base/tests/test_valid_pix.py diff --git a/l10n_br_base/__manifest__.py b/l10n_br_base/__manifest__.py index bc8e288f7d96..83683535d7af 100644 --- a/l10n_br_base/__manifest__.py +++ b/l10n_br_base/__manifest__.py @@ -32,9 +32,12 @@ "demo/res_partner_demo.xml", "demo/res_company_demo.xml", "demo/res_users_demo.xml", + "demo/res_partner_pix_demo.xml", ], "installable": True, "pre_init_hook": "pre_init_hook", "development_status": "Mature", - "external_dependencies": {"python": ["num2words", "erpbrasil.base"]}, + "external_dependencies": { + "python": ["num2words", "erpbrasil.base", "phonenumbers", "email_validator"] + }, } diff --git a/l10n_br_base/demo/res_partner_pix_demo.xml b/l10n_br_base/demo/res_partner_pix_demo.xml new file mode 100644 index 000000000000..bf49958115be --- /dev/null +++ b/l10n_br_base/demo/res_partner_pix_demo.xml @@ -0,0 +1,32 @@ + + + + + + + cnpj_cpf + 62228384000151 + + + + + phone + 1144576060 + + + + + email + amdteste@amdteste.com.br + + + + + evp + 123e4567-e12b-12d1-a456-426655440000 + + + diff --git a/l10n_br_base/models/__init__.py b/l10n_br_base/models/__init__.py index 9f0a1867f154..a73c93dfcb85 100644 --- a/l10n_br_base/models/__init__.py +++ b/l10n_br_base/models/__init__.py @@ -12,3 +12,4 @@ from . import state_tax_numbers from . import res_company from . import res_config_settings +from . import res_partner_pix diff --git a/l10n_br_base/models/res_partner.py b/l10n_br_base/models/res_partner.py index 0819daabd7b6..9e9379ecfb1c 100644 --- a/l10n_br_base/models/res_partner.py +++ b/l10n_br_base/models/res_partner.py @@ -34,6 +34,18 @@ class Partner(models.Model): union_entity_code = fields.Char(string="Union Entity code") + pix_key_ids = fields.One2many( + string="Pix Keys", + comodel_name="res.partner.pix", + inverse_name="partner_id", + help="Keys for Brazilian instant payment (pix)", + ) + + show_l10n_br = fields.Boolean( + compute="_compute_show_l10n_br", + help="Indicates if Brazilian localization fields should be displayed.", + ) + @api.constrains("cnpj_cpf", "inscr_est") def _check_cnpj_inscr_est(self): for record in self: @@ -176,3 +188,13 @@ def _address_fields(self): @api.onchange("city_id") def _onchange_city_id(self): self.city = self.city_id.name + + def _compute_show_l10n_br(self): + """ + Defines when Brazilian localization fields should be displayed. + """ + for rec in self: + if rec.company_id and rec.company_id.country_id != self.env.ref("base.br"): + rec.show_l10n_br = False + else: + rec.show_l10n_br = True diff --git a/l10n_br_base/models/res_partner_bank.py b/l10n_br_base/models/res_partner_bank.py index 615ed219a660..fd43ea563003 100644 --- a/l10n_br_base/models/res_partner_bank.py +++ b/l10n_br_base/models/res_partner_bank.py @@ -15,6 +15,12 @@ ("13", _("Conta depósito judicial/Depósito em consignação conjunta")), ] +TRANSACTIONAL_ACCOUNT_TYPE = [ + ("checking", _("Checking Account (Conta Corrente)")), + ("saving", _("Saving Account (Conta Poupança)")), + ("payment", _("Prepaid Payment Account (Conta Pagamento)")), +] + class ResPartnerBank(models.Model): """Adiciona campos necessários para o cadastramentos de contas @@ -28,6 +34,19 @@ class ResPartnerBank(models.Model): default="01", ) + transactional_acc_type = fields.Selection( + selection=TRANSACTIONAL_ACCOUNT_TYPE, + string="Account Type", + help="Type of transactional account, classification used in " + "the Brazilian instant payment system (PIX)", + ) + + partner_pix_ids = fields.One2many( + comodel_name="res.partner.pix", + inverse_name="partner_bank_id", + string="Pix Keys", + ) + acc_number = fields.Char( string="Account Number", size=64, @@ -55,9 +74,42 @@ class ResPartnerBank(models.Model): help="Last part of BIC/Swift Code.", ) + company_country_id = fields.Many2one( + comodel_name="res.country", + string="Company Country", + related="company_id.country_id", + ) + @api.constrains("bra_number") def _check_bra_number(self): for b in self: if b.bank_id.code_bc: if len(b.bra_number) > 4: raise UserError(_("Bank branch code must be four caracteres.")) + + @api.constrains( + "transactional_acc_type", + "bank_id", + "acc_number", + "bra_number", + "acc_number_dig", + ) + def _check_transc_acc_type(self): + for rec in self: + if rec.transactional_acc_type: + if not rec.bank_id or not rec.bank_id.code_bc or not rec.acc_number: + raise UserError( + _( + "a transactional account must contain the bank " + "information (code_bc) and the account number" + ) + ) + if rec.transactional_acc_type in ["checking", "saving"]: + if not rec.bra_number or not rec.acc_number_dig: + raise UserError( + _( + "A Checking Account or Saving Account transactional account " + "must contain the branch number and the account verification " + "digit." + ) + ) diff --git a/l10n_br_base/models/res_partner_pix.py b/l10n_br_base/models/res_partner_pix.py new file mode 100644 index 000000000000..ced29b51923b --- /dev/null +++ b/l10n_br_base/models/res_partner_pix.py @@ -0,0 +1,150 @@ +import phonenumbers +from email_validator import EmailSyntaxError, validate_email +from erpbrasil.base.fiscal import cnpj_cpf + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PartnerPix(models.Model): + _name = "res.partner.pix" + _description = "Brazilian instant payment ecosystem (Pix)" + _order = "sequence, id" + _rec_name = "key" + + _sql_constraints = [ + ( + "partner_pix_key_unique", + "unique(key_type, key, partner_id)", + "A Pix Key with this values already exists in this partner.", + ) + ] + + KEY_TYPES = [ + ("cnpj_cpf", _("CPF or CNPJ")), + ("phone", _("Phone Number")), + ("email", _("E-mail")), + ("evp", _("Random Key")), + ] + + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Partner", + ondelete="cascade", + required=True, + ) + sequence = fields.Integer(default=10) + key_type = fields.Selection( + selection=KEY_TYPES, + string="Type", + required=True, + ) + key = fields.Char( + help="PIX Addressing key", + required=True, + ) + + partner_bank_id = fields.Many2one( + comodel_name="res.partner.bank", + string="Bank Account", + domain="[('partner_id', '=', partner_id)]", + ) + + def _normalize_email(self, email): + try: + result = validate_email( + email, + check_deliverability=False, + ) + except EmailSyntaxError: + raise ValidationError(_(f"{email.strip()} is an invalid email")) + normalized_email = result["local"].lower() + "@" + result["domain_i18n"] + if len(normalized_email) > 77: + raise ValidationError( + _( + f"The email is too long, " + f"a maximum of 77 characters is allowed: \n{email.strip()}" + ) + ) + return normalized_email + + def _normalize_phone(self, phone): + try: + phonenumber = phonenumbers.parse(phone, "BR") + except phonenumbers.phonenumberutil.NumberParseException as e: + raise ValidationError(_(f"Unable to parse {phone}: {str(e)}")) + if not phonenumbers.is_possible_number(phonenumber): + raise ValidationError( + _(f"Impossible number {phone}: probably invalid number of digits.") + ) + if not phonenumbers.is_valid_number(phonenumber): + raise ValidationError( + _(f"Invalid number {phone}: probably incorrect prefix.") + ) + phone = phonenumbers.format_number( + phonenumber, phonenumbers.PhoneNumberFormat.E164 + ) + return phone + + def _normalize_cnpj_cpf(self, doc_number): + doc_number = "".join(char for char in doc_number if char.isdigit()) + if not 11 <= len(doc_number) <= 14: + raise ValidationError( + _( + f"Invalid Document Number {doc_number}: " + f"\nThe CPF must have 11 digits and the CNPJ 14 digits." + ) + ) + is_valid = cnpj_cpf.validar(doc_number) + if not is_valid: + raise ValidationError(_(f"Invalid Document Number: {doc_number}")) + return doc_number + + def _normalize_evp(self, key): + # EVP: Endereço Virtual de Pagamento (chave aleatória) + # ex: 123e4567-e12b-12d1-a456-426655440000 + key = "".join(key.split()) + if len(key) != 36: + raise ValidationError( + _(f"Invalid Random Key: {key}, cannot be longer than 35 characters") + ) + blocks = key.split("-") + if len(blocks) != 5: + raise ValidationError( + _(f"Invalid Random Key: {key}, the key must consist of five blocks.") + ) + for block in blocks: + try: + int(block, 16) + except ValueError: + raise ValidationError( + _( + f"Invalid Random Key: {key} \nthe block {block} " + f"is not a valid hexadecimal format." + ) + ) + return key + + @api.model + def create(self, vals): + self.check_vals(vals) + return super(PartnerPix, self).create(vals) + + def write(self, vals): + self.check_vals(vals) + return super(PartnerPix, self).write(vals) + + def check_vals(self, vals): + key_type = vals.get("key_type") or self.key_type + key = vals.get("key") or self.key + if not key or not key_type: + return + if key_type == "email": + key = self._normalize_email(key) + elif key_type == "phone": + key = self._normalize_phone(key) + elif key_type == "cnpj_cpf": + key = self._normalize_cnpj_cpf(key) + elif key_type == "evp": + key = self._normalize_evp(key) + vals["key"] = key diff --git a/l10n_br_base/security/ir.model.access.csv b/l10n_br_base/security/ir.model.access.csv index e54d3b0c5a8e..24b816d48b89 100644 --- a/l10n_br_base/security/ir.model.access.csv +++ b/l10n_br_base/security/ir.model.access.csv @@ -1,3 +1,5 @@ "id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" "state_tax_numbers_user","State Tax Numbers for User","model_state_tax_numbers","base.group_user",1,0,0,0 "state_tax_numbers_manager","State Tax Numbers for Manager","model_state_tax_numbers","base.group_system",1,1,1,1 +"res_partner_pix_user","Partner PIX for User","model_res_partner_pix","base.group_user",1,0,0,0 +"res_partner_pix_manager","Partner PIX for Partner Manager","model_res_partner_pix","base.group_partner_manager",1,1,1,1 diff --git a/l10n_br_base/tests/__init__.py b/l10n_br_base/tests/__init__.py index 175dbac74bf8..a89f88a8c1a1 100644 --- a/l10n_br_base/tests/__init__.py +++ b/l10n_br_base/tests/__init__.py @@ -8,3 +8,5 @@ from . import test_valid_createid from . import test_base_onchange from . import test_other_ie +from . import test_valid_pix +from . import test_partner_bank diff --git a/l10n_br_base/tests/test_partner_bank.py b/l10n_br_base/tests/test_partner_bank.py new file mode 100644 index 000000000000..02721dd5029c --- /dev/null +++ b/l10n_br_base/tests/test_partner_bank.py @@ -0,0 +1,36 @@ +from odoo.exceptions import UserError +from odoo.tests import TransactionCase + + +class PartnerBankTest(TransactionCase): + def setUp(self): + super().setUp() + self.partner_bank_model = self.env["res.partner.bank"] + self.partner_id = self.env.ref("l10n_br_base.res_partner_amd") + self.bank_id = self.env.ref("l10n_br_base.res_bank_001") + + def test_ok_transactional_acc_type(self): + ok_bank_vals = { + "partner_id": self.partner_id.id, + "transactional_acc_type": "checking", + "bank_id": self.bank_id.id, + "bra_number": "1020", + "acc_number": "102030", + "acc_number_dig": "9", + } + ok_acc_bank = self.partner_bank_model.with_context( + tracking_disable=True + ).create(ok_bank_vals) + self.assertTrue(ok_acc_bank.exists()) + + def test_wrong_transactional_acc_type(self): + wrong_bank_vals = { + "partner_id": self.partner_id.id, + "transactional_acc_type": "checking", + "bra_number": "1020", + "acc_number_dig": "9", + } + with self.assertRaises(UserError): + self.partner_bank_model.with_context(tracking_disable=True).create( + wrong_bank_vals + ) diff --git a/l10n_br_base/tests/test_valid_pix.py b/l10n_br_base/tests/test_valid_pix.py new file mode 100644 index 000000000000..465c3fd44dce --- /dev/null +++ b/l10n_br_base/tests/test_valid_pix.py @@ -0,0 +1,114 @@ +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase +from odoo.tools import mute_logger + + +class ValidCreatePIXTest(TransactionCase): + """Test if ValidationError is raised well during create({})""" + + def setUp(self): + super().setUp() + self.res_partner_pix_model = self.env["res.partner.pix"] + self.partner_id = self.env.ref("l10n_br_base.res_partner_amd") + + def test_invalid_pix_cnpj_too_big(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "cnpj_cpf", + "key": "0296089500013199", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_cnpj_too_less(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "cnpj_cpf", + "key": "950001319", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_cnpj_wrong_value(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "cnpj_cpf", + "key": "12345897560234", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_phone_wrong_value(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "phone", + "key": "1103252020", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_phone_wrong_country_code(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "phone", + "key": "0119991123456789", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_email_wrong_value(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "email", + "key": "teste#teste.com", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_email_too_long(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "email", + "key": "toooooooolooooooooooongemaaaaaailllllll@teeeeeeeee" + "eeeeeeeeeeeeeeeeeeeest.com.br", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_EVP_wrong_value(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "evp", + "key": "nmmnaasa-qwhjwqhjk-2112", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_EVP_wrong_blocks(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "evp", + "key": "123e4567-e12b-12d1-a456-426655-40000", + } + self.check_validation_error_on_create(pix_vals) + + def test_invalid_pix_EVP_wrong_hex(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "evp", + "key": "123*4567-e12b-12d1-a456-426655440000", + } + self.check_validation_error_on_create(pix_vals) + + def check_validation_error_on_create(self, pix_vals): + with self.assertRaises(ValidationError): + self.res_partner_pix_model.with_context(tracking_disable=True).create( + pix_vals + ) + + def test_repeated_pix_key(self): + pix_vals = { + "partner_id": self.partner_id.id, + "key_type": "phone", + "key": "+50372424737", + } + self.res_partner_pix_model.with_context(tracking_disable=True).create(pix_vals) + with mute_logger("odoo.sql_db"): + with self.assertRaisesRegex(IntegrityError, "partner_pix_key_unique"): + self.res_partner_pix_model.with_context(tracking_disable=True).create( + pix_vals + ) diff --git a/l10n_br_base/views/res_partner_bank_view.xml b/l10n_br_base/views/res_partner_bank_view.xml index 3df9df3263af..8eb09e7bed17 100644 --- a/l10n_br_base/views/res_partner_bank_view.xml +++ b/l10n_br_base/views/res_partner_bank_view.xml @@ -25,6 +25,26 @@ + + + + + + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 5080d9a6e9bd..bd88372e990b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ # generated from manifests external_dependencies +email_validator erpbrasil.assinatura erpbrasil.base erpbrasil.edoc @@ -7,6 +8,7 @@ erpbrasil.transmissao nfelib num2words odoo_test_helper +phonenumbers pycep_correios workalendar xmldiff From f4642b2489efc40fcd2f88758a56aa235ab7e80b Mon Sep 17 00:00:00 2001 From: Neto Date: Mon, 19 Sep 2022 20:32:48 -0300 Subject: [PATCH 2/3] [IMP] l10n_br_account: add pix fields for partners --- l10n_br_account/__manifest__.py | 1 + l10n_br_account/views/res_partner_view.xml | 34 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 l10n_br_account/views/res_partner_view.xml diff --git a/l10n_br_account/__manifest__.py b/l10n_br_account/__manifest__.py index 60a2a707420d..89ae4f5d123a 100644 --- a/l10n_br_account/__manifest__.py +++ b/l10n_br_account/__manifest__.py @@ -40,6 +40,7 @@ "views/l10n_br_account_menu.xml", # Report # "report/account_invoice_report_view.xml", + "views/res_partner_view.xml", ], "demo": [ "demo/res_users_demo.xml", diff --git a/l10n_br_account/views/res_partner_view.xml b/l10n_br_account/views/res_partner_view.xml new file mode 100644 index 000000000000..7da071bd6c9a --- /dev/null +++ b/l10n_br_account/views/res_partner_view.xml @@ -0,0 +1,34 @@ + + + + + l10n_br_account.res.partner.form + res.partner + 99 + + + +
+ + + + + + + + + + + + + + + + From 05863d6d38f5fccaa32566aa742f53bc22d1fbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Valyi?= Date: Thu, 29 Sep 2022 20:03:41 -0300 Subject: [PATCH 3/3] [DOC] README + CONTRIBUTORS update --- l10n_br_base/readme/CONTRIBUTORS.rst | 17 +++++++++++++---- l10n_br_base/readme/DESCRIPTION.rst | 3 ++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/l10n_br_base/readme/CONTRIBUTORS.rst b/l10n_br_base/readme/CONTRIBUTORS.rst index 363560e1d62d..a3f5c99f208c 100644 --- a/l10n_br_base/readme/CONTRIBUTORS.rst +++ b/l10n_br_base/readme/CONTRIBUTORS.rst @@ -1,4 +1,13 @@ -* Renato Lima -* Raphaël Valyi -* Luis Felipe Mileo -* Michell Stuttgart +* `Akretion `_: + + * Renato Lima + * Raphaël Valyi + +* `KMEE `_: + + * Luis Felipe Mileo + * Michell Stuttgart + +* `Engenere `_: + + * Antônio S. Pereira Neto diff --git a/l10n_br_base/readme/DESCRIPTION.rst b/l10n_br_base/readme/DESCRIPTION.rst index 29ddf99bc57f..6fb4d7cf65a5 100644 --- a/l10n_br_base/readme/DESCRIPTION.rst +++ b/l10n_br_base/readme/DESCRIPTION.rst @@ -1,4 +1,4 @@ -Este é o módulo raiz da localização brasileira. Ele traz adaptações nos modelos do módulo ``base`` do Odoo como Parceiros, Empresas e Endereços: +Este é o módulo 'raiz' da localização brasileira. Ele traz adaptações nos modelos do módulo ``base`` do Odoo como Parceiros, Empresas e Endereços: * Campo CNPJ e CPF com formatação e validação destes campos; * Campo de Inscrição Estadual com validação; @@ -6,6 +6,7 @@ Este é o módulo raiz da localização brasileira. Ele traz adaptações nos mo * Código do Banco Central e Siscomex para países; * Código do IBGE para estados e municípios; * Lista dos Bancos brasileiros; +* Contas bancarias e chaves PIX dos parceiros; * Lista dos municípios brasileiros. Se trata de um módulo muito simples e maduro. Existem alguns outros módulos simples que dependem apenas desse módulo ou quase como ``l10n_br_crm`` ou ``l10n_br_portal``.