catalogmx 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- catalogmx/__init__.py +56 -0
- catalogmx/catalogs/__init__.py +5 -0
- catalogmx/catalogs/banxico/__init__.py +24 -0
- catalogmx/catalogs/banxico/banks.py +136 -0
- catalogmx/catalogs/banxico/codigos_plaza.py +287 -0
- catalogmx/catalogs/banxico/instituciones_financieras.py +338 -0
- catalogmx/catalogs/banxico/monedas_divisas.py +386 -0
- catalogmx/catalogs/banxico/udis.py +279 -0
- catalogmx/catalogs/ift/__init__.py +15 -0
- catalogmx/catalogs/ift/codigos_lada.py +426 -0
- catalogmx/catalogs/ift/operadores_moviles.py +315 -0
- catalogmx/catalogs/inegi/__init__.py +21 -0
- catalogmx/catalogs/inegi/localidades.py +207 -0
- catalogmx/catalogs/inegi/municipios.py +73 -0
- catalogmx/catalogs/inegi/municipios_completo.py +236 -0
- catalogmx/catalogs/inegi/states.py +148 -0
- catalogmx/catalogs/mexico/__init__.py +17 -0
- catalogmx/catalogs/mexico/hoy_no_circula.py +215 -0
- catalogmx/catalogs/mexico/placas_formatos.py +184 -0
- catalogmx/catalogs/mexico/salarios_minimos.py +156 -0
- catalogmx/catalogs/mexico/uma.py +207 -0
- catalogmx/catalogs/sat/__init__.py +13 -0
- catalogmx/catalogs/sat/carta_porte/__init__.py +19 -0
- catalogmx/catalogs/sat/carta_porte/aeropuertos.py +76 -0
- catalogmx/catalogs/sat/carta_porte/carreteras.py +59 -0
- catalogmx/catalogs/sat/carta_porte/config_autotransporte.py +54 -0
- catalogmx/catalogs/sat/carta_porte/material_peligroso.py +66 -0
- catalogmx/catalogs/sat/carta_porte/puertos_maritimos.py +63 -0
- catalogmx/catalogs/sat/carta_porte/tipo_embalaje.py +48 -0
- catalogmx/catalogs/sat/carta_porte/tipo_permiso.py +54 -0
- catalogmx/catalogs/sat/cfdi_4/__init__.py +42 -0
- catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py +383 -0
- catalogmx/catalogs/sat/cfdi_4/clave_unidad.py +298 -0
- catalogmx/catalogs/sat/cfdi_4/exportacion.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/forma_pago.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/impuesto.py +57 -0
- catalogmx/catalogs/sat/cfdi_4/meses.py +34 -0
- catalogmx/catalogs/sat/cfdi_4/metodo_pago.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/objeto_imp.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/periodicidad.py +34 -0
- catalogmx/catalogs/sat/cfdi_4/regimen_fiscal.py +57 -0
- catalogmx/catalogs/sat/cfdi_4/tasa_o_cuota.py +42 -0
- catalogmx/catalogs/sat/cfdi_4/tipo_comprobante.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/tipo_factor.py +34 -0
- catalogmx/catalogs/sat/cfdi_4/tipo_relacion.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/uso_cfdi.py +45 -0
- catalogmx/catalogs/sat/comercio_exterior/__init__.py +39 -0
- catalogmx/catalogs/sat/comercio_exterior/claves_pedimento.py +77 -0
- catalogmx/catalogs/sat/comercio_exterior/estados.py +122 -0
- catalogmx/catalogs/sat/comercio_exterior/incoterms.py +226 -0
- catalogmx/catalogs/sat/comercio_exterior/monedas.py +107 -0
- catalogmx/catalogs/sat/comercio_exterior/motivos_traslado.py +54 -0
- catalogmx/catalogs/sat/comercio_exterior/paises.py +88 -0
- catalogmx/catalogs/sat/comercio_exterior/registro_ident_trib.py +76 -0
- catalogmx/catalogs/sat/comercio_exterior/unidades_aduana.py +54 -0
- catalogmx/catalogs/sat/comercio_exterior/validator.py +212 -0
- catalogmx/catalogs/sat/nomina/__init__.py +19 -0
- catalogmx/catalogs/sat/nomina/banco.py +50 -0
- catalogmx/catalogs/sat/nomina/periodicidad_pago.py +48 -0
- catalogmx/catalogs/sat/nomina/riesgo_puesto.py +56 -0
- catalogmx/catalogs/sat/nomina/tipo_contrato.py +47 -0
- catalogmx/catalogs/sat/nomina/tipo_jornada.py +42 -0
- catalogmx/catalogs/sat/nomina/tipo_nomina.py +52 -0
- catalogmx/catalogs/sat/nomina/tipo_regimen.py +47 -0
- catalogmx/catalogs/sepomex/__init__.py +5 -0
- catalogmx/catalogs/sepomex/codigos_postales.py +184 -0
- catalogmx/cli.py +185 -0
- catalogmx/helpers.py +324 -0
- catalogmx/utils/text.py +55 -0
- catalogmx/validators/__init__.py +0 -0
- catalogmx/validators/clabe.py +233 -0
- catalogmx/validators/curp.py +623 -0
- catalogmx/validators/nss.py +255 -0
- catalogmx/validators/rfc.py +1004 -0
- catalogmx-0.3.0.dist-info/METADATA +644 -0
- catalogmx-0.3.0.dist-info/RECORD +81 -0
- catalogmx-0.3.0.dist-info/WHEEL +5 -0
- catalogmx-0.3.0.dist-info/entry_points.txt +2 -0
- catalogmx-0.3.0.dist-info/licenses/AUTHORS.rst +5 -0
- catalogmx-0.3.0.dist-info/licenses/LICENSE +19 -0
- catalogmx-0.3.0.dist-info/top_level.txt +1 -0
catalogmx/helpers.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""
|
|
3
|
+
Modern, user-friendly API for RFC and CURP generation and validation.
|
|
4
|
+
|
|
5
|
+
This module provides simple functions for common use cases, making it easier
|
|
6
|
+
to work with Mexican identification codes without dealing with class constructors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
|
|
11
|
+
from .validators.curp import CURPGenerator, CURPValidator
|
|
12
|
+
from .validators.rfc import RFCGeneratorFisicas, RFCGeneratorMorales, RFCValidator
|
|
13
|
+
|
|
14
|
+
# ============================================================================
|
|
15
|
+
# RFC Helper Functions
|
|
16
|
+
# ============================================================================
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_rfc_persona_fisica(
|
|
20
|
+
nombre: str,
|
|
21
|
+
apellido_paterno: str,
|
|
22
|
+
apellido_materno: str,
|
|
23
|
+
fecha_nacimiento: datetime.date | str,
|
|
24
|
+
**kwargs,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Generate RFC for a natural person (Persona Física).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
nombre: First name(s)
|
|
31
|
+
apellido_paterno: Father's surname
|
|
32
|
+
apellido_materno: Mother's surname
|
|
33
|
+
fecha_nacimiento: Birth date (datetime.date or 'YYYY-MM-DD' string)
|
|
34
|
+
**kwargs: Additional arguments passed to RFCGeneratorFisicas
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
str: 13-character RFC code
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> rfc = generate_rfc_persona_fisica(
|
|
41
|
+
... nombre='Juan',
|
|
42
|
+
... apellido_paterno='Pérez',
|
|
43
|
+
... apellido_materno='García',
|
|
44
|
+
... fecha_nacimiento='1990-05-15'
|
|
45
|
+
... )
|
|
46
|
+
>>> print(rfc) # PEGJ900515...
|
|
47
|
+
"""
|
|
48
|
+
# Convert string date to datetime.date if needed
|
|
49
|
+
if isinstance(fecha_nacimiento, str):
|
|
50
|
+
fecha_nacimiento = datetime.datetime.strptime(fecha_nacimiento, "%Y-%m-%d").date()
|
|
51
|
+
|
|
52
|
+
generator = RFCGeneratorFisicas(
|
|
53
|
+
paterno=apellido_paterno,
|
|
54
|
+
materno=apellido_materno,
|
|
55
|
+
nombre=nombre,
|
|
56
|
+
fecha=fecha_nacimiento,
|
|
57
|
+
**kwargs,
|
|
58
|
+
)
|
|
59
|
+
return generator.rfc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def generate_rfc_persona_moral(
|
|
63
|
+
razon_social: str, fecha_constitucion: datetime.date | str, **kwargs
|
|
64
|
+
) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Generate RFC for a legal entity (Persona Moral/company).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
razon_social: Company name
|
|
70
|
+
fecha_constitucion: Constitution date (datetime.date or 'YYYY-MM-DD' string)
|
|
71
|
+
**kwargs: Additional arguments passed to RFCGeneratorMorales
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
str: 12-character RFC code
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> rfc = generate_rfc_persona_moral(
|
|
78
|
+
... razon_social='Grupo Bimbo S.A.B. de C.V.',
|
|
79
|
+
... fecha_constitucion='1981-06-15'
|
|
80
|
+
... )
|
|
81
|
+
>>> print(rfc) # GBI810615...
|
|
82
|
+
"""
|
|
83
|
+
# Convert string date to datetime.date if needed
|
|
84
|
+
if isinstance(fecha_constitucion, str):
|
|
85
|
+
fecha_constitucion = datetime.datetime.strptime(fecha_constitucion, "%Y-%m-%d").date()
|
|
86
|
+
|
|
87
|
+
generator = RFCGeneratorMorales(razon_social=razon_social, fecha=fecha_constitucion, **kwargs)
|
|
88
|
+
return generator.rfc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_rfc(rfc: str, check_checksum: bool = True) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Validate an RFC code.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
rfc: RFC code to validate
|
|
97
|
+
check_checksum: Whether to validate the checksum digit (default: True)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
bool: True if valid, False otherwise
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
>>> validate_rfc('PEGJ900515KL8')
|
|
104
|
+
True
|
|
105
|
+
>>> validate_rfc('INVALID')
|
|
106
|
+
False
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
validator = RFCValidator(rfc)
|
|
110
|
+
if not validator.validate_general_regex():
|
|
111
|
+
return False
|
|
112
|
+
if check_checksum:
|
|
113
|
+
return validator.validate_checksum()
|
|
114
|
+
return True
|
|
115
|
+
except:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def detect_rfc_type(rfc: str) -> str | None:
|
|
120
|
+
"""
|
|
121
|
+
Detect the type of RFC (Persona Física, Persona Moral, or Genérico).
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
rfc: RFC code to analyze
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
str: 'fisica', 'moral', 'generico', or None if invalid
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
>>> detect_rfc_type('PEGJ900515KL8')
|
|
131
|
+
'fisica'
|
|
132
|
+
>>> detect_rfc_type('GBI810615945')
|
|
133
|
+
'moral'
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
validator = RFCValidator(rfc)
|
|
137
|
+
tipo = validator.detect_fisica_moral()
|
|
138
|
+
if tipo == "Persona Física":
|
|
139
|
+
return "fisica"
|
|
140
|
+
elif tipo == "Persona Moral":
|
|
141
|
+
return "moral"
|
|
142
|
+
elif tipo == "Genérico":
|
|
143
|
+
return "generico"
|
|
144
|
+
return None
|
|
145
|
+
except:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ============================================================================
|
|
150
|
+
# CURP Helper Functions
|
|
151
|
+
# ============================================================================
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def generate_curp(
|
|
155
|
+
nombre: str,
|
|
156
|
+
apellido_paterno: str,
|
|
157
|
+
apellido_materno: str | None,
|
|
158
|
+
fecha_nacimiento: datetime.date | str,
|
|
159
|
+
sexo: str,
|
|
160
|
+
estado: str,
|
|
161
|
+
differentiator: str | None = None,
|
|
162
|
+
) -> str:
|
|
163
|
+
"""
|
|
164
|
+
Generate a CURP code.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
nombre: First name(s)
|
|
168
|
+
apellido_paterno: Father's surname
|
|
169
|
+
apellido_materno: Mother's surname (can be empty string or None)
|
|
170
|
+
fecha_nacimiento: Birth date (datetime.date or 'YYYY-MM-DD' string)
|
|
171
|
+
sexo: Gender ('H' for male, 'M' for female)
|
|
172
|
+
estado: Birth state (name or 2-letter code)
|
|
173
|
+
differentiator: Optional custom differentiator (position 17)
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
str: 18-character CURP code
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
>>> curp = generate_curp(
|
|
180
|
+
... nombre='Juan',
|
|
181
|
+
... apellido_paterno='Pérez',
|
|
182
|
+
... apellido_materno='García',
|
|
183
|
+
... fecha_nacimiento='1990-05-15',
|
|
184
|
+
... sexo='H',
|
|
185
|
+
... estado='Jalisco'
|
|
186
|
+
... )
|
|
187
|
+
>>> print(curp) # PEGJ900515HJCRRN...
|
|
188
|
+
"""
|
|
189
|
+
# Convert string date to datetime.date if needed
|
|
190
|
+
if isinstance(fecha_nacimiento, str):
|
|
191
|
+
fecha_nacimiento = datetime.datetime.strptime(fecha_nacimiento, "%Y-%m-%d").date()
|
|
192
|
+
|
|
193
|
+
# Handle empty apellido_materno
|
|
194
|
+
if not apellido_materno:
|
|
195
|
+
apellido_materno = ""
|
|
196
|
+
|
|
197
|
+
generator = CURPGenerator(
|
|
198
|
+
nombre=nombre,
|
|
199
|
+
paterno=apellido_paterno,
|
|
200
|
+
materno=apellido_materno,
|
|
201
|
+
fecha_nacimiento=fecha_nacimiento,
|
|
202
|
+
sexo=sexo,
|
|
203
|
+
estado=estado,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# If custom differentiator is provided, regenerate homoclave
|
|
207
|
+
if differentiator is not None:
|
|
208
|
+
# Generate base CURP (first 16 characters)
|
|
209
|
+
base = (
|
|
210
|
+
generator.generate_letters()
|
|
211
|
+
+ generator.generate_date()
|
|
212
|
+
+ generator.sexo
|
|
213
|
+
+ generator.get_state_code(generator.estado)
|
|
214
|
+
+ generator.generate_consonants()
|
|
215
|
+
)
|
|
216
|
+
# Add custom differentiator and calculate check digit
|
|
217
|
+
check_digit = CURPGenerator.calculate_check_digit(base + differentiator)
|
|
218
|
+
return base + differentiator + check_digit
|
|
219
|
+
|
|
220
|
+
return generator.curp
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def validate_curp(curp: str, check_digit: bool = True) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Validate a CURP code.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
curp: CURP code to validate
|
|
229
|
+
check_digit: Whether to validate the check digit (default: True)
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
bool: True if valid, False otherwise
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> validate_curp('PEGJ900515HJCRRN05')
|
|
236
|
+
True
|
|
237
|
+
>>> validate_curp('INVALID')
|
|
238
|
+
False
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
validator = CURPValidator(curp)
|
|
242
|
+
if not validator.validate():
|
|
243
|
+
return False
|
|
244
|
+
if check_digit:
|
|
245
|
+
return validator.validate_check_digit()
|
|
246
|
+
return True
|
|
247
|
+
except:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_curp_info(curp: str) -> dict | None:
|
|
252
|
+
"""
|
|
253
|
+
Extract information from a CURP code.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
curp: CURP code to analyze
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
dict: Extracted information or None if invalid
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
>>> info = get_curp_info('PEGJ900515HJCRRN05')
|
|
263
|
+
>>> print(info['fecha_nacimiento'])
|
|
264
|
+
'1990-05-15'
|
|
265
|
+
>>> print(info['sexo'])
|
|
266
|
+
'Hombre'
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
validator = CURPValidator(curp)
|
|
270
|
+
if not validator.validate():
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
# Extract information
|
|
274
|
+
year = int(curp[4:6])
|
|
275
|
+
# Assume year 2000+ if < 50, otherwise 1900+
|
|
276
|
+
year = 2000 + year if year < 50 else 1900 + year
|
|
277
|
+
month = int(curp[6:8])
|
|
278
|
+
day = int(curp[8:10])
|
|
279
|
+
|
|
280
|
+
sexo_code = curp[10]
|
|
281
|
+
estado_code = curp[11:13]
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
"fecha_nacimiento": f"{year:04d}-{month:02d}-{day:02d}",
|
|
285
|
+
"sexo": "Hombre" if sexo_code == "H" else "Mujer",
|
|
286
|
+
"sexo_code": sexo_code,
|
|
287
|
+
"estado_code": estado_code,
|
|
288
|
+
"differentiator": curp[16],
|
|
289
|
+
"check_digit": curp[17],
|
|
290
|
+
"check_digit_valid": validator.validate_check_digit(),
|
|
291
|
+
}
|
|
292
|
+
except:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ============================================================================
|
|
297
|
+
# Quick validation functions
|
|
298
|
+
# ============================================================================
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def is_valid_rfc(rfc: str) -> bool:
|
|
302
|
+
"""Quick RFC validation. Alias for validate_rfc()."""
|
|
303
|
+
return validate_rfc(rfc)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def is_valid_curp(curp: str) -> bool:
|
|
307
|
+
"""Quick CURP validation. Alias for validate_curp()."""
|
|
308
|
+
return validate_curp(curp)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ============================================================================
|
|
312
|
+
# Path Helper Functions
|
|
313
|
+
# ============================================================================
|
|
314
|
+
from pathlib import Path
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_project_root() -> Path:
|
|
318
|
+
"""Returns the project root folder by searching for a .git directory."""
|
|
319
|
+
current_path = Path(__file__).parent
|
|
320
|
+
while current_path.parent != current_path:
|
|
321
|
+
if (current_path / ".git").exists():
|
|
322
|
+
return current_path
|
|
323
|
+
current_path = current_path.parent
|
|
324
|
+
raise FileNotFoundError("Project root not found. Could not find .git directory.")
|
catalogmx/utils/text.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text normalization utilities for catalogmx
|
|
3
|
+
==========================================
|
|
4
|
+
|
|
5
|
+
Provides accent-insensitive text normalization for searching across catalogs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from unidecode import unidecode
|
|
10
|
+
except ImportError:
|
|
11
|
+
# Fallback if unidecode not available
|
|
12
|
+
def unidecode(text: str) -> str:
|
|
13
|
+
"""Fallback implementation using unicodedata."""
|
|
14
|
+
import unicodedata
|
|
15
|
+
|
|
16
|
+
nfd = unicodedata.normalize("NFD", text)
|
|
17
|
+
return "".join(char for char in nfd if unicodedata.category(char) != "Mn")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalize_text(text: str) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Normalize text by removing accents and converting to uppercase.
|
|
23
|
+
Makes text searchable without worrying about accents or case.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
text: Text to normalize
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Normalized text (uppercase, no accents)
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> normalize_text("México")
|
|
33
|
+
'MEXICO'
|
|
34
|
+
>>> normalize_text("San José")
|
|
35
|
+
'SAN JOSE'
|
|
36
|
+
>>> normalize_text("Michoacán de Ocampo")
|
|
37
|
+
'MICHOACAN DE OCAMPO'
|
|
38
|
+
"""
|
|
39
|
+
return unidecode(text).upper()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_for_search(text: str) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Alias for normalize_text for clarity in search contexts.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
text: Text to normalize for searching
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Normalized text suitable for accent-insensitive search
|
|
51
|
+
"""
|
|
52
|
+
return normalize_text(text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["normalize_text", "normalize_for_search", "unidecode"]
|
|
File without changes
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CLABE (Clave Bancaria Estandarizada) Validator
|
|
4
|
+
|
|
5
|
+
CLABE is the standardized 18-digit bank account number used in Mexico for
|
|
6
|
+
interbank electronic transfers (SPEI).
|
|
7
|
+
|
|
8
|
+
Structure:
|
|
9
|
+
- 3 digits: Bank code
|
|
10
|
+
- 3 digits: Branch/Plaza code
|
|
11
|
+
- 11 digits: Account number
|
|
12
|
+
- 1 digit: Check digit (modulo 10 algorithm)
|
|
13
|
+
|
|
14
|
+
Example: 002010077777777771
|
|
15
|
+
002: Banamex
|
|
16
|
+
010: Branch code
|
|
17
|
+
07777777777: Account number
|
|
18
|
+
1: Check digit
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CLABEException(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CLABELengthError(CLABEException):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CLABEStructureError(CLABEException):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CLABECheckDigitError(CLABEException):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CLABEValidator:
|
|
39
|
+
"""
|
|
40
|
+
Validates CLABE (Clave Bancaria Estandarizada) bank account numbers
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
LENGTH = 18
|
|
44
|
+
|
|
45
|
+
# Weights for check digit calculation (positions 0-16)
|
|
46
|
+
WEIGHTS = [3, 7, 1] * 6 # Pattern repeats: 3,7,1,3,7,1,...
|
|
47
|
+
|
|
48
|
+
def __init__(self, clabe: str | None) -> None:
|
|
49
|
+
"""
|
|
50
|
+
:param clabe: The CLABE number to validate
|
|
51
|
+
"""
|
|
52
|
+
self.clabe = ""
|
|
53
|
+
if bool(clabe) and isinstance(clabe, str):
|
|
54
|
+
self.clabe = clabe.strip()
|
|
55
|
+
|
|
56
|
+
def validate(self) -> bool:
|
|
57
|
+
"""
|
|
58
|
+
Validates the CLABE structure and check digit
|
|
59
|
+
:return: True if valid, raises exception if invalid
|
|
60
|
+
"""
|
|
61
|
+
value = self.clabe
|
|
62
|
+
|
|
63
|
+
# Check length
|
|
64
|
+
if len(value) != self.LENGTH:
|
|
65
|
+
raise CLABELengthError(f"CLABE length must be {self.LENGTH} digits, got {len(value)}")
|
|
66
|
+
|
|
67
|
+
# Check if all characters are digits
|
|
68
|
+
if not value.isdigit():
|
|
69
|
+
raise CLABEStructureError("CLABE must contain only digits")
|
|
70
|
+
|
|
71
|
+
# Validate check digit
|
|
72
|
+
if not self.verify_check_digit(value):
|
|
73
|
+
raise CLABECheckDigitError("Invalid CLABE check digit")
|
|
74
|
+
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def is_valid(self) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Checks if CLABE is valid without raising exceptions
|
|
80
|
+
:return: True if valid, False otherwise
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
return self.validate()
|
|
84
|
+
except CLABEException:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def calculate_check_digit(cls, clabe_17: str) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Calculates the check digit for a 17-digit CLABE
|
|
91
|
+
|
|
92
|
+
Algorithm:
|
|
93
|
+
1. Multiply each digit by its weight (3,7,1 pattern)
|
|
94
|
+
2. Take modulo 10 of each result
|
|
95
|
+
3. Sum all results
|
|
96
|
+
4. Take modulo 10 of the sum
|
|
97
|
+
5. Subtract from 10
|
|
98
|
+
6. Take modulo 10 of the result
|
|
99
|
+
|
|
100
|
+
:param clabe_17: First 17 digits of CLABE
|
|
101
|
+
:return: Check digit (0-9)
|
|
102
|
+
"""
|
|
103
|
+
if len(clabe_17) != 17:
|
|
104
|
+
raise CLABELengthError("Need exactly 17 digits to calculate check digit")
|
|
105
|
+
|
|
106
|
+
if not clabe_17.isdigit():
|
|
107
|
+
raise CLABEStructureError("CLABE must contain only digits")
|
|
108
|
+
|
|
109
|
+
# Calculate weighted sum
|
|
110
|
+
weighted_sum = 0
|
|
111
|
+
for i, digit in enumerate(clabe_17):
|
|
112
|
+
product = int(digit) * cls.WEIGHTS[i]
|
|
113
|
+
weighted_sum += product % 10
|
|
114
|
+
|
|
115
|
+
# Calculate check digit
|
|
116
|
+
check_digit = (10 - (weighted_sum % 10)) % 10
|
|
117
|
+
|
|
118
|
+
return str(check_digit)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def verify_check_digit(cls, clabe: str) -> bool:
|
|
122
|
+
"""
|
|
123
|
+
Verifies the check digit of an 18-digit CLABE
|
|
124
|
+
|
|
125
|
+
:param clabe: Complete 18-digit CLABE
|
|
126
|
+
:return: True if check digit is valid, False otherwise
|
|
127
|
+
"""
|
|
128
|
+
if len(clabe) != cls.LENGTH:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
calculated = cls.calculate_check_digit(clabe[:17])
|
|
132
|
+
return calculated == clabe[17]
|
|
133
|
+
|
|
134
|
+
def get_bank_code(self) -> str | None:
|
|
135
|
+
"""
|
|
136
|
+
Extracts the bank code (first 3 digits)
|
|
137
|
+
:return: Bank code as string
|
|
138
|
+
"""
|
|
139
|
+
if len(self.clabe) >= 3:
|
|
140
|
+
return self.clabe[:3]
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def get_branch_code(self) -> str | None:
|
|
144
|
+
"""
|
|
145
|
+
Extracts the branch/plaza code (digits 4-6)
|
|
146
|
+
:return: Branch code as string
|
|
147
|
+
"""
|
|
148
|
+
if len(self.clabe) >= 6:
|
|
149
|
+
return self.clabe[3:6]
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def get_account_number(self) -> str | None:
|
|
153
|
+
"""
|
|
154
|
+
Extracts the account number (digits 7-17)
|
|
155
|
+
:return: Account number as string
|
|
156
|
+
"""
|
|
157
|
+
if len(self.clabe) >= 17:
|
|
158
|
+
return self.clabe[6:17]
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def get_check_digit(self) -> str | None:
|
|
162
|
+
"""
|
|
163
|
+
Extracts the check digit (digit 18)
|
|
164
|
+
:return: Check digit as string
|
|
165
|
+
"""
|
|
166
|
+
if len(self.clabe) == self.LENGTH:
|
|
167
|
+
return self.clabe[17]
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def get_parts(self) -> dict[str, str] | None:
|
|
171
|
+
"""
|
|
172
|
+
Returns all CLABE parts as a dictionary
|
|
173
|
+
:return: Dictionary with bank_code, branch_code, account_number, check_digit
|
|
174
|
+
"""
|
|
175
|
+
if not self.is_valid():
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
"bank_code": self.get_bank_code(),
|
|
180
|
+
"branch_code": self.get_branch_code(),
|
|
181
|
+
"account_number": self.get_account_number(),
|
|
182
|
+
"check_digit": self.get_check_digit(),
|
|
183
|
+
"clabe": self.clabe,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_clabe(clabe: str | None) -> bool:
|
|
188
|
+
"""
|
|
189
|
+
Helper function to validate a CLABE
|
|
190
|
+
|
|
191
|
+
:param clabe: CLABE number as string
|
|
192
|
+
:return: True if valid, False otherwise
|
|
193
|
+
"""
|
|
194
|
+
validator = CLABEValidator(clabe)
|
|
195
|
+
return validator.is_valid()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def generate_clabe(bank_code: str | int, branch_code: str | int, account_number: str | int) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Generates a complete CLABE with check digit
|
|
201
|
+
|
|
202
|
+
:param bank_code: 3-digit bank code
|
|
203
|
+
:param branch_code: 3-digit branch code
|
|
204
|
+
:param account_number: 11-digit account number
|
|
205
|
+
:return: Complete 18-digit CLABE
|
|
206
|
+
"""
|
|
207
|
+
# Ensure all parts are strings and properly formatted
|
|
208
|
+
bank_code = str(bank_code).zfill(3)
|
|
209
|
+
branch_code = str(branch_code).zfill(3)
|
|
210
|
+
account_number = str(account_number).zfill(11)
|
|
211
|
+
|
|
212
|
+
if len(bank_code) != 3:
|
|
213
|
+
raise CLABEStructureError("Bank code must be 3 digits")
|
|
214
|
+
if len(branch_code) != 3:
|
|
215
|
+
raise CLABEStructureError("Branch code must be 3 digits")
|
|
216
|
+
if len(account_number) != 11:
|
|
217
|
+
raise CLABEStructureError("Account number must be 11 digits")
|
|
218
|
+
|
|
219
|
+
clabe_17 = bank_code + branch_code + account_number
|
|
220
|
+
check_digit = CLABEValidator.calculate_check_digit(clabe_17)
|
|
221
|
+
|
|
222
|
+
return clabe_17 + check_digit
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_clabe_info(clabe: str | None) -> dict[str, str] | None:
|
|
226
|
+
"""
|
|
227
|
+
Helper function to get information from a CLABE
|
|
228
|
+
|
|
229
|
+
:param clabe: CLABE number as string
|
|
230
|
+
:return: Dictionary with CLABE parts or None if invalid
|
|
231
|
+
"""
|
|
232
|
+
validator = CLABEValidator(clabe)
|
|
233
|
+
return validator.get_parts()
|