diff --git a/docs/authors.rst b/docs/authors.rst index 18945866..9423fa62 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -115,6 +115,7 @@ Authors * Rael Max * Ramiro Morales * Raphael Michel +* Renne Rocha * Rolf Erik Lekang * Russell Keith-Magee * Santosh Bhattarai diff --git a/docs/changelog.rst b/docs/changelog.rst index f8fed00e..126f4850 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,8 @@ Modifications to existing flavors: (`gh-529 `_). - Update SI postal codes (`gh-531 `_). +- Update BR CNPJ validator to accept new alphanumeric format + (`gh-533 `_) Other changes: diff --git a/localflavor/br/forms.py b/localflavor/br/forms.py index 48ceaa04..8fc44d61 100644 --- a/localflavor/br/forms.py +++ b/localflavor/br/forms.py @@ -88,23 +88,24 @@ class BRCNPJField(CharField): A form field that validates input as `Brazilian CNPJ`_. Input can either be of the format XX.XXX.XXX/XXXX-XX or be a group of 14 - digits. + digits or upper case letters. This field is already compliant with the `July 2026`_ + change that allows the use of alphanumeric characters in the CNPJ. - If you want to use the long format only, you can specify: + If you want to use the long format only (XXXXXXX/XXXX-XX or XX.XXX.XXX/XXXX-XX), you can specify: brcnpj_field = BRCNPJField(min_length=16) - If you want to use the short format, you can specify: + If you want to use the short format only (XXXXXXXXXXXXXX), you can specify: brcnpj_field = BRCNPJField(max_length=14) - Otherwise both formats will be valid. + Otherwise all formats will be valid. .. _Brazilian CNPJ: http://en.wikipedia.org/wiki/National_identification_number#Brazil + .. _July 2026: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico .. versionchanged:: 1.4 .. versionchanged:: 2.2 Use BRCNPJValidator to centralize validation logic and share with equivalent model field. More details at: https://github.com/django/django-localflavor/issues/334 """ - default_error_messages = { 'invalid': _("Invalid CNPJ number."), 'max_digits': _("This field requires at least 14 digits"), diff --git a/localflavor/br/validators.py b/localflavor/br/validators.py index fee51ab5..85343b3d 100644 --- a/localflavor/br/validators.py +++ b/localflavor/br/validators.py @@ -1,3 +1,4 @@ +import itertools import re from django.core.exceptions import ValidationError @@ -5,7 +6,6 @@ from django.utils.translation import gettext_lazy as _ postal_code_re = re.compile(r'^\d{5}-\d{3}$') -cnpj_digits_re = re.compile(r'^(\d{2})[.-]?(\d{3})[.-]?(\d{3})/(\d{4})-(\d{2})$') cpf_digits_re = re.compile(r'^(\d{3})\.(\d{3})\.(\d{3})-(\d{2})$') @@ -34,35 +34,46 @@ class BRCNPJValidator(RegexValidator): .. versionadded:: 2.2 """ + CNPJ_RE = re.compile(r'^([A-Z0-9]{2}).?([A-Z0-9]{3}).?([A-Z0-9]{3})\/?([A-Z0-9]{4})-?(\d{2})$') + def __init__(self, *args, **kwargs): super().__init__( *args, - regex=cnpj_digits_re, + regex=self.CNPJ_RE, message=_("Invalid CNPJ number."), **kwargs ) - def __call__(self, value): - orig_dv = value[-2:] + def _get_check_digit(self, cnpj): + ''' + Based on official documentation at: + https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj + ''' + def _get_digit(value): + values = [ord(c) - 48 for c in value][::-1] + remainder = ( + sum( + [x * y for x, y in list(zip(values, itertools.cycle(range(2, 10))))] + ) + % 11 + ) + check_digit = 0 if remainder in (0, 1) else 11 - remainder + return str(check_digit) + + first_check_digit = _get_digit(cnpj) + second_check_digit = _get_digit(cnpj + first_check_digit) + return f"{first_check_digit}{second_check_digit}" - if not value.isdigit(): - cnpj = cnpj_digits_re.search(value) - if cnpj: - value = ''.join(cnpj.groups()) - else: - raise ValidationError(self.message, code='invalid') + def __call__(self, value): + super().__call__(value) - if len(value) != 14: - raise ValidationError(self.message, code='max_digits') + # After this point, only digits and uppercase letters are important + cleaned_value = re.sub(r"[^A-Z0-9]", "", value) - new_1dv = sum([i * int(value[idx]) for idx, i in enumerate(list(range(5, 1, -1)) + list(range(9, 1, -1)))]) - new_1dv = dv_maker(new_1dv % 11) - value = value[:-2] + str(new_1dv) + value[-1] - new_2dv = sum([i * int(value[idx]) for idx, i in enumerate(list(range(6, 1, -1)) + list(range(9, 1, -1)))]) - new_2dv = dv_maker(new_2dv % 11) - value = value[:-1] + str(new_2dv) - if value[-2:] != orig_dv: - raise ValidationError(self.message, code='invalid') + input_check_digit = cleaned_value[-2:] + calculated_check_digit = self._get_check_digit(cleaned_value[:-2]) + if input_check_digit != calculated_check_digit: + raise ValidationError(self.message, code="invalid") class BRCPFValidator(RegexValidator): diff --git a/tests/test_br/test_br.py b/tests/test_br/test_br.py index e03b21af..12e722e0 100644 --- a/tests/test_br/test_br.py +++ b/tests/test_br/test_br.py @@ -31,50 +31,37 @@ def test_BRZipCodeField(self): self.assertEqual(form.errors['postal_code'], error) def test_BRCNPJField(self): - error_format = { - 'invalid': ['Invalid CNPJ number.'], - 'only_long_version': ['Ensure this value has at least 16 characters (it has 14).'], - # The long version can be 16 or 18 characters long so actual error message is set dynamically when the - # invalid_long dict is generated. - 'only_short_version': ['Ensure this value has at most 14 characters (it has %s).'], - } - - long_version_valid = { - '64.132.916/0001-88': '64.132.916/0001-88', - '64-132-916/0001-88': '64-132-916/0001-88', + valid_inputs = { '64132916/0001-88': '64132916/0001-88', - } - short_version_valid = { + '64.132.916/0001-88': '64.132.916/0001-88', + '12.ABC.345/01DE-35': '12.ABC.345/01DE-35', + 'AB.CCC.DEF/GHIJ-08': 'AB.CCC.DEF/GHIJ-08', '64132916000188': '64132916000188', + '12ABC34501DE35': '12ABC34501DE35', + 'ABCDEFGHIJKL80': 'ABCDEFGHIJKL80', + 'MNOPQRSTUVWX50': 'MNOPQRSTUVWX50', + 'YZOPQRSTUVWX76': 'YZOPQRSTUVWX76', + '03634711000106': '03634711000106', } - valid = long_version_valid.copy() - valid.update(short_version_valid) - - invalid = { - '../-12345678901234': error_format['invalid'], - '12-345-678/9012-10': error_format['invalid'], - '12.345.678/9012-10': error_format['invalid'], - '12345678/9012-10': error_format['invalid'], - '64.132.916/0001-XX': error_format['invalid'], + invalid_inputs = { + '../-12345678901234': [BRCNPJField.default_error_messages['invalid'], ], + '12-345-678/9012-10': [BRCNPJField.default_error_messages['invalid'], ], + '12.345.678/9012-10': [BRCNPJField.default_error_messages['invalid'], ], + '12345678/9012-10': [BRCNPJField.default_error_messages['invalid'], ], + '64.132.916/0001-XX': [BRCNPJField.default_error_messages['invalid'], ], + '64.132.916/0001-80': [BRCNPJField.default_error_messages['invalid'], ], + '64,132,916/0001-80': [BRCNPJField.default_error_messages['invalid'], ], + '641329160001AA': [BRCNPJField.default_error_messages['invalid'], ], + '2ABC34501DE35': [BRCNPJField.default_error_messages['invalid'], ], + '2ABC34501DE35': [BRCNPJField.default_error_messages['invalid'], ], + '3634711000106': [BRCNPJField.default_error_messages['invalid'], ], + '12.abc.345/01de-35': [BRCNPJField.default_error_messages['invalid'], ], } - self.assertFieldOutput(BRCNPJField, valid, invalid) - - # The short versions should be invalid when 'min_length=16' passed to the field. - invalid_short = dict([(k, error_format['only_long_version']) for k in short_version_valid.keys()]) - self.assertFieldOutput(BRCNPJField, long_version_valid, invalid_short, field_kwargs={'min_length': 16}) - - # The long versions should be invalid when 'max_length=14' passed to the field. - invalid_long = dict([(k, [error_format['only_short_version'][0] % len(k)]) for k in long_version_valid.keys()]) - self.assertFieldOutput(BRCNPJField, short_version_valid, invalid_long, field_kwargs={'max_length': 14}) - - for cnpj, invalid_msg in invalid.items(): - with self.subTest(cnpj=cnpj, invalid_msg=invalid_msg): - form = BRPersonProfileForm({ - 'cnpj': cnpj - }) - - self.assertFalse(form.is_valid()) - self.assertEqual(form.errors['cnpj'], invalid_msg) + self.assertFieldOutput( + BRCNPJField, + valid=valid_inputs, + invalid=invalid_inputs, + ) def test_BRCPFField(self): error_format = ['Invalid CPF number.']