├── .codecov.yml ├── .github ├── dependabot.yml └── workflows │ ├── licenses.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── clabe ├── __init__.py ├── banks.py ├── py.typed ├── types.py ├── validations.py └── version.py ├── requirements-test.txt ├── requirements.txt ├── scripts └── compare_banks.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_clabe.py └── test_types.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | range: [95, 100] 7 | 8 | comment: 9 | layout: 'header, diff, flags, files, footer' 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: wednesday 8 | time: "11:00" 9 | timezone: "America/Mexico_City" 10 | open-pull-requests-limit: 5 11 | reviewers: 12 | - "cuenca-mx/admin" 13 | 14 | - package-ecosystem: pip 15 | directory: "/" 16 | schedule: 17 | interval: weekly 18 | day: wednesday 19 | time: "11:00" 20 | timezone: "America/Mexico_City" 21 | open-pull-requests-limit: 5 22 | reviewers: 23 | - "cuenca-mx/admin" 24 | -------------------------------------------------------------------------------- /.github/workflows/licenses.yml: -------------------------------------------------------------------------------- 1 | name: Upload licenses to S3 2 | 3 | on: ["push"] 4 | 5 | jobs: 6 | upload: 7 | runs-on: ubuntu-latest 8 | env: 9 | FILE: ./licence_compliance.csv 10 | AWS_REGION: 'us-east-1' 11 | S3_BUCKET: 'project-license-dependency' 12 | S3_KEY: ${{ github.event.repository.name }} 13 | AWS_ACCESS_KEY_ID: ${{ secrets.S3_UPLOAD_APIKEY }} 14 | AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_UPLOAD_API_SECRET }} 15 | PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} 16 | PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 17 | steps: 18 | - uses: actions/checkout@v2.4.0 19 | - name: Setup Python 20 | uses: actions/setup-python@v2.3.1 21 | with: 22 | python-version: 3.8 23 | - name: Install and run pip-licenses 24 | run: | 25 | pip config set global.extra-index-url https://$PYPI_USERNAME:$PYPI_PASSWORD@pypi.cuenca.io:8081 26 | make install-test 27 | pip install pip-licenses 28 | pip-licenses --format=csv --output-file licence_compliance.csv 29 | - name: Upload S3 30 | # https://github.com/zdurham/s3-upload-github-action 31 | run: | 32 | pip install --quiet --no-cache-dir awscli 33 | mkdir -p ~/.aws 34 | touch ~/.aws/credentials 35 | echo "[default] 36 | aws_access_key_id = ${AWS_ACCESS_KEY_ID} 37 | aws_secret_access_key = ${AWS_SECRET_ACCESS_KEY}" > ~/.aws/credentials 38 | aws s3 cp ${FILE} s3://${S3_BUCKET}/${S3_KEY} --region ${AWS_REGION} $* 39 | rm -rf ~/.aws 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: push 4 | 5 | jobs: 6 | publish-pypi: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 3.8 11 | uses: actions/setup-python@v4 12 | with: 13 | python-version: 3.8 14 | - name: Install dependencies 15 | run: pip install -qU setuptools wheel twine 16 | - name: Generating distribution archives 17 | run: python setup.py sdist bdist_wheel 18 | - name: Publish distribution 📦 to PyPI 19 | if: startsWith(github.event.ref, 'refs/tags') 20 | uses: pypa/gh-action-pypi-publish@release/v1 21 | with: 22 | user: __token__ 23 | password: ${{ secrets.PYPI_API_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 11 | uses: actions/setup-python@v2.2.1 12 | with: 13 | python-version: 3.8 14 | - name: Install dependencies 15 | run: make install-test 16 | - name: Lint 17 | run: make lint 18 | 19 | pytest: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v2.2.1 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: make install-test 32 | - name: Run tests 33 | run: pytest 34 | 35 | coverage: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@master 39 | - name: Setup Python 40 | uses: actions/setup-python@v2.2.1 41 | with: 42 | python-version: 3.8 43 | - name: Install dependencies 44 | run: make install-test 45 | - name: Generate coverage report 46 | run: pytest --cov-report=xml 47 | - name: Upload coverage to Codecov 48 | uses: codecov/codecov-action@v2.1.0 49 | with: 50 | file: ./coverage.xml 51 | flags: unittests 52 | name: codecov-umbrella 53 | fail_ci_if_error: true 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # pycharm 107 | .idea/ 108 | 109 | # visual studio code 110 | .vscode/ 111 | 112 | # environment variables 113 | .env 114 | 115 | # envrc 116 | .envrc 117 | 118 | # others 119 | .DS_Store 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Cuenca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := bash 2 | PATH := ./venv/bin:${PATH} 3 | PYTHON = python3.8 4 | PROJECT = clabe 5 | isort = isort $(PROJECT) tests setup.py 6 | black = black -S -l 79 --target-version py38 $(PROJECT) tests setup.py 7 | 8 | 9 | all: test 10 | 11 | venv: 12 | $(PYTHON) -m venv --prompt $(PROJECT) venv 13 | pip install -qU pip 14 | 15 | install: 16 | pip install -qU -r requirements.txt 17 | 18 | install-test: install 19 | pip install -qU -r requirements-test.txt 20 | 21 | test: clean install-test lint 22 | pytest 23 | 24 | format: 25 | $(isort) 26 | $(black) 27 | 28 | lint: 29 | flake8 $(PROJECT) tests setup.py 30 | $(isort) --check-only 31 | $(black) --check 32 | mypy $(PROJECT) tests 33 | 34 | clean: 35 | rm -rf `find . -name __pycache__` 36 | rm -f `find . -type f -name '*.py[co]' ` 37 | rm -f `find . -type f -name '*~' ` 38 | rm -f `find . -type f -name '.*~' ` 39 | rm -rf .cache 40 | rm -rf .pytest_cache 41 | rm -rf .mypy_cache 42 | rm -rf htmlcov 43 | rm -rf *.egg-info 44 | rm -f .coverage 45 | rm -f .coverage.* 46 | rm -rf build 47 | rm -rf dist 48 | 49 | release: test clean 50 | python setup.py sdist bdist_wheel 51 | twine upload dist/* 52 | 53 | compare-banks: 54 | PYTHONPATH=. python scripts/compare_banks.py 55 | 56 | .PHONY: all install-test test format lint clean release compare-banks 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CLABE 2 | 3 | [![test](https://github.com/cuenca-mx/clabe-python/workflows/test/badge.svg)](https://github.com/cuenca-mx/clabe-python/actions?query=workflow%3Atest) 4 | [![codecov](https://codecov.io/gh/cuenca-mx/clabe-python/branch/main/graph/badge.svg)](https://codecov.io/gh/cuenca-mx/clabe-python) 5 | [![PyPI](https://img.shields.io/pypi/v/clabe.svg)](https://pypi.org/project/clabe/) 6 | [![Downloads](https://pepy.tech/badge/clabe)](https://pepy.tech/project/clabe) 7 | 8 | Librería para validar y calcular un número CLABE basado en 9 | https://es.wikipedia.org/wiki/CLABE 10 | 11 | ## Requerimientos 12 | 13 | Python 3.8 o superior. 14 | 15 | ## Instalación 16 | 17 | Se puede instalar desde Pypi usando 18 | 19 | ``` 20 | pip install clabe 21 | ``` 22 | 23 | ## Pruebas 24 | 25 | Para ejecutar las pruebas 26 | 27 | ``` 28 | $ make test 29 | ``` 30 | 31 | ## Uso básico 32 | 33 | ### Como tipo personalizado en un modelo de Pydantic 34 | 35 | ```python 36 | from pydantic import BaseModel, ValidationError 37 | 38 | from clabe import Clabe 39 | 40 | 41 | class Account(BaseModel): 42 | id: str 43 | clabe: Clabe 44 | 45 | 46 | account = Account(id='123', clabe='723010123456789019') 47 | print(account) 48 | """ 49 | id='123' clabe='723010123456789019' 50 | """ 51 | 52 | try: 53 | account = Account(id='321', clabe='000000000000000011') 54 | except ValidationError as exc: 55 | print(exc) 56 | """ 57 | 1 validation error for Account 58 | clabe 59 | código de banco no es válido [type=clabe.bank_code, input_value='000000000000000011', input_type=str] 60 | """ 61 | ``` 62 | 63 | ### Obtener el dígito de control de un número CLABE 64 | 65 | ```python 66 | import clabe 67 | clabe.compute_control_digit('00200000000000000') 68 | ``` 69 | 70 | ### Para validar si un número CLABE es válido 71 | 72 | ```python 73 | import clabe 74 | clabe.validate_clabe('002000000000000008') 75 | ``` 76 | 77 | ### Para obtener el banco a partir de 3 dígitos 78 | 79 | ```python 80 | import clabe 81 | clabe.get_bank_name('002') 82 | ``` 83 | 84 | ### Para generar nuevo válido CLABES 85 | 86 | ```python 87 | import clabe 88 | clabe.generate_new_clabes(10, '002123456') 89 | ``` 90 | 91 | ## Agregar un nuevo banco 92 | 93 | A partir de la versión **2.0.0**, el paquete ha sido actualizado para utilizar **Pydantic v2**, lo que implica que las versiones anteriores ya no recibirán soporte ni actualizaciones. 94 | 95 | No obstante, en versiones anteriores hemos agregado una función que permite añadir bancos adicionales a la lista sin necesidad de crear un PR. Esto es útil para quienes aún utilicen versiones anteriores. Sin embargo, a partir de la versión 2, continuaremos manteniendo y actualizando la lista oficial de bancos mediante PRs en el repositorio. 96 | 97 | ### Cómo agregar un banco 98 | 99 | Para agregar un banco, llama a la función `add_bank` pasando el código de Banxico y el nombre del banco como parámetros. 100 | 101 | ```python 102 | import clabe 103 | clabe.add_bank('12345', 'New Bank') 104 | ``` 105 | 106 | ### Cómo eliminar un banco 107 | 108 | De manera similar, puedes eliminar un banco llamando a la función remove_bank con el código del banco que deseas eliminar. 109 | 110 | ```python 111 | import clabe 112 | clabe.remove_bank('12345') 113 | ``` 114 | 115 | **Nota**: Aunque estas funciones están disponibles para un uso más flexible, recomendamos utilizar siempre la lista oficial de bancos actualizada en la versión 2+. -------------------------------------------------------------------------------- /clabe/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | '__version__', 3 | 'BANK_NAMES', 4 | 'BANKS', 5 | 'Clabe', 6 | 'compute_control_digit', 7 | 'generate_new_clabes', 8 | 'get_bank_name', 9 | 'validate_clabe', 10 | 'add_bank', 11 | 'remove_bank', 12 | ] 13 | 14 | from .banks import BANK_NAMES, BANKS 15 | from .types import Clabe 16 | from .validations import ( 17 | add_bank, 18 | compute_control_digit, 19 | generate_new_clabes, 20 | get_bank_name, 21 | remove_bank, 22 | validate_clabe, 23 | ) 24 | from .version import __version__ 25 | -------------------------------------------------------------------------------- /clabe/banks.py: -------------------------------------------------------------------------------- 1 | BANKS = { 2 | '138': '40138', 3 | '133': '40133', 4 | '062': '40062', 5 | '638': '90638', 6 | '706': '90706', 7 | '659': '90659', 8 | '128': '40128', 9 | '127': '40127', 10 | '166': '37166', 11 | '030': '40030', 12 | '002': '40002', 13 | '154': '40154', 14 | '006': '37006', 15 | '137': '40137', 16 | '160': '40160', 17 | '152': '40152', 18 | '019': '37019', 19 | '147': '40147', 20 | '106': '40106', 21 | '159': '40159', 22 | '009': '37009', 23 | '072': '40072', 24 | '058': '40058', 25 | '060': '40060', 26 | '001': '2001', 27 | '129': '40129', 28 | '145': '40145', 29 | '012': '40012', 30 | '112': '40112', 31 | '677': '90677', 32 | '683': '90683', 33 | '630': '90630', 34 | '124': '40124', 35 | '143': '40143', 36 | '631': '90631', 37 | '901': '90901', 38 | '903': '90903', 39 | '130': '40130', 40 | '140': '40140', 41 | '652': '90652', 42 | '688': '90688', 43 | '680': '90680', 44 | '723': '90723', 45 | '722': '90722', 46 | '151': '40151', 47 | '616': '90616', 48 | '634': '90634', 49 | '689': '90689', 50 | '699': '90699', 51 | '685': '90685', 52 | '601': '90601', 53 | '168': '37168', 54 | '021': '40021', 55 | '155': '40155', 56 | '036': '40036', 57 | '902': '90902', 58 | '150': '40150', 59 | '136': '40136', 60 | '059': '40059', 61 | '110': '40110', 62 | '661': '90661', 63 | '653': '90653', 64 | '670': '90670', 65 | '602': '90602', 66 | '042': '40042', 67 | '158': '40158', 68 | '600': '90600', 69 | '108': '40108', 70 | '132': '40132', 71 | '135': '37135', 72 | '710': '90710', 73 | '684': '90684', 74 | '148': '40148', 75 | '620': '90620', 76 | '156': '40156', 77 | '014': '40014', 78 | '044': '40044', 79 | '157': '40157', 80 | '728': '90728', 81 | '646': '90646', 82 | '656': '90656', 83 | '617': '90617', 84 | '605': '90605', 85 | '608': '90608', 86 | '703': '90703', 87 | '113': '40113', 88 | '141': '40141', 89 | '715': '90715', 90 | '732': '90732', 91 | '734': '90734', 92 | '167': '40167', 93 | '721': '90721', 94 | } 95 | 96 | 97 | # Descargado de https://www.banxico.org.mx/cep-scl/listaInstituciones.do 98 | # 2022-10-18 99 | # The order of the banks must be alphabetical by bank name 100 | BANK_NAMES = { 101 | '40133': 'Actinver', 102 | '40062': 'Afirme', 103 | '90721': 'Albo', 104 | '90706': 'Arcus Fi', 105 | '90659': 'Asp Integra Opc', 106 | '40128': 'Autofin', 107 | '40127': 'Azteca', 108 | '37166': 'BaBien', 109 | '40030': 'Bajio', 110 | '40002': 'Banamex', 111 | '40154': 'Banco Covalto', 112 | '37006': 'Bancomext', 113 | '40137': 'Bancoppel', 114 | '40160': 'Banco S3', 115 | '40152': 'Bancrea', 116 | '37019': 'Banjercito', 117 | '40147': 'Bankaool', 118 | '40106': 'Bank Of America', 119 | '40159': 'Bank Of China', 120 | '37009': 'Banobras', 121 | '40072': 'Banorte', 122 | '40058': 'Banregio', 123 | '40060': 'Bansi', 124 | '2001': 'Banxico', 125 | '40129': 'Barclays', 126 | '40145': 'BBase', 127 | '40012': 'BBVA Mexico', 128 | '40112': 'Bmonex', 129 | '90677': 'Caja Pop Mexica', 130 | '90683': 'Caja Telefonist', 131 | '90715': 'Cartera Digital', 132 | '90630': 'CB Intercam', 133 | '40124': 'Citi Mexico', 134 | '40143': 'CIBanco', 135 | '90631': 'CI Bolsa', 136 | '90901': 'Cls', 137 | '90903': 'CoDi Valida', 138 | '40130': 'Compartamos', 139 | '40140': 'Consubanco', 140 | '90652': 'Credicapital', 141 | '90688': 'Crediclub', 142 | '90680': 'Cristobal Colon', 143 | '90723': 'Cuenca', 144 | '40151': 'Donde', 145 | '90616': 'Finamex', 146 | '90634': 'Fincomun', 147 | '90734': 'Finco Pay', 148 | '90689': 'Fomped', 149 | '90699': 'Fondeadora', 150 | '90685': 'Fondo (Fira)', 151 | '90601': 'Gbm', 152 | '40167': 'Hey Banco', 153 | '37168': 'Hipotecaria Fed', 154 | '40021': 'HSBC', 155 | '40155': 'Icbc', 156 | '40036': 'Inbursa', 157 | '90902': 'Indeval', 158 | '40150': 'Inmobiliario', 159 | '40136': 'Intercam Banco', 160 | '40059': 'Invex', 161 | '40110': 'JP Morgan', 162 | '90661': 'KLAR', 163 | '90653': 'Kuspit', 164 | '90670': 'Libertad', 165 | '90602': 'Masari', 166 | '90722': 'Mercado Pago W', 167 | '40042': 'Mifel', 168 | '40158': 'Mizuho Bank', 169 | '90600': 'Monexcb', 170 | '40108': 'Mufg', 171 | '40132': 'Multiva Banco', 172 | '37135': 'Nafin', 173 | '90638': 'NU MEXICO', 174 | '90710': 'NVIO', 175 | '40148': 'Pagatodo', 176 | '90732': 'Peibo', 177 | '90620': 'Profuturo', 178 | '40156': 'Sabadell', 179 | '40014': 'Santander', 180 | '40044': 'Scotiabank', 181 | '40157': 'Shinhan', 182 | '90728': 'Spin by OXXO', 183 | '90646': 'STP', 184 | '90703': 'Tesored', 185 | '90684': 'Transfer', 186 | '40138': 'Uala', 187 | '90656': 'Unagra', 188 | '90617': 'Valmex', 189 | '90605': 'Value', 190 | '90608': 'Vector', 191 | '40113': 'Ve Por Mas', 192 | '40141': 'Volkswagen', 193 | } 194 | -------------------------------------------------------------------------------- /clabe/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuenca-mx/clabe-python/31ce660c2f362c085cee7b985a98ecc062d2b9b2/clabe/py.typed -------------------------------------------------------------------------------- /clabe/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Type 2 | 3 | from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler 4 | from pydantic_core import PydanticCustomError, core_schema 5 | 6 | from .validations import BANK_NAMES, BANKS, compute_control_digit 7 | 8 | CLABE_LENGTH = 18 9 | 10 | 11 | class Clabe(str): 12 | """ 13 | Based on: https://es.wikipedia.org/wiki/CLABE 14 | """ 15 | 16 | def __init__(self, clabe: str) -> None: 17 | self.bank_code_abm = clabe[:3] 18 | self.bank_code_banxico = BANKS[clabe[:3]] 19 | self.bank_name = BANK_NAMES[self.bank_code_banxico] 20 | 21 | @property 22 | def bank_code(self) -> str: 23 | return self.bank_code_banxico 24 | 25 | @classmethod 26 | def __get_pydantic_json_schema__( 27 | cls, 28 | schema: core_schema.CoreSchema, 29 | handler: GetJsonSchemaHandler, 30 | ) -> Dict[str, Any]: 31 | json_schema = handler(schema) 32 | json_schema.update( 33 | type="string", 34 | pattern="^[0-9]{18}$", 35 | description="CLABE (Clave Bancaria Estandarizada)", 36 | examples=["723010123456789019"], 37 | ) 38 | return json_schema 39 | 40 | @classmethod 41 | def __get_pydantic_core_schema__( 42 | cls, 43 | _: Type[Any], 44 | __: GetCoreSchemaHandler, 45 | ) -> core_schema.CoreSchema: 46 | return core_schema.no_info_after_validator_function( 47 | cls._validate, 48 | core_schema.str_schema( 49 | min_length=CLABE_LENGTH, 50 | max_length=CLABE_LENGTH, 51 | strip_whitespace=True, 52 | ), 53 | ) 54 | 55 | @classmethod 56 | def _validate(cls, clabe: str) -> 'Clabe': 57 | if not clabe.isdigit(): 58 | raise PydanticCustomError('clabe', 'debe ser numérico') 59 | if clabe[:3] not in BANKS: 60 | raise PydanticCustomError( 61 | 'clabe.bank_code', 'código de banco no es válido' 62 | ) 63 | if clabe[-1] != compute_control_digit(clabe): 64 | raise PydanticCustomError( 65 | 'clabe.control_digit', 'clabe dígito de control no es válido' 66 | ) 67 | return cls(clabe) 68 | -------------------------------------------------------------------------------- /clabe/validations.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import List, Union 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | from .banks import BANK_NAMES, BANKS 7 | 8 | CLABE_LENGTH = 18 9 | CLABE_WEIGHTS = [3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7] 10 | 11 | 12 | def compute_control_digit(clabe: Union[str, List[int]]) -> str: 13 | """ 14 | Compute CLABE control digit according to 15 | https://es.wikipedia.org/wiki/CLABE#D.C3.ADgito_control 16 | """ 17 | clabe = [int(i) for i in clabe] 18 | weighted = [ 19 | c * w % 10 for c, w in zip(clabe[: CLABE_LENGTH - 1], CLABE_WEIGHTS) 20 | ] 21 | summed = sum(weighted) % 10 22 | control_digit = (10 - summed) % 10 23 | return str(control_digit) 24 | 25 | 26 | def validate_clabe(clabe: str) -> bool: 27 | """ 28 | Validate CLABE according to 29 | https://es.wikipedia.org/wiki/CLABE#D.C3.ADgito_control 30 | """ 31 | return ( 32 | clabe.isdigit() 33 | and len(clabe) == CLABE_LENGTH 34 | and clabe[:3] in BANKS.keys() 35 | and clabe[-1] == compute_control_digit(clabe) 36 | ) 37 | 38 | 39 | def get_bank_name(clabe: str) -> str: 40 | """ 41 | Regresa el nombre del banco basado en los primeros 3 digitos 42 | https://es.wikipedia.org/wiki/CLABE#D.C3.ADgito_control 43 | """ 44 | code = clabe[:3] 45 | try: 46 | bank_name = BANK_NAMES[BANKS[code]] 47 | except KeyError: 48 | raise ValueError(f"Ningún banco tiene código '{code}'") 49 | else: 50 | return bank_name 51 | 52 | 53 | def generate_new_clabes(number_of_clabes: int, prefix: str) -> List[str]: 54 | clabes = [] 55 | missing = CLABE_LENGTH - len(prefix) - 1 56 | assert (10**missing - 10 ** (missing - 1)) >= number_of_clabes 57 | clabe_sections = random.sample( 58 | range(10 ** (missing - 1), 10**missing), number_of_clabes 59 | ) 60 | for clabe_section in clabe_sections: 61 | clabe = prefix + str(clabe_section) 62 | clabe += compute_control_digit(clabe) 63 | assert validate_clabe(clabe) 64 | clabes.append(clabe) 65 | return clabes 66 | 67 | 68 | class BankConfigRequest(BaseModel): 69 | """ 70 | Validates and processes bank configuration requests. 71 | 72 | The class handles validation of bank names and codes, ensuring: 73 | - Bank names are non-empty strings 74 | - Banxico codes are exactly 5 digits 75 | """ 76 | 77 | bank_name: str = Field( 78 | min_length=1, 79 | strip_whitespace=True, 80 | description="Bank name must have at least 1 character.", 81 | ) 82 | 83 | bank_code_banxico: str = Field( 84 | pattern=r"^\d{5}$", 85 | description="Banxico code must be a 5-digit string.", 86 | ) 87 | 88 | @property 89 | def bank_code_abm(self): 90 | return self.bank_code_banxico[-3:] 91 | 92 | 93 | def add_bank(bank_code_banxico: str, bank_name: str) -> None: 94 | """ 95 | Add a bank configuration. 96 | 97 | Args: 98 | bank_code_banxico: 5-digit Banxico bank code 99 | bank_name: Bank name 100 | """ 101 | request = BankConfigRequest( 102 | bank_code_banxico=bank_code_banxico, 103 | bank_name=bank_name, 104 | ) 105 | BANKS[request.bank_code_abm] = request.bank_code_banxico 106 | BANK_NAMES[request.bank_code_banxico] = request.bank_name 107 | 108 | 109 | def remove_bank(bank_code_banxico: str) -> None: 110 | """ 111 | Remove a bank configuration by its Banxico code. 112 | 113 | Args: 114 | bank_code_banxico: 5-digit Banxico bank code 115 | """ 116 | BANKS.pop(bank_code_banxico[-3:], None) 117 | BANK_NAMES.pop(bank_code_banxico, None) 118 | -------------------------------------------------------------------------------- /clabe/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.1.3' 2 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest==7.4.4 2 | pytest-cov==4.1.0 3 | black==22.8.0 4 | isort==5.11.5 5 | flake8==5.0.4 6 | mypy==1.4.1 7 | requests==2.32.3 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pydantic==2.10.3 2 | -------------------------------------------------------------------------------- /scripts/compare_banks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import requests 4 | from clabe.banks import BANK_NAMES 5 | 6 | 7 | def fetch_banxico_data(): 8 | current_date = datetime.now().strftime('%d-%m-%Y') 9 | url = f'https://www.banxico.org.mx/cep/instituciones.do?fecha={current_date}' 10 | try: 11 | response = requests.get(url) 12 | response.raise_for_status() 13 | data = response.json() 14 | return dict(data.get('instituciones', [])) 15 | except Exception as e: 16 | print(f'Error fetching data from Banxico: {e}') 17 | return {} 18 | 19 | 20 | def compare_bank_data(): 21 | current_banks = dict(BANK_NAMES) 22 | banxico_banks = fetch_banxico_data() 23 | 24 | differences = {'additions': {}, 'removals': {}, 'changes': {}} 25 | 26 | print('Comparing bank data...\n') 27 | 28 | # Check for additions (in Banxico but not in package) 29 | additions = { 30 | code: name for code, name in banxico_banks.items() if code not in current_banks 31 | } 32 | differences['additions'] = additions 33 | if additions: 34 | print('=== ADDITIONS (in Banxico but not in package) ===') 35 | for code, name in sorted(additions.items()): 36 | print(f' {code}: {name}') 37 | print() 38 | 39 | # Check for removals (in package but not in Banxico) 40 | removals = { 41 | code: name for code, name in current_banks.items() if code not in banxico_banks 42 | } 43 | differences['removals'] = removals 44 | if removals: 45 | print('=== REMOVALS (in package but not in Banxico) ===') 46 | for code, name in sorted(removals.items()): 47 | print(f' {code}: {name}') 48 | print() 49 | 50 | # Check for changes (different names for the same code) 51 | changes = { 52 | code: (current_banks[code], banxico_banks[code]) 53 | for code in set(current_banks) & set(banxico_banks) 54 | if current_banks[code].upper() != banxico_banks[code].upper() 55 | } 56 | differences['changes'] = changes 57 | if changes: 58 | print('=== CHANGES (different names for the same code): Package -> Banxico ===') 59 | for code, (current_name, banxico_name) in sorted(changes.items()): 60 | print(f' {code}: {current_name} -> {banxico_name}') 61 | print() 62 | 63 | if not additions and not removals and not changes: 64 | print('No differences found. The data is in sync.') 65 | 66 | return differences 67 | 68 | 69 | if __name__ == '__main__': 70 | differences = compare_bank_data() 71 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [tool:pytest] 5 | addopts = -p no:warnings -v --cov-report term-missing --cov=clabe 6 | 7 | [flake8] 8 | inline-quotes = ' 9 | multiline-quotes = """ 10 | 11 | [isort] 12 | profile=black 13 | line_length=79 14 | 15 | [mypy-pytest] 16 | ignore_missing_imports = True 17 | 18 | [coverage:report] 19 | precision = 2 20 | exclude_lines = 21 | if TYPE_CHECKING: 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from importlib.machinery import SourceFileLoader 2 | 3 | import setuptools 4 | 5 | version = SourceFileLoader('version', 'clabe/version.py').load_module() 6 | 7 | 8 | with open('README.md', 'r') as f: 9 | long_description = f.read() 10 | 11 | 12 | setuptools.setup( 13 | name='clabe', 14 | version=version.__version__, 15 | author='Cuenca', 16 | author_email='dev@cuenca.com', 17 | description='Validate and generate the control digit of a CLABE in Mexico', 18 | long_description=long_description, 19 | long_description_content_type='text/markdown', 20 | url='https://github.com/cuenca-mx/clabe', 21 | packages=setuptools.find_packages(), 22 | include_package_data=True, 23 | package_data=dict(clabe=['py.typed']), 24 | python_requires='>=3.8', 25 | install_requires=['pydantic>=2.10.3'], 26 | classifiers=[ 27 | 'Programming Language :: Python :: 3', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Operating System :: OS Independent', 30 | ], 31 | ) 32 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuenca-mx/clabe-python/31ce660c2f362c085cee7b985a98ecc062d2b9b2/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_clabe.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clabe import ( 4 | BANK_NAMES, 5 | BANKS, 6 | add_bank, 7 | compute_control_digit, 8 | generate_new_clabes, 9 | get_bank_name, 10 | remove_bank, 11 | validate_clabe, 12 | ) 13 | 14 | VALID_CLABE = '002000000000000008' 15 | INVALID_CLABE_CONTROL_DIGIT = '002000000000000007' 16 | INVALID_CLABE_BANK_CODE = '0' * 18 # Control digit es valido 17 | 18 | 19 | def test_compute_control_digit(): 20 | assert compute_control_digit(VALID_CLABE[:17]) == VALID_CLABE[17] 21 | 22 | 23 | def test_validate_clabe(): 24 | assert validate_clabe(VALID_CLABE) 25 | assert not validate_clabe(INVALID_CLABE_BANK_CODE) 26 | assert not validate_clabe(INVALID_CLABE_CONTROL_DIGIT) 27 | 28 | 29 | def test_get_bank_name(): 30 | assert get_bank_name('002') == 'Banamex' 31 | with pytest.raises(ValueError): 32 | get_bank_name('989') 33 | 34 | 35 | def test_generate_new_clabes(): 36 | num_clabes = 10 37 | prefix = '64618000011' 38 | clabes = generate_new_clabes(10, prefix) 39 | assert len(clabes) == num_clabes 40 | for clabe in clabes: 41 | assert clabe.startswith(prefix) 42 | assert validate_clabe(clabe) 43 | 44 | 45 | @pytest.mark.parametrize( 46 | 'abm_code, banxico_code, name', 47 | [ 48 | ('713', '90713', 'Cuenca DMZ'), 49 | ('714', '90714', 'Cuenca Gem DMZ'), 50 | ('715', '90715', 'Cuenca Gem Beta'), 51 | ], 52 | ) 53 | def test_add_bank_success(abm_code, banxico_code, name): 54 | add_bank(banxico_code, name) 55 | assert get_bank_name(abm_code) == name 56 | 57 | 58 | @pytest.mark.parametrize( 59 | 'banxico_code, name', 60 | [ 61 | ('1234', 'Test Bank'), # invalid Banxico code 4 digits 62 | ('123456', 'Test Bank'), # invalid Banxico code 6 digits 63 | ('12345', ''), # Valid code, empty name 64 | ('123AT', 'Test Bank'), # Non-numeric codes 65 | ], 66 | ) 67 | def test_add_bank_invalid_inputs(banxico_code, name): 68 | with pytest.raises(ValueError): 69 | add_bank(banxico_code, name) 70 | 71 | 72 | def test_remove_bank(): 73 | test_code = "90716" 74 | test_name = "To Delete Bank" 75 | add_bank(test_code, test_name) 76 | 77 | assert test_code[-3:] in BANKS 78 | assert test_code in BANK_NAMES 79 | 80 | remove_bank(test_code) 81 | 82 | assert test_code[-3:] not in BANKS 83 | assert test_code not in BANK_NAMES 84 | 85 | 86 | def test_remove_nonexistent_bank(): 87 | nonexistent_code = "99999" 88 | 89 | remove_bank(nonexistent_code) 90 | 91 | assert nonexistent_code[-3:] not in BANKS 92 | assert nonexistent_code not in BANK_NAMES 93 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import BaseModel, ValidationError 3 | 4 | from clabe import BANK_NAMES, BANKS 5 | from clabe.types import Clabe 6 | 7 | VALID_CLABE = '723123456682660854' 8 | 9 | 10 | class Cuenta(BaseModel): 11 | clabe: Clabe 12 | 13 | 14 | def test_valid_clabe(): 15 | cuenta = Cuenta(clabe=VALID_CLABE) 16 | assert cuenta.clabe.bank_code_abm == '723' 17 | assert cuenta.clabe.bank_code_banxico == BANKS['723'] 18 | assert cuenta.clabe.bank_name == BANK_NAMES[BANKS['723']] 19 | assert cuenta.clabe.bank_code == cuenta.clabe.bank_code_banxico 20 | 21 | 22 | @pytest.mark.parametrize( 23 | 'clabe,expected_message', 24 | [ 25 | pytest.param( 26 | 'h' * 18, 27 | 'debe ser numérico', 28 | id='clabe_not_digit', 29 | ), 30 | pytest.param( 31 | '9' * 17, 32 | 'String should have at least 18 characters', 33 | id='invalid_bank_length', 34 | ), 35 | pytest.param( 36 | '9' * 19, 37 | 'String should have at most 18 characters', 38 | id='invalid_bank_length', 39 | ), 40 | pytest.param( 41 | '111180157042875763', 42 | 'código de banco no es válido', 43 | id='invalid_bank_code', 44 | ), 45 | pytest.param( 46 | '001' + '9' * 15, 47 | 'clabe dígito de control no es válido', 48 | id='invalid_control_digit', 49 | ), 50 | ], 51 | ) 52 | def test_invalid_clabe(clabe: Clabe, expected_message: str) -> None: 53 | with pytest.raises(ValidationError) as exc: 54 | Cuenta(clabe=clabe) 55 | assert expected_message in str(exc.value) 56 | 57 | 58 | def test_get_json_schema() -> None: 59 | from pydantic import TypeAdapter 60 | 61 | adapter = TypeAdapter(Clabe) 62 | schema = adapter.json_schema() 63 | assert schema == { 64 | 'description': 'CLABE (Clave Bancaria Estandarizada)', 65 | 'examples': ['723010123456789019'], 66 | 'maxLength': 18, 67 | 'minLength': 18, 68 | 'pattern': '^[0-9]{18}$', 69 | 'type': 'string', 70 | } 71 | --------------------------------------------------------------------------------