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.
Files changed (81) hide show
  1. catalogmx/__init__.py +56 -0
  2. catalogmx/catalogs/__init__.py +5 -0
  3. catalogmx/catalogs/banxico/__init__.py +24 -0
  4. catalogmx/catalogs/banxico/banks.py +136 -0
  5. catalogmx/catalogs/banxico/codigos_plaza.py +287 -0
  6. catalogmx/catalogs/banxico/instituciones_financieras.py +338 -0
  7. catalogmx/catalogs/banxico/monedas_divisas.py +386 -0
  8. catalogmx/catalogs/banxico/udis.py +279 -0
  9. catalogmx/catalogs/ift/__init__.py +15 -0
  10. catalogmx/catalogs/ift/codigos_lada.py +426 -0
  11. catalogmx/catalogs/ift/operadores_moviles.py +315 -0
  12. catalogmx/catalogs/inegi/__init__.py +21 -0
  13. catalogmx/catalogs/inegi/localidades.py +207 -0
  14. catalogmx/catalogs/inegi/municipios.py +73 -0
  15. catalogmx/catalogs/inegi/municipios_completo.py +236 -0
  16. catalogmx/catalogs/inegi/states.py +148 -0
  17. catalogmx/catalogs/mexico/__init__.py +17 -0
  18. catalogmx/catalogs/mexico/hoy_no_circula.py +215 -0
  19. catalogmx/catalogs/mexico/placas_formatos.py +184 -0
  20. catalogmx/catalogs/mexico/salarios_minimos.py +156 -0
  21. catalogmx/catalogs/mexico/uma.py +207 -0
  22. catalogmx/catalogs/sat/__init__.py +13 -0
  23. catalogmx/catalogs/sat/carta_porte/__init__.py +19 -0
  24. catalogmx/catalogs/sat/carta_porte/aeropuertos.py +76 -0
  25. catalogmx/catalogs/sat/carta_porte/carreteras.py +59 -0
  26. catalogmx/catalogs/sat/carta_porte/config_autotransporte.py +54 -0
  27. catalogmx/catalogs/sat/carta_porte/material_peligroso.py +66 -0
  28. catalogmx/catalogs/sat/carta_porte/puertos_maritimos.py +63 -0
  29. catalogmx/catalogs/sat/carta_porte/tipo_embalaje.py +48 -0
  30. catalogmx/catalogs/sat/carta_porte/tipo_permiso.py +54 -0
  31. catalogmx/catalogs/sat/cfdi_4/__init__.py +42 -0
  32. catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py +383 -0
  33. catalogmx/catalogs/sat/cfdi_4/clave_unidad.py +298 -0
  34. catalogmx/catalogs/sat/cfdi_4/exportacion.py +45 -0
  35. catalogmx/catalogs/sat/cfdi_4/forma_pago.py +45 -0
  36. catalogmx/catalogs/sat/cfdi_4/impuesto.py +57 -0
  37. catalogmx/catalogs/sat/cfdi_4/meses.py +34 -0
  38. catalogmx/catalogs/sat/cfdi_4/metodo_pago.py +45 -0
  39. catalogmx/catalogs/sat/cfdi_4/objeto_imp.py +45 -0
  40. catalogmx/catalogs/sat/cfdi_4/periodicidad.py +34 -0
  41. catalogmx/catalogs/sat/cfdi_4/regimen_fiscal.py +57 -0
  42. catalogmx/catalogs/sat/cfdi_4/tasa_o_cuota.py +42 -0
  43. catalogmx/catalogs/sat/cfdi_4/tipo_comprobante.py +45 -0
  44. catalogmx/catalogs/sat/cfdi_4/tipo_factor.py +34 -0
  45. catalogmx/catalogs/sat/cfdi_4/tipo_relacion.py +45 -0
  46. catalogmx/catalogs/sat/cfdi_4/uso_cfdi.py +45 -0
  47. catalogmx/catalogs/sat/comercio_exterior/__init__.py +39 -0
  48. catalogmx/catalogs/sat/comercio_exterior/claves_pedimento.py +77 -0
  49. catalogmx/catalogs/sat/comercio_exterior/estados.py +122 -0
  50. catalogmx/catalogs/sat/comercio_exterior/incoterms.py +226 -0
  51. catalogmx/catalogs/sat/comercio_exterior/monedas.py +107 -0
  52. catalogmx/catalogs/sat/comercio_exterior/motivos_traslado.py +54 -0
  53. catalogmx/catalogs/sat/comercio_exterior/paises.py +88 -0
  54. catalogmx/catalogs/sat/comercio_exterior/registro_ident_trib.py +76 -0
  55. catalogmx/catalogs/sat/comercio_exterior/unidades_aduana.py +54 -0
  56. catalogmx/catalogs/sat/comercio_exterior/validator.py +212 -0
  57. catalogmx/catalogs/sat/nomina/__init__.py +19 -0
  58. catalogmx/catalogs/sat/nomina/banco.py +50 -0
  59. catalogmx/catalogs/sat/nomina/periodicidad_pago.py +48 -0
  60. catalogmx/catalogs/sat/nomina/riesgo_puesto.py +56 -0
  61. catalogmx/catalogs/sat/nomina/tipo_contrato.py +47 -0
  62. catalogmx/catalogs/sat/nomina/tipo_jornada.py +42 -0
  63. catalogmx/catalogs/sat/nomina/tipo_nomina.py +52 -0
  64. catalogmx/catalogs/sat/nomina/tipo_regimen.py +47 -0
  65. catalogmx/catalogs/sepomex/__init__.py +5 -0
  66. catalogmx/catalogs/sepomex/codigos_postales.py +184 -0
  67. catalogmx/cli.py +185 -0
  68. catalogmx/helpers.py +324 -0
  69. catalogmx/utils/text.py +55 -0
  70. catalogmx/validators/__init__.py +0 -0
  71. catalogmx/validators/clabe.py +233 -0
  72. catalogmx/validators/curp.py +623 -0
  73. catalogmx/validators/nss.py +255 -0
  74. catalogmx/validators/rfc.py +1004 -0
  75. catalogmx-0.3.0.dist-info/METADATA +644 -0
  76. catalogmx-0.3.0.dist-info/RECORD +81 -0
  77. catalogmx-0.3.0.dist-info/WHEEL +5 -0
  78. catalogmx-0.3.0.dist-info/entry_points.txt +2 -0
  79. catalogmx-0.3.0.dist-info/licenses/AUTHORS.rst +5 -0
  80. catalogmx-0.3.0.dist-info/licenses/LICENSE +19 -0
  81. catalogmx-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,623 @@
1
+ #!/usr/bin/env python3
2
+ import datetime
3
+ import re
4
+
5
+ import unidecode
6
+
7
+
8
+ class CURPException(Exception):
9
+ pass
10
+
11
+
12
+ class CURPLengthError(CURPException):
13
+ pass
14
+
15
+
16
+ class CURPStructureError(CURPException):
17
+ pass
18
+
19
+
20
+ class CURPGeneral:
21
+ """
22
+ General Functions for CURP (Clave Única de Registro de Población)
23
+
24
+ CURP is an 18-character unique identifier for people in Mexico.
25
+ Format: AAAA-YYMMDD-H-EE-BBB-CC
26
+ Where:
27
+ AAAA: 4 letters from name (like RFC)
28
+ YYMMDD: Birth date
29
+ H: Gender (H=Male/Hombre, M=Female/Mujer)
30
+ EE: State code (2 letters)
31
+ BBB: Internal consonants from paterno, materno, nombre
32
+ CC: Homoclave (2 digits/letters)
33
+ """
34
+
35
+ general_regex = re.compile(
36
+ r"[A-Z][AEIOUX][A-Z]{2}[0-9]{2}[0-1][0-9][0-3][0-9][MH][A-Z]{2}[BCDFGHJKLMNPQRSTVWXYZ]{3}[0-9A-Z]{2}"
37
+ )
38
+ length = 18
39
+
40
+ # Mexican state codes
41
+ state_codes = {
42
+ "AGUASCALIENTES": "AS",
43
+ "BAJA CALIFORNIA": "BC",
44
+ "BAJA CALIFORNIA SUR": "BS",
45
+ "CAMPECHE": "CC",
46
+ "COAHUILA": "CL",
47
+ "COLIMA": "CM",
48
+ "CHIAPAS": "CS",
49
+ "CHIHUAHUA": "CH",
50
+ "CIUDAD DE MEXICO": "DF", # Also accepts CDMX
51
+ "DISTRITO FEDERAL": "DF",
52
+ "CDMX": "DF",
53
+ "DURANGO": "DG",
54
+ "GUANAJUATO": "GT",
55
+ "GUERRERO": "GR",
56
+ "HIDALGO": "HG",
57
+ "JALISCO": "JC",
58
+ "ESTADO DE MEXICO": "MC",
59
+ "MEXICO": "MC",
60
+ "MICHOACAN": "MN",
61
+ "MORELOS": "MS",
62
+ "NAYARIT": "NT",
63
+ "NUEVO LEON": "NL",
64
+ "OAXACA": "OC",
65
+ "PUEBLA": "PL",
66
+ "QUERETARO": "QT",
67
+ "QUINTANA ROO": "QR",
68
+ "SAN LUIS POTOSI": "SP",
69
+ "SINALOA": "SL",
70
+ "SONORA": "SR",
71
+ "TABASCO": "TC",
72
+ "TAMAULIPAS": "TS",
73
+ "TLAXCALA": "TL",
74
+ "VERACRUZ": "VZ",
75
+ "YUCATAN": "YN",
76
+ "ZACATECAS": "ZS",
77
+ "NACIDO EN EL EXTRANJERO": "NE", # Born abroad
78
+ "EXTRANJERO": "NE",
79
+ }
80
+
81
+ vocales = "AEIOU"
82
+ consonantes = "BCDFGHJKLMNPQRSTVWXYZ"
83
+
84
+ # Lista oficial completa de palabras inconvenientes según Anexo 2 del Instructivo Normativo CURP
85
+ # Cuando se detectan estas palabras en las primeras 4 letras, la segunda letra se sustituye con 'X'
86
+ cacophonic_words = [
87
+ "BACA",
88
+ "BAKA",
89
+ "BUEI",
90
+ "BUEY",
91
+ "CACA",
92
+ "CACO",
93
+ "CAGA",
94
+ "CAGO",
95
+ "CAKA",
96
+ "KAKO",
97
+ "COGE",
98
+ "COGI",
99
+ "COJA",
100
+ "COJE",
101
+ "COJI",
102
+ "COJO",
103
+ "COLA",
104
+ "CULO",
105
+ "FALO",
106
+ "FETO",
107
+ "GETA",
108
+ "GUEI",
109
+ "GUEY",
110
+ "JETA",
111
+ "JOTO",
112
+ "KACA",
113
+ "KACO",
114
+ "KAGA",
115
+ "KAGO",
116
+ "KAKA",
117
+ "KAKO",
118
+ "KOGE",
119
+ "KOGI",
120
+ "KOJA",
121
+ "KOJE",
122
+ "KOJI",
123
+ "KOJO",
124
+ "KOLA",
125
+ "KULO",
126
+ "LILO",
127
+ "LOCA",
128
+ "LOCO",
129
+ "LOKA",
130
+ "LOKO",
131
+ "MAME",
132
+ "MAMO",
133
+ "MEAR",
134
+ "MEAS",
135
+ "MEON",
136
+ "MIAR",
137
+ "MION",
138
+ "MOCO",
139
+ "MOKO",
140
+ "MULA",
141
+ "MULO",
142
+ "NACA",
143
+ "NACO",
144
+ "PEDA",
145
+ "PEDO",
146
+ "PENE",
147
+ "PIPI",
148
+ "PITO",
149
+ "POPO",
150
+ "PUTA",
151
+ "PUTO",
152
+ "QULO",
153
+ "RATA",
154
+ "ROBA",
155
+ "ROBE",
156
+ "ROBO",
157
+ "RUIN",
158
+ "SENO",
159
+ "TETA",
160
+ "VACA",
161
+ "VAGA",
162
+ "VAGO",
163
+ "VAKA",
164
+ "VUEI",
165
+ "VUEY",
166
+ "WUEI",
167
+ "WUEY",
168
+ ]
169
+
170
+ excluded_words = [
171
+ "DE",
172
+ "LA",
173
+ "LAS",
174
+ "MC",
175
+ "VON",
176
+ "DEL",
177
+ "LOS",
178
+ "Y",
179
+ "MAC",
180
+ "VAN",
181
+ "MI",
182
+ "DA",
183
+ "DAS",
184
+ "DE",
185
+ "DEL",
186
+ "DER",
187
+ "DI",
188
+ "DIE",
189
+ "DD",
190
+ "EL",
191
+ "LA",
192
+ "LOS",
193
+ "LAS",
194
+ "LE",
195
+ "LES",
196
+ "MAC",
197
+ "MC",
198
+ "VAN",
199
+ "VON",
200
+ "Y",
201
+ ]
202
+
203
+ allowed_chars = list("ABCDEFGHIJKLMNÑOPQRSTUVWXYZ")
204
+
205
+
206
+ class CURPValidator(CURPGeneral):
207
+ """
208
+ Validates a CURP (Clave Única de Registro de Población)
209
+ """
210
+
211
+ def __init__(self, curp: str | None) -> None:
212
+ """
213
+ :param curp: The CURP code to be validated
214
+ """
215
+ self.curp = ""
216
+ if bool(curp) and isinstance(curp, str):
217
+ self.curp = curp.upper().strip()
218
+
219
+ def validate(self) -> bool:
220
+ """
221
+ Validates the CURP structure
222
+ :return: True if valid, raises exception if invalid
223
+ """
224
+ value = self.curp.strip()
225
+ if len(value) != self.length:
226
+ raise CURPLengthError("CURP length must be 18")
227
+ if self.general_regex.match(value):
228
+ return True
229
+ else:
230
+ raise CURPStructureError("Invalid CURP structure")
231
+
232
+ def is_valid(self) -> bool:
233
+ """
234
+ Checks if CURP is valid without raising exceptions
235
+ :return: True if valid, False otherwise
236
+ """
237
+ try:
238
+ return self.validate()
239
+ except CURPException:
240
+ return False
241
+
242
+ def validate_check_digit(self) -> bool:
243
+ """
244
+ Valida el dígito verificador (posición 18) del CURP
245
+
246
+ :return: True si el dígito verificador es correcto, False en caso contrario
247
+ """
248
+ if len(self.curp) != 18:
249
+ return False
250
+
251
+ # Obtener los primeros 17 caracteres
252
+ curp_17 = self.curp[:17]
253
+
254
+ # Calcular el dígito verificador esperado
255
+ expected_digit = CURPGenerator.calculate_check_digit(curp_17)
256
+
257
+ # Comparar con el dígito actual
258
+ actual_digit = self.curp[17]
259
+
260
+ return expected_digit == actual_digit
261
+
262
+
263
+ class CURPGeneratorUtils(CURPGeneral):
264
+ """
265
+ Utility functions for CURP generation
266
+ """
267
+
268
+ @classmethod
269
+ def clean_name(cls, nombre: str | None) -> str:
270
+ """Clean name by removing excluded words and special characters"""
271
+ if not nombre:
272
+ return ""
273
+ result = (
274
+ "".join(
275
+ char if char in cls.allowed_chars else unidecode.unidecode(char)
276
+ for char in " ".join(
277
+ elem for elem in nombre.split(" ") if elem.upper() not in cls.excluded_words
278
+ )
279
+ .strip()
280
+ .upper()
281
+ )
282
+ .strip()
283
+ .upper()
284
+ )
285
+ return result
286
+
287
+ @staticmethod
288
+ def name_adapter(name: str | None, non_strict: bool = False) -> str:
289
+ """Adapt name to uppercase and strip"""
290
+ if isinstance(name, str):
291
+ return name.upper().strip()
292
+ elif non_strict:
293
+ if name is None or not name:
294
+ return ""
295
+ else:
296
+ raise ValueError("Name must be a string")
297
+
298
+ @classmethod
299
+ def get_first_consonant(cls, word: str) -> str:
300
+ """
301
+ Get the first internal consonant from a word
302
+ (the first consonant that is not the first letter)
303
+ """
304
+ if not word or len(word) <= 1:
305
+ return "X"
306
+
307
+ for char in word[1:]:
308
+ if char in cls.consonantes:
309
+ return char
310
+ return "X"
311
+
312
+ @classmethod
313
+ def get_state_code(cls, state: str | None) -> str:
314
+ """
315
+ Get the two-letter state code from state name
316
+ """
317
+ if not state:
318
+ return "NE" # Born abroad default
319
+
320
+ state_upper = state.upper().strip()
321
+
322
+ # Try exact match first
323
+ if state_upper in cls.state_codes:
324
+ return cls.state_codes[state_upper]
325
+
326
+ # Clean the state name and try again
327
+ state_clean = cls.clean_name(state).upper()
328
+ if state_clean in cls.state_codes:
329
+ return cls.state_codes[state_clean]
330
+
331
+ # Try to find partial match
332
+ for state_name, code in cls.state_codes.items():
333
+ if state_name in state_upper or state_upper in state_name:
334
+ return code
335
+
336
+ # If it's already a 2-letter code, validate and return
337
+ if (
338
+ len(state_upper) == 2
339
+ and state_upper[0] in cls.allowed_chars
340
+ and state_upper[1] in cls.allowed_chars
341
+ ):
342
+ return state_upper
343
+
344
+ return "NE" # Default to born abroad
345
+
346
+
347
+ class CURPGenerator(CURPGeneratorUtils):
348
+ """
349
+ CURP Generator for Mexican citizens and residents
350
+
351
+ Generates an 18-character CURP based on:
352
+ - Personal names (paterno, materno, nombre)
353
+ - Birth date
354
+ - Gender
355
+ - Birth state
356
+ """
357
+
358
+ def __init__(
359
+ self,
360
+ nombre: str,
361
+ paterno: str,
362
+ materno: str | None,
363
+ fecha_nacimiento: datetime.date,
364
+ sexo: str,
365
+ estado: str | None,
366
+ ) -> None:
367
+ """
368
+ Initialize CURP Generator
369
+
370
+ :param nombre: First name(s)
371
+ :param paterno: First surname (apellido paterno)
372
+ :param materno: Second surname (apellido materno) - can be empty
373
+ :param fecha_nacimiento: Birth date (datetime.date object)
374
+ :param sexo: Gender - 'H' for male (Hombre), 'M' for female (Mujer)
375
+ :param estado: Birth state (Mexican state name or code)
376
+ """
377
+ if not paterno or not paterno.strip():
378
+ raise ValueError("Apellido paterno is required")
379
+ if not nombre or not nombre.strip():
380
+ raise ValueError("Nombre is required")
381
+ if not isinstance(fecha_nacimiento, datetime.date):
382
+ raise ValueError("fecha_nacimiento must be a datetime.date object")
383
+ if sexo.upper() not in ("H", "M"):
384
+ raise ValueError('sexo must be "H" (Hombre) or "M" (Mujer)')
385
+
386
+ self.nombre = nombre
387
+ self.paterno = paterno
388
+ self.materno = materno if materno else ""
389
+ self.fecha_nacimiento = fecha_nacimiento
390
+ self.sexo = sexo.upper()
391
+ self.estado = estado
392
+ self._curp = ""
393
+
394
+ @property
395
+ def nombre(self) -> str:
396
+ return self._nombre
397
+
398
+ @nombre.setter
399
+ def nombre(self, value: str) -> None:
400
+ self._nombre = self.name_adapter(value)
401
+
402
+ @property
403
+ def paterno(self) -> str:
404
+ return self._paterno
405
+
406
+ @paterno.setter
407
+ def paterno(self, value: str) -> None:
408
+ self._paterno = self.name_adapter(value)
409
+
410
+ @property
411
+ def materno(self) -> str:
412
+ return self._materno
413
+
414
+ @materno.setter
415
+ def materno(self, value: str | None) -> None:
416
+ self._materno = self.name_adapter(value, non_strict=True)
417
+
418
+ @property
419
+ def nombre_calculo(self) -> str:
420
+ """Get cleaned first name"""
421
+ return self.clean_name(self.nombre)
422
+
423
+ @property
424
+ def paterno_calculo(self) -> str:
425
+ """Get cleaned first surname"""
426
+ return self.clean_name(self.paterno)
427
+
428
+ @property
429
+ def materno_calculo(self) -> str:
430
+ """Get cleaned second surname"""
431
+ return self.clean_name(self.materno) if self.materno else ""
432
+
433
+ @property
434
+ def nombre_iniciales(self) -> str:
435
+ """
436
+ Get the first name to use for initials
437
+ Skip common first names like JOSE and MARIA in compound names
438
+ """
439
+ if not self.nombre_calculo:
440
+ return self.nombre_calculo
441
+
442
+ words = self.nombre_calculo.split()
443
+ if len(words) > 1:
444
+ if words[0] in ("MARIA", "JOSE", "MA", "MA.", "J", "J."):
445
+ return " ".join(words[1:])
446
+ return self.nombre_calculo
447
+
448
+ def generate_letters(self) -> str:
449
+ """
450
+ Generate the first 4 letters of CURP
451
+
452
+ 1. First letter of paterno
453
+ 2. First vowel of paterno (after first letter)
454
+ 3. First letter of materno (or X if none)
455
+ 4. First letter of nombre
456
+ """
457
+ clave = []
458
+
459
+ # First letter of paterno
460
+ paterno = self.paterno_calculo
461
+ if not paterno:
462
+ raise ValueError("Apellido paterno cannot be empty")
463
+
464
+ clave.append(paterno[0])
465
+
466
+ # First vowel of paterno (after first letter)
467
+ vowel_found = False
468
+ for char in paterno[1:]:
469
+ if char in self.vocales:
470
+ clave.append(char)
471
+ vowel_found = True
472
+ break
473
+
474
+ if not vowel_found:
475
+ clave.append("X")
476
+
477
+ # First letter of materno (or X if none)
478
+ materno = self.materno_calculo
479
+ if materno:
480
+ clave.append(materno[0])
481
+ else:
482
+ clave.append("X")
483
+
484
+ # First letter of nombre
485
+ nombre = self.nombre_iniciales
486
+ if not nombre:
487
+ raise ValueError("Nombre cannot be empty")
488
+
489
+ clave.append(nombre[0])
490
+
491
+ result = "".join(clave)
492
+
493
+ # Check for cacophonic words and replace second character (first vowel) with 'X'
494
+ # Según el Instructivo Normativo CURP, Anexo 2
495
+ if result in self.cacophonic_words:
496
+ result = result[0] + "X" + result[2:]
497
+
498
+ return result
499
+
500
+ def generate_date(self) -> str:
501
+ """Generate date portion in YYMMDD format"""
502
+ return self.fecha_nacimiento.strftime("%y%m%d")
503
+
504
+ def generate_consonants(self) -> str:
505
+ """
506
+ Generate the 3-consonant section
507
+
508
+ 1. First internal consonant of paterno
509
+ 2. First internal consonant of materno (or X if none)
510
+ 3. First internal consonant of nombre
511
+ """
512
+ consonants = []
513
+
514
+ # First internal consonant of paterno
515
+ paterno = self.paterno_calculo
516
+ consonants.append(self.get_first_consonant(paterno))
517
+
518
+ # First internal consonant of materno
519
+ materno = self.materno_calculo
520
+ if materno:
521
+ consonants.append(self.get_first_consonant(materno))
522
+ else:
523
+ consonants.append("X")
524
+
525
+ # First internal consonant of nombre
526
+ nombre = self.nombre_iniciales
527
+ consonants.append(self.get_first_consonant(nombre))
528
+
529
+ return "".join(consonants)
530
+
531
+ def generate_homoclave(self) -> str:
532
+ """
533
+ Generate the 2-character homoclave (positions 17-18)
534
+
535
+ IMPORTANTE: Según el Instructivo Normativo oficial:
536
+ - Posición 17: Diferenciador de homonimia asignado ALEATORIAMENTE por RENAPO
537
+ (no es calculable algorítmicamente)
538
+ Para nacidos antes del 2000: números 0-9
539
+ Para nacidos después del 2000: letras A-Z o números 0-9
540
+ - Posición 18: Dígito verificador calculado mediante algoritmo oficial
541
+
542
+ Este método genera valores por defecto ya que la homoclave real solo puede
543
+ ser asignada oficialmente por RENAPO.
544
+ """
545
+ # Posición 17: Diferenciador (asignado por RENAPO, usamos '0' por defecto)
546
+ if self.fecha_nacimiento.year < 2000:
547
+ differentiator = "0" # Para antes del 2000: 0-9
548
+ else:
549
+ differentiator = "A" # Para después del 2000: A-Z o 0-9
550
+
551
+ # Posición 18: Dígito verificador (calculable)
552
+ temp_curp = (
553
+ self.generate_letters()
554
+ + self.generate_date()
555
+ + self.sexo
556
+ + self.get_state_code(self.estado)
557
+ + self.generate_consonants()
558
+ + differentiator
559
+ )
560
+
561
+ check_digit = self.calculate_check_digit(temp_curp)
562
+
563
+ return differentiator + check_digit
564
+
565
+ @staticmethod
566
+ def calculate_check_digit(curp_17: str) -> str:
567
+ """
568
+ Calcula el dígito verificador (posición 18) según el algoritmo oficial RENAPO
569
+
570
+ Algoritmo:
571
+ 1. Diccionario de valores: "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ"
572
+ 2. Para cada carácter de los primeros 17:
573
+ valor = índice_en_diccionario * (18 - posición)
574
+ 3. Suma todos los valores
575
+ 4. dígito = 10 - (suma % 10)
576
+ 5. Si dígito == 10, entonces dígito = 0
577
+
578
+ :param curp_17: Los primeros 17 caracteres del CURP
579
+ :return: Dígito verificador (0-9)
580
+ """
581
+ if len(curp_17) != 17:
582
+ raise ValueError(
583
+ "CURP debe tener exactamente 17 caracteres para calcular dígito verificador"
584
+ )
585
+
586
+ # Diccionario oficial de valores
587
+ dictionary = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ"
588
+
589
+ suma = 0
590
+ for i, char in enumerate(curp_17):
591
+ # Obtener el índice del carácter en el diccionario
592
+ try:
593
+ char_value = dictionary.index(char)
594
+ except ValueError:
595
+ # Si el carácter no está en el diccionario, usar 0
596
+ char_value = 0
597
+
598
+ # Multiplicar por (18 - posición)
599
+ suma += char_value * (18 - i)
600
+
601
+ # Calcular dígito verificador
602
+ digito = 10 - (suma % 10)
603
+
604
+ # Si es 10, retornar 0
605
+ if digito == 10:
606
+ digito = 0
607
+
608
+ return str(digito)
609
+
610
+ @property
611
+ def curp(self) -> str:
612
+ """Generate and return the complete CURP"""
613
+ if not self._curp:
614
+ letters = self.generate_letters()
615
+ date = self.generate_date()
616
+ gender = self.sexo
617
+ state = self.get_state_code(self.estado)
618
+ consonants = self.generate_consonants()
619
+ homoclave = self.generate_homoclave()
620
+
621
+ self._curp = letters + date + gender + state + consonants + homoclave
622
+
623
+ return self._curp