catalogmx 0.3.0__py3-none-any.whl → 0.4.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 (53) hide show
  1. catalogmx/__init__.py +133 -19
  2. catalogmx/calculators/__init__.py +113 -0
  3. catalogmx/calculators/costo_trabajador.py +213 -0
  4. catalogmx/calculators/impuestos.py +920 -0
  5. catalogmx/calculators/imss.py +370 -0
  6. catalogmx/calculators/isr.py +290 -0
  7. catalogmx/calculators/resico.py +154 -0
  8. catalogmx/catalogs/banxico/__init__.py +29 -3
  9. catalogmx/catalogs/banxico/cetes_sqlite.py +279 -0
  10. catalogmx/catalogs/banxico/inflacion_sqlite.py +302 -0
  11. catalogmx/catalogs/banxico/salarios_minimos_sqlite.py +295 -0
  12. catalogmx/catalogs/banxico/tiie_sqlite.py +279 -0
  13. catalogmx/catalogs/banxico/tipo_cambio_usd_sqlite.py +255 -0
  14. catalogmx/catalogs/banxico/udis_sqlite.py +332 -0
  15. catalogmx/catalogs/cnbv/__init__.py +9 -0
  16. catalogmx/catalogs/cnbv/sectores.py +173 -0
  17. catalogmx/catalogs/conapo/__init__.py +15 -0
  18. catalogmx/catalogs/conapo/sistema_urbano_nacional.py +50 -0
  19. catalogmx/catalogs/conapo/zonas_metropolitanas.py +230 -0
  20. catalogmx/catalogs/ift/__init__.py +1 -1
  21. catalogmx/catalogs/ift/codigos_lada.py +517 -313
  22. catalogmx/catalogs/inegi/__init__.py +17 -0
  23. catalogmx/catalogs/inegi/scian.py +127 -0
  24. catalogmx/catalogs/mexico/__init__.py +2 -0
  25. catalogmx/catalogs/mexico/giros_mercantiles.py +119 -0
  26. catalogmx/catalogs/sat/carta_porte/material_peligroso.py +5 -1
  27. catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py +78 -0
  28. catalogmx/catalogs/sat/cfdi_4/tasa_o_cuota.py +2 -1
  29. catalogmx/catalogs/sepomex/__init__.py +2 -1
  30. catalogmx/catalogs/sepomex/codigos_postales.py +30 -2
  31. catalogmx/catalogs/sepomex/codigos_postales_completo.py +261 -0
  32. catalogmx/cli.py +12 -9
  33. catalogmx/data/__init__.py +10 -0
  34. catalogmx/data/mexico_dynamic.sqlite3 +0 -0
  35. catalogmx/data/updater.py +362 -0
  36. catalogmx/generators/__init__.py +20 -0
  37. catalogmx/generators/identity.py +582 -0
  38. catalogmx/helpers.py +177 -3
  39. catalogmx/utils/__init__.py +29 -0
  40. catalogmx/utils/clabe_utils.py +417 -0
  41. catalogmx/utils/text.py +7 -1
  42. catalogmx/validators/clabe.py +52 -2
  43. catalogmx/validators/nss.py +32 -27
  44. catalogmx/validators/rfc.py +185 -52
  45. catalogmx-0.4.0.dist-info/METADATA +905 -0
  46. {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/RECORD +51 -25
  47. {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/WHEEL +1 -1
  48. catalogmx/catalogs/banxico/udis.py +0 -279
  49. catalogmx-0.3.0.dist-info/METADATA +0 -644
  50. {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/entry_points.txt +0 -0
  51. {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/licenses/AUTHORS.rst +0 -0
  52. {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/licenses/LICENSE +0 -0
  53. {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/top_level.txt +0 -0
@@ -167,7 +167,7 @@ class CLABEValidator:
167
167
  return self.clabe[17]
168
168
  return None
169
169
 
170
- def get_parts(self) -> dict[str, str] | None:
170
+ def get_parts(self) -> dict[str, str | None] | None:
171
171
  """
172
172
  Returns all CLABE parts as a dictionary
173
173
  :return: Dictionary with bank_code, branch_code, account_number, check_digit
@@ -222,7 +222,7 @@ def generate_clabe(bank_code: str | int, branch_code: str | int, account_number:
222
222
  return clabe_17 + check_digit
223
223
 
224
224
 
225
- def get_clabe_info(clabe: str | None) -> dict[str, str] | None:
225
+ def get_clabe_info(clabe: str | None) -> dict[str, str | None] | None:
226
226
  """
227
227
  Helper function to get information from a CLABE
228
228
 
@@ -231,3 +231,53 @@ def get_clabe_info(clabe: str | None) -> dict[str, str] | None:
231
231
  """
232
232
  validator = CLABEValidator(clabe)
233
233
  return validator.get_parts()
234
+
235
+
236
+ def generate_clabe_random(
237
+ bank_code: str | int | None = None,
238
+ plaza_code: str | int | None = None,
239
+ account_number: str | int | None = None,
240
+ ) -> str:
241
+ """
242
+ Generates a random valid CLABE with optional parameters.
243
+ Any parameter not provided will be randomly generated.
244
+
245
+ :param bank_code: Optional 3-digit bank code (e.g., '012' for BBVA)
246
+ :param plaza_code: Optional 3-digit plaza code
247
+ :param account_number: Optional 11-digit account number
248
+ :return: Complete 18-digit CLABE
249
+
250
+ Example:
251
+ >>> # Fully random CLABE
252
+ >>> clabe = generate_clabe_random()
253
+ >>> len(clabe)
254
+ 18
255
+ >>> # CLABE for specific bank
256
+ >>> clabe = generate_clabe_random(bank_code='012')
257
+ >>> clabe[:3]
258
+ '012'
259
+ >>> # Fully specified
260
+ >>> clabe = generate_clabe_random('002', '010', '12345678901')
261
+ >>> clabe
262
+ '002010123456789018'
263
+ """
264
+ import random
265
+
266
+ # Generate bank code if not provided
267
+ if bank_code is None:
268
+ # Common SPEI bank codes
269
+ common_banks = ["002", "012", "014", "021", "036", "044", "058", "072", "127"]
270
+ bank_code = random.choice(common_banks)
271
+ bank_str = str(bank_code).zfill(3)
272
+
273
+ # Generate plaza code if not provided
274
+ if plaza_code is None:
275
+ plaza_code = random.randint(1, 999)
276
+ plaza_str = str(plaza_code).zfill(3)
277
+
278
+ # Generate account number if not provided
279
+ if account_number is None:
280
+ account_number = random.randint(0, 99999999999)
281
+ account_str = str(account_number).zfill(11)
282
+
283
+ return generate_clabe(bank_str, plaza_str, account_str)
@@ -4,18 +4,19 @@ NSS (Número de Seguridad Social) Validator
4
4
 
5
5
  NSS is the 11-digit social security number issued by IMSS (Instituto Mexicano del Seguro Social).
6
6
 
7
- Structure:
8
- - 2 digits: Subdelegation code
9
- - 2 digits: Year of registration (last 2 digits)
10
- - 2 digits: Registration serial number
11
- - 5 digits: Sequential number
7
+ Structure (11 digits):
8
+ - 2 digits: Subdelegation code (IMSS office)
9
+ - 2 digits: Registration year (last 2 digits)
10
+ - 2 digits: Birth year (last 2 digits)
11
+ - 4 digits: Sequential number
12
12
  - 1 digit: Check digit (modified Luhn algorithm)
13
13
 
14
14
  Example: 12345678903
15
15
  12: Subdelegation
16
- 34: Year (2034 or 1934)
17
- 56: Serial
18
- 78903: Sequential and check digit
16
+ 34: Registration year (2034 or 1934)
17
+ 56: Birth year (2056 or 1956)
18
+ 7890: Sequential
19
+ 3: Check digit
19
20
 
20
21
  Note: The check digit uses a modified Luhn algorithm specific to NSS.
21
22
  """
@@ -145,7 +146,7 @@ class NSSValidator:
145
146
  return self.nss[:2]
146
147
  return None
147
148
 
148
- def get_year(self) -> str | None:
149
+ def get_registration_year(self) -> str | None:
149
150
  """
150
151
  Extracts the registration year (digits 3-4, last 2 digits of year)
151
152
  Note: This is ambiguous - could be 19XX or 20XX
@@ -155,10 +156,11 @@ class NSSValidator:
155
156
  return self.nss[2:4]
156
157
  return None
157
158
 
158
- def get_serial(self) -> str | None:
159
+ def get_birth_year(self) -> str | None:
159
160
  """
160
- Extracts the registration serial (digits 5-6)
161
- :return: Serial number as string
161
+ Extracts the birth year (digits 5-6, last 2 digits of year)
162
+ Note: This is ambiguous - could be 19XX or 20XX
163
+ :return: Year suffix as string
162
164
  """
163
165
  if len(self.nss) >= 6:
164
166
  return self.nss[4:6]
@@ -182,18 +184,18 @@ class NSSValidator:
182
184
  return self.nss[10]
183
185
  return None
184
186
 
185
- def get_parts(self) -> dict[str, str] | None:
187
+ def get_parts(self) -> dict[str, str | None] | None:
186
188
  """
187
189
  Returns all NSS parts as a dictionary
188
- :return: Dictionary with subdelegation, year, serial, sequential, check_digit
190
+ :return: Dictionary with subdelegation, registration_year, birth_year, sequential, check_digit
189
191
  """
190
192
  if not self.is_valid():
191
193
  return None
192
194
 
193
195
  return {
194
196
  "subdelegation": self.get_subdelegation(),
195
- "year": self.get_year(),
196
- "serial": self.get_serial(),
197
+ "registration_year": self.get_registration_year(),
198
+ "birth_year": self.get_birth_year(),
197
199
  "sequential": self.get_sequential(),
198
200
  "check_digit": self.get_check_digit(),
199
201
  "nss": self.nss,
@@ -212,39 +214,42 @@ def validate_nss(nss: str | None) -> bool:
212
214
 
213
215
 
214
216
  def generate_nss(
215
- subdelegation: str | int, year: str | int, serial: str | int, sequential: str | int
217
+ subdelegation: str | int,
218
+ registration_year: str | int,
219
+ birth_year: str | int,
220
+ sequential: str | int,
216
221
  ) -> str:
217
222
  """
218
223
  Generates a complete NSS with check digit
219
224
 
220
225
  :param subdelegation: 2-digit subdelegation code
221
- :param year: 2-digit year (last 2 digits)
222
- :param serial: 2-digit serial number
226
+ :param registration_year: 2-digit registration year (last 2 digits)
227
+ :param birth_year: 2-digit birth year (last 2 digits)
223
228
  :param sequential: 4-digit sequential number
224
229
  :return: Complete 11-digit NSS
225
230
  """
226
231
  # Ensure all parts are strings and properly formatted
227
232
  subdelegation = str(subdelegation).zfill(2)
228
- year = str(year).zfill(2)
229
- serial = str(serial).zfill(2)
233
+ registration_year = str(registration_year).zfill(2)
234
+ birth_year = str(birth_year).zfill(2)
230
235
  sequential = str(sequential).zfill(4)
231
236
 
232
237
  if len(subdelegation) != 2:
233
238
  raise NSSStructureError("Subdelegation must be 2 digits")
234
- if len(year) != 2:
235
- raise NSSStructureError("Year must be 2 digits")
236
- if len(serial) != 2:
237
- raise NSSStructureError("Serial must be 2 digits")
239
+ if len(registration_year) != 2:
240
+ raise NSSStructureError("Registration year must be 2 digits")
241
+ if len(birth_year) != 2:
242
+ raise NSSStructureError("Birth year must be 2 digits")
238
243
  if len(sequential) != 4:
239
244
  raise NSSStructureError("Sequential must be 4 digits")
240
245
 
241
- nss_10 = subdelegation + year + serial + sequential
246
+ nss_10 = subdelegation + registration_year + birth_year + sequential
242
247
  check_digit = NSSValidator.calculate_check_digit(nss_10)
243
248
 
244
249
  return nss_10 + check_digit
245
250
 
246
251
 
247
- def get_nss_info(nss: str | None) -> dict[str, str] | None:
252
+ def get_nss_info(nss: str | None) -> dict[str, str | None] | None:
248
253
  """
249
254
  Helper function to get information from an NSS
250
255
 
@@ -264,8 +264,7 @@ class RFCValidator(RFCGeneral):
264
264
  return "Persona Física"
265
265
  if self.is_moral():
266
266
  return "Persona Moral"
267
- else:
268
- return "RFC Inválido"
267
+ return "RFC Inválido"
269
268
 
270
269
  def is_generic(self) -> bool:
271
270
  """
@@ -322,7 +321,7 @@ class RFCValidator(RFCGeneral):
322
321
  return False
323
322
 
324
323
  @classmethod
325
- def calculate_last_digit(cls, rfc: str, with_checksum: bool = True) -> str | bool:
324
+ def calculate_last_digit(cls, rfc: str, with_checksum: bool = True) -> str:
326
325
  """
327
326
  Calculates the checksum of an RFC.
328
327
 
@@ -332,7 +331,7 @@ class RFCValidator(RFCGeneral):
332
331
  if bool(rfc) and isinstance(rfc, str):
333
332
  str_rfc = rfc.strip().upper()
334
333
  else:
335
- return False
334
+ return ""
336
335
  if with_checksum:
337
336
  str_rfc = str_rfc[:-1]
338
337
  assert len(str_rfc) in (11, 12)
@@ -412,14 +411,13 @@ class RFCGeneratorUtils(RFCGeneral):
412
411
  "MI",
413
412
  "COMPAÑIA",
414
413
  "COMPAÑÍA",
414
+ "COMPANIA", # Without accent
415
415
  "CIA",
416
416
  "CIA.",
417
417
  "SOCIEDAD",
418
418
  "SOC",
419
419
  "SOC.",
420
- "COOPERATIVA",
421
- "COOP",
422
- "COOP.",
420
+ # Note: COOPERATIVA is NOT excluded according to SAT spec
423
421
  "S.A.",
424
422
  "SA",
425
423
  "S.A",
@@ -511,6 +509,31 @@ class RFCGeneratorUtils(RFCGeneral):
511
509
  "18": "DIECIOCHO",
512
510
  "19": "DIECINUEVE",
513
511
  "20": "VEINTE",
512
+ "21": "VEINTIUNO",
513
+ "22": "VEINTIDOS",
514
+ "23": "VEINTITRES",
515
+ "24": "VEINTICUATRO",
516
+ "25": "VEINTICINCO",
517
+ "26": "VEINTISEIS",
518
+ "27": "VEINTISIETE",
519
+ "28": "VEINTIOCHO",
520
+ "29": "VEINTINUEVE",
521
+ "30": "TREINTA",
522
+ "40": "CUARENTA",
523
+ "50": "CINCUENTA",
524
+ "60": "SESENTA",
525
+ "70": "SETENTA",
526
+ "80": "OCHENTA",
527
+ "90": "NOVENTA",
528
+ "100": "CIEN",
529
+ "200": "DOSCIENTOS",
530
+ "300": "TRESCIENTOS",
531
+ "400": "CUATROCIENTOS",
532
+ "500": "QUINIENTOS",
533
+ "600": "SEISCIENTOS",
534
+ "700": "SETECIENTOS",
535
+ "800": "OCHOCIENTOS",
536
+ "900": "NOVECIENTOS",
514
537
  }
515
538
 
516
539
  # Tabla de números romanos a arábigos
@@ -535,8 +558,61 @@ class RFCGeneratorUtils(RFCGeneral):
535
558
  "XVIII": 18,
536
559
  "XIX": 19,
537
560
  "XX": 20,
561
+ "XXI": 21,
562
+ "XXII": 22,
563
+ "XXIII": 23,
564
+ "XXIV": 24,
565
+ "XXV": 25,
566
+ "XXVI": 26,
567
+ "XXVII": 27,
568
+ "XXVIII": 28,
569
+ "XXIX": 29,
570
+ "XXX": 30,
538
571
  }
539
572
 
573
+ @classmethod
574
+ def _convert_arabigo_a_texto(cls, num: int) -> str:
575
+ """Convert an Arabic number to text (supports 0-100000)"""
576
+ if num < 0:
577
+ return str(num)
578
+ if str(num) in cls.numeros_texto:
579
+ return cls.numeros_texto[str(num)]
580
+ if num == 100000:
581
+ return "CIEN MIL"
582
+
583
+ parts = []
584
+
585
+ # Thousands (1000-99999)
586
+ if num >= 1000:
587
+ thousands = num // 1000
588
+ if thousands == 1:
589
+ parts.append("MIL")
590
+ else:
591
+ parts.append(cls._convert_arabigo_a_texto(thousands))
592
+ parts.append("MIL")
593
+ num = num % 1000
594
+
595
+ # Hundreds (100-999)
596
+ if num >= 100:
597
+ hundreds = (num // 100) * 100
598
+ if str(hundreds) in cls.numeros_texto:
599
+ parts.append(cls.numeros_texto[str(hundreds)])
600
+ num = num % 100
601
+
602
+ # Tens and units (1-99)
603
+ if num > 0:
604
+ if str(num) in cls.numeros_texto:
605
+ parts.append(cls.numeros_texto[str(num)])
606
+ elif num >= 30:
607
+ tens = (num // 10) * 10
608
+ units = num % 10
609
+ if str(tens) in cls.numeros_texto:
610
+ parts.append(cls.numeros_texto[str(tens)])
611
+ if units > 0 and str(units) in cls.numeros_texto:
612
+ parts.append(cls.numeros_texto[str(units)])
613
+
614
+ return " ".join(parts) if parts else str(num)
615
+
540
616
  @classmethod
541
617
  def convertir_numero_a_texto(cls, numero_str: str) -> str:
542
618
  """Convierte un número (arábigo o romano) a su representación en texto"""
@@ -544,19 +620,14 @@ class RFCGeneratorUtils(RFCGeneral):
544
620
 
545
621
  # Intentar como número romano
546
622
  if numero_str in cls.numeros_romanos:
547
- numero_arabigo = str(cls.numeros_romanos[numero_str])
548
- if numero_arabigo in cls.numeros_texto:
549
- return cls.numeros_texto[numero_arabigo]
623
+ arabigo = cls.numeros_romanos[numero_str]
624
+ return cls._convert_arabigo_a_texto(arabigo)
550
625
 
551
626
  # Intentar como número arábigo
552
- if numero_str in cls.numeros_texto:
553
- return cls.numeros_texto[numero_str]
554
-
555
- # Si no está en la tabla, intentar convertir dígitos
556
627
  try:
557
628
  num = int(numero_str)
558
- if 0 <= num <= 20:
559
- return cls.numeros_texto[str(num)]
629
+ if num >= 0:
630
+ return cls._convert_arabigo_a_texto(num)
560
631
  except ValueError:
561
632
  pass
562
633
 
@@ -564,18 +635,25 @@ class RFCGeneratorUtils(RFCGeneral):
564
635
 
565
636
  @classmethod
566
637
  def clean_name(cls, nombre: str) -> str:
567
- return (
568
- "".join(
569
- char if char in cls.allowed_chars else unidecode.unidecode(char)
570
- for char in " ".join(
571
- elem for elem in nombre.split(" ") if elem not in cls.excluded_words_fisicas
572
- )
573
- .strip()
574
- .upper()
575
- )
576
- .strip()
577
- .upper()
638
+ # Remove apostrophes without adding space (O'farril -> OFARRIL)
639
+ nombre = nombre.replace("'", "")
640
+ # Remove dots
641
+ nombre = nombre.replace(".", " ")
642
+ # Process and filter excluded words
643
+ cleaned = " ".join(
644
+ elem for elem in nombre.split() if elem.upper() not in cls.excluded_words_fisicas
578
645
  )
646
+ # Convert each character: preserve allowed chars, use unidecode for others
647
+ result = []
648
+ for char in cleaned.upper():
649
+ if char in cls.allowed_chars or char == " ":
650
+ result.append(char)
651
+ else:
652
+ # Convert non-allowed characters via unidecode
653
+ decoded = unidecode.unidecode(char).upper()
654
+ result.append("".join(c for c in decoded if c in cls.allowed_chars or c == " "))
655
+ final = "".join(result)
656
+ return final.strip()
579
657
 
580
658
  @staticmethod
581
659
  def name_adapter(name: str, non_strict: bool = False) -> str:
@@ -607,7 +685,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
607
685
  return self._paterno
608
686
 
609
687
  @paterno.setter
610
- def paterno(self, name: str):
688
+ def paterno(self, name: str) -> None:
611
689
  self._paterno = self.name_adapter(name)
612
690
 
613
691
  @property
@@ -615,7 +693,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
615
693
  return self._materno
616
694
 
617
695
  @materno.setter
618
- def materno(self, name: str):
696
+ def materno(self, name: str) -> None:
619
697
  self._materno = self.name_adapter(name, non_strict=True)
620
698
 
621
699
  @property
@@ -623,7 +701,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
623
701
  return self._nombre
624
702
 
625
703
  @nombre.setter
626
- def nombre(self, name: str):
704
+ def nombre(self, name: str) -> None:
627
705
  self._nombre = self.name_adapter(name)
628
706
 
629
707
  @property
@@ -631,7 +709,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
631
709
  return self._dob
632
710
 
633
711
  @dob.setter
634
- def dob(self, date: datetime.date):
712
+ def dob(self, date: datetime.date) -> None:
635
713
  if isinstance(date, datetime.date):
636
714
  self._dob = date
637
715
 
@@ -648,27 +726,55 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
648
726
  return self.dob.strftime("%y%m%d")
649
727
 
650
728
  def generate_letters(self) -> str:
651
- extra_letter = False
652
- clave = []
653
- clave.append(self.paterno_calculo[0])
654
- second_value = list(
655
- filter(lambda x: x >= 0, map(self.paterno_calculo[1:].find, self.vocales))
729
+ # Get parts of compound surnames
730
+ paterno_parts = [p for p in self.paterno_calculo.split() if p]
731
+ materno_parts = [p for p in self.materno_calculo.split() if p]
732
+
733
+ paterno_first = paterno_parts[0] if paterno_parts else ""
734
+ nombre_safe = self.nombre_iniciales if self.nombre_iniciales else "X"
735
+
736
+ # Check if materno originally had prepositions (de, del, la, los, las)
737
+ original_materno_upper = self.materno.upper().strip()
738
+ materno_had_prepositions = (
739
+ original_materno_upper.startswith("DE ")
740
+ or original_materno_upper.startswith("DEL ")
741
+ or original_materno_upper.startswith("LA ")
742
+ or original_materno_upper.startswith("LOS ")
743
+ or original_materno_upper.startswith("LAS ")
656
744
  )
657
- if len(second_value) > 0:
658
- clave.append(self.paterno_calculo[min(second_value) + 1])
659
- else:
660
- extra_letter = True
661
- if self.materno_calculo:
662
- clave.append(self.materno_calculo[0])
745
+
746
+ # Determine effective materno: use second word of paterno if materno was preposition-based or empty
747
+ effective_materno_first = materno_parts[0] if materno_parts else ""
748
+ if len(paterno_parts) >= 2 and (materno_had_prepositions or not effective_materno_first):
749
+ effective_materno_first = paterno_parts[1] if len(paterno_parts) >= 2 else ""
750
+
751
+ clave = ""
752
+
753
+ # Regla 4: Apellido paterno de 1-2 letras
754
+ if paterno_first and len(paterno_first) <= 2 and effective_materno_first:
755
+ clave = paterno_first[0] if paterno_first else "X"
756
+ clave += effective_materno_first[0] if effective_materno_first else "X"
757
+ clave += nombre_safe[0] if nombre_safe else "X"
758
+ clave += nombre_safe[1] if len(nombre_safe) > 1 else "X"
759
+ # Regla 7: Un solo apellido (sin materno)
760
+ elif not effective_materno_first:
761
+ apellido = paterno_first if paterno_first else "X"
762
+ clave = apellido[0] if apellido else "X"
763
+ clave += apellido[1] if len(apellido) > 1 else "X"
764
+ clave += nombre_safe[0] if nombre_safe else "X"
765
+ clave += nombre_safe[1] if len(nombre_safe) > 1 else "X"
766
+ # Regla 1: Formación básica
663
767
  else:
664
- if extra_letter:
665
- clave.append(self.paterno_calculo[1])
768
+ clave = paterno_first[0] if paterno_first else "X"
769
+ # Find first vowel after first letter
770
+ second_value = list(filter(lambda x: x >= 0, map(paterno_first[1:].find, self.vocales)))
771
+ if second_value:
772
+ clave += paterno_first[min(second_value) + 1]
666
773
  else:
667
- extra_letter = True
668
- clave.append(self.nombre_iniciales[0])
669
- if extra_letter:
670
- clave.append(self.nombre_iniciales[1])
671
- clave = "".join(clave)
774
+ clave += "X"
775
+ clave += effective_materno_first[0] if effective_materno_first else "X"
776
+ clave += nombre_safe[0] if nombre_safe else "X"
777
+
672
778
  if clave in self.cacophonic_words:
673
779
  clave = clave[:-1] + "X"
674
780
  return clave
@@ -758,7 +864,7 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
758
864
  return self._razon_social
759
865
 
760
866
  @razon_social.setter
761
- def razon_social(self, name: str):
867
+ def razon_social(self, name: str) -> None:
762
868
  if isinstance(name, str):
763
869
  self._razon_social = name.upper().strip()
764
870
  else:
@@ -769,7 +875,7 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
769
875
  return self._fecha
770
876
 
771
877
  @fecha.setter
772
- def fecha(self, date: datetime.date):
878
+ def fecha(self, date: datetime.date) -> None:
773
879
  if isinstance(date, datetime.date):
774
880
  self._fecha = date
775
881
  else:
@@ -789,6 +895,16 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
789
895
  """Generate date portion in YYMMDD format"""
790
896
  return self.fecha.strftime("%y%m%d")
791
897
 
898
+ # Special character to word conversions according to SAT specification
899
+ # These only apply when the character is standalone (surrounded by spaces/boundaries)
900
+ special_char_words = {
901
+ "@": "ARROBA",
902
+ "%": "PORCIENTO",
903
+ "#": "NUMERO",
904
+ "(": "ABRE",
905
+ "/": "DIAGONAL",
906
+ }
907
+
792
908
  @property
793
909
  def razon_social_calculo(self) -> str:
794
910
  """
@@ -800,8 +916,21 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
800
916
  - Convert numbers (arabic and roman) to text
801
917
  - Handle consonant compounds (CH → C, LL → L)
802
918
  """
919
+ import re
920
+
803
921
  razon = self.razon_social.upper().strip()
804
922
 
923
+ # Step 0: Handle special characters - different treatment for standalone vs embedded
924
+ # Standalone special chars get converted to words, embedded ones are removed
925
+ for char, word in self.special_char_words.items():
926
+ # Replace standalone special chars with their word equivalents
927
+ escaped = re.escape(char)
928
+ razon = re.sub(rf"(^|\s){escaped}(\s|$)", rf"\1{word}\2", razon)
929
+
930
+ # Remove special characters that are embedded inside words (not standalone)
931
+ for char in self.special_char_words.keys():
932
+ razon = razon.replace(char, "")
933
+
805
934
  # Step 1: First pass - remove excluded words with punctuation patterns
806
935
  # This handles cases like "S.A.", "S. A.", etc.
807
936
  # Process longer words first to avoid partial matches (e.g., S.A.B. before S.A.)
@@ -878,8 +1007,12 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
878
1007
  elif word_clean not in self.excluded_words_morales:
879
1008
  filtered_words.append(word_clean)
880
1009
 
1010
+ # If ALL words were excluded, keep the original words (the company name itself)
1011
+ # This handles cases like "Al" where "AL" is an excluded word but is the actual name
1012
+ words_to_use = filtered_words if filtered_words else [w.upper() for w in words_converted]
1013
+
881
1014
  # Step 7: Clean remaining special characters and accents
882
- cleaned = " ".join(filtered_words)
1015
+ cleaned = " ".join(words_to_use)
883
1016
  result = ""
884
1017
  for char in cleaned:
885
1018
  if char in self.allowed_chars: