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,77 @@
1
+ """
2
+ Catálogo c_ClavePedimento - Claves de Pedimento Aduanero
3
+
4
+ Identificadores del tipo de operación aduanera que ampara el CFDI.
5
+
6
+ Fuente: SAT - Anexo 22 de las RGCE
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+
13
+ class ClavePedimentoCatalog:
14
+ """Catálogo de claves de pedimento aduanero"""
15
+
16
+ _data: list[dict] | None = None
17
+ _clave_by_code: dict[str, dict] | None = None
18
+
19
+ @classmethod
20
+ def _load_data(cls) -> None:
21
+ """Carga los datos del catálogo desde el archivo JSON compartido"""
22
+ if cls._data is None:
23
+ current_file = Path(__file__)
24
+ shared_data_path = (
25
+ current_file.parent.parent.parent.parent.parent.parent
26
+ / "shared-data"
27
+ / "sat"
28
+ / "comercio_exterior"
29
+ / "claves_pedimento.json"
30
+ )
31
+
32
+ with open(shared_data_path, encoding="utf-8") as f:
33
+ data = json.load(f)
34
+ # Handle both list and dict formats
35
+ cls._data = data if isinstance(data, list) else data.get("claves", data)
36
+
37
+ cls._clave_by_code = {item["clave"]: item for item in cls._data}
38
+
39
+ @classmethod
40
+ def get_clave(cls, code: str) -> dict | None:
41
+ """Obtiene una clave de pedimento por su código"""
42
+ cls._load_data()
43
+ return cls._clave_by_code.get(code.upper())
44
+
45
+ @classmethod
46
+ def is_valid(cls, code: str) -> bool:
47
+ """Verifica si una clave de pedimento es válida"""
48
+ return cls.get_clave(code) is not None
49
+
50
+ @classmethod
51
+ def is_export(cls, code: str) -> bool:
52
+ """Verifica si la clave corresponde a exportación"""
53
+ clave = cls.get_clave(code)
54
+ return clave.get("regimen") == "exportacion" if clave else False
55
+
56
+ @classmethod
57
+ def is_import(cls, code: str) -> bool:
58
+ """Verifica si la clave corresponde a importación"""
59
+ clave = cls.get_clave(code)
60
+ return clave.get("regimen") == "importacion" if clave else False
61
+
62
+ @classmethod
63
+ def get_by_regime(cls, regime: str) -> list[dict]:
64
+ """
65
+ Obtiene claves por régimen
66
+
67
+ Args:
68
+ regime: exportacion, importacion, retorno, transito, etc.
69
+ """
70
+ cls._load_data()
71
+ return [item for item in cls._data if item.get("regimen") == regime]
72
+
73
+ @classmethod
74
+ def get_all(cls) -> list[dict]:
75
+ """Retorna todas las claves de pedimento"""
76
+ cls._load_data()
77
+ return cls._data.copy()
@@ -0,0 +1,122 @@
1
+ """Catálogo c_Estado - Estados de USA y Provincias de Canadá"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ class EstadoCatalog:
8
+ """Catálogo de estados/provincias de USA y Canadá para comercio exterior"""
9
+
10
+ _estados_usa: list[dict] | None = None
11
+ _provincias_canada: list[dict] | None = None
12
+ _estado_by_code: dict[str, dict] | None = None
13
+
14
+ @classmethod
15
+ def _load_data(cls) -> None:
16
+ """Carga los datos del catálogo desde el archivo JSON compartido"""
17
+ if cls._estados_usa is None:
18
+ current_file = Path(__file__)
19
+ shared_data_path = (
20
+ current_file.parent.parent.parent.parent.parent.parent
21
+ / "shared-data"
22
+ / "sat"
23
+ / "comercio_exterior"
24
+ / "estados_usa_canada.json"
25
+ )
26
+
27
+ with open(shared_data_path, encoding="utf-8") as f:
28
+ data = json.load(f)
29
+ # Handle both dict with keys or direct list
30
+ if isinstance(data, dict):
31
+ cls._estados_usa = data.get("estados_usa", [])
32
+ cls._provincias_canada = data.get("provincias_canada", [])
33
+ else:
34
+ # Direct list - separate by country field
35
+ cls._estados_usa = [item for item in data if item.get("country") == "USA"]
36
+ cls._provincias_canada = [item for item in data if item.get("country") == "CAN"]
37
+
38
+ # Crear índice unificado por código
39
+ cls._estado_by_code = {}
40
+ for estado in cls._estados_usa:
41
+ cls._estado_by_code[estado["code"]] = estado
42
+ for provincia in cls._provincias_canada:
43
+ cls._estado_by_code[provincia["code"]] = provincia
44
+
45
+ @classmethod
46
+ def get_estado(cls, code: str, country: str | None = None) -> dict | None:
47
+ """
48
+ Obtiene un estado/provincia por su código
49
+
50
+ Args:
51
+ code: Código del estado (TX, CA, ON, etc.)
52
+ country: Opcional - 'USA' o 'CAN' para filtrar
53
+
54
+ Returns:
55
+ dict con información del estado/provincia
56
+ """
57
+ cls._load_data()
58
+ code_upper = code.upper()
59
+ estado = cls._estado_by_code.get(code_upper)
60
+
61
+ if estado and country:
62
+ if estado["country"] != country.upper():
63
+ return None
64
+
65
+ return estado
66
+
67
+ @classmethod
68
+ def get_estado_usa(cls, code: str) -> dict | None:
69
+ """Obtiene un estado de USA por su código"""
70
+ return cls.get_estado(code, "USA")
71
+
72
+ @classmethod
73
+ def get_provincia_canada(cls, code: str) -> dict | None:
74
+ """Obtiene una provincia de Canadá por su código"""
75
+ return cls.get_estado(code, "CAN")
76
+
77
+ @classmethod
78
+ def is_valid(cls, code: str, country: str | None = None) -> bool:
79
+ """Verifica si un código de estado/provincia es válido"""
80
+ return cls.get_estado(code, country) is not None
81
+
82
+ @classmethod
83
+ def get_all_usa(cls) -> list[dict]:
84
+ """Retorna todos los estados de USA"""
85
+ cls._load_data()
86
+ return cls._estados_usa.copy()
87
+
88
+ @classmethod
89
+ def get_all_canada(cls) -> list[dict]:
90
+ """Retorna todas las provincias de Canadá"""
91
+ cls._load_data()
92
+ return cls._provincias_canada.copy()
93
+
94
+ @classmethod
95
+ def get_all(cls) -> list[dict]:
96
+ """Retorna todos los estados y provincias"""
97
+ cls._load_data()
98
+ return cls._estados_usa + cls._provincias_canada
99
+
100
+ @classmethod
101
+ def validate_foreign_address(cls, address_data: dict) -> dict:
102
+ """
103
+ Valida dirección extranjera para comercio exterior
104
+
105
+ Args:
106
+ address_data: dict con 'pais', 'estado', 'num_reg_id_trib'
107
+
108
+ Returns:
109
+ dict con 'valid' (bool) y 'errors' (list)
110
+ """
111
+ errors = []
112
+ pais = address_data.get("pais", "").upper()
113
+ estado = address_data.get("estado", "").upper()
114
+
115
+ # Validar que USA/CAN tengan estado
116
+ if pais in ["USA", "CAN"]:
117
+ if not estado:
118
+ errors.append(f"Campo Estado es obligatorio para país {pais}")
119
+ elif not cls.is_valid(estado, pais):
120
+ errors.append(f"Estado {estado} no válido para país {pais}")
121
+
122
+ return {"valid": len(errors) == 0, "errors": errors}
@@ -0,0 +1,226 @@
1
+ """
2
+ Catálogo c_INCOTERM - Términos Internacionales de Comercio (INCOTERMS 2020)
3
+
4
+ Los INCOTERMS definen las responsabilidades entre comprador y vendedor
5
+ en operaciones de comercio internacional.
6
+
7
+ Fuente: ICC - International Chamber of Commerce / SAT México
8
+ """
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+
14
+ class IncotermsValidator:
15
+ """Validador y catálogo de INCOTERMS 2020 para Comercio Exterior"""
16
+
17
+ _data: list[dict] | None = None
18
+ _incoterm_by_code: dict[str, dict] | None = None
19
+
20
+ @classmethod
21
+ def _load_data(cls) -> None:
22
+ """Carga los datos del catálogo desde el archivo JSON compartido"""
23
+ if cls._data is None:
24
+ current_file = Path(__file__)
25
+ # Navegar a shared-data desde packages/python/catalogmx/catalogs/sat/comercio_exterior
26
+ shared_data_path = (
27
+ current_file.parent.parent.parent.parent.parent.parent
28
+ / "shared-data"
29
+ / "sat"
30
+ / "comercio_exterior"
31
+ / "incoterms.json"
32
+ )
33
+
34
+ with open(shared_data_path, encoding="utf-8") as f:
35
+ data = json.load(f)
36
+ # Handle both list and dict formats
37
+ cls._data = data if isinstance(data, list) else data.get("incoterms", data)
38
+
39
+ # Crear índice por código
40
+ cls._incoterm_by_code = {item["code"]: item for item in cls._data}
41
+
42
+ @classmethod
43
+ def get_incoterm(cls, code: str) -> dict | None:
44
+ """
45
+ Obtiene un INCOTERM por su código
46
+
47
+ Args:
48
+ code: Código INCOTERM (EXW, FCA, FOB, CIF, etc.)
49
+
50
+ Returns:
51
+ Dict con información del INCOTERM o None si no existe
52
+
53
+ Example:
54
+ >>> incoterm = IncotermsValidator.get_incoterm('CIF')
55
+ >>> print(incoterm['name'])
56
+ Cost, Insurance and Freight
57
+ """
58
+ cls._load_data()
59
+ return cls._incoterm_by_code.get(code.upper())
60
+
61
+ @classmethod
62
+ def is_valid(cls, code: str) -> bool:
63
+ """
64
+ Verifica si un código INCOTERM es válido
65
+
66
+ Args:
67
+ code: Código INCOTERM a validar
68
+
69
+ Returns:
70
+ True si el código es válido
71
+
72
+ Example:
73
+ >>> IncotermsValidator.is_valid('FOB')
74
+ True
75
+ >>> IncotermsValidator.is_valid('XXX')
76
+ False
77
+ """
78
+ return cls.get_incoterm(code) is not None
79
+
80
+ @classmethod
81
+ def is_valid_for_transport(cls, code: str, transport_type: str) -> bool:
82
+ """
83
+ Verifica si un INCOTERM es válido para un tipo de transporte
84
+
85
+ Args:
86
+ code: Código INCOTERM
87
+ transport_type: Tipo de transporte ('sea', 'land', 'air', 'multimodal', 'any')
88
+
89
+ Returns:
90
+ True si el INCOTERM es válido para ese transporte
91
+
92
+ Example:
93
+ >>> IncotermsValidator.is_valid_for_transport('CIF', 'sea')
94
+ True
95
+ >>> IncotermsValidator.is_valid_for_transport('CIF', 'land')
96
+ False
97
+ >>> IncotermsValidator.is_valid_for_transport('FCA', 'land')
98
+ True
99
+ """
100
+ incoterm = cls.get_incoterm(code)
101
+ if not incoterm:
102
+ return False
103
+
104
+ suitable_for = incoterm.get("suitable_for", [])
105
+
106
+ if transport_type == "any" or incoterm["transport_mode"] == "any":
107
+ return True
108
+
109
+ return transport_type in suitable_for
110
+
111
+ @classmethod
112
+ def get_multimodal_incoterms(cls) -> list[str]:
113
+ """
114
+ Retorna lista de INCOTERMS válidos para cualquier modo de transporte
115
+
116
+ Returns:
117
+ Lista de códigos INCOTERM multimodales
118
+
119
+ Example:
120
+ >>> multimodal = IncotermsValidator.get_multimodal_incoterms()
121
+ >>> print(multimodal)
122
+ ['EXW', 'FCA', 'CPT', 'CIP', 'DAP', 'DPU', 'DDP']
123
+ """
124
+ cls._load_data()
125
+ return [item["code"] for item in cls._data if item["transport_mode"] == "any"]
126
+
127
+ @classmethod
128
+ def get_maritime_incoterms(cls) -> list[str]:
129
+ """
130
+ Retorna lista de INCOTERMS válidos solo para transporte marítimo
131
+
132
+ Returns:
133
+ Lista de códigos INCOTERM marítimos
134
+
135
+ Example:
136
+ >>> maritime = IncotermsValidator.get_maritime_incoterms()
137
+ >>> print(maritime)
138
+ ['FAS', 'FOB', 'CFR', 'CIF']
139
+ """
140
+ cls._load_data()
141
+ return [item["code"] for item in cls._data if item["transport_mode"] == "maritime"]
142
+
143
+ @classmethod
144
+ def seller_pays_freight(cls, code: str) -> bool:
145
+ """
146
+ Verifica si el vendedor paga el flete en este INCOTERM
147
+
148
+ Args:
149
+ code: Código INCOTERM
150
+
151
+ Returns:
152
+ True si el vendedor paga flete
153
+
154
+ Example:
155
+ >>> IncotermsValidator.seller_pays_freight('CIF')
156
+ True
157
+ >>> IncotermsValidator.seller_pays_freight('EXW')
158
+ False
159
+ """
160
+ incoterm = cls.get_incoterm(code)
161
+ return incoterm.get("seller_pays_freight", False) if incoterm else False
162
+
163
+ @classmethod
164
+ def seller_pays_insurance(cls, code: str) -> bool:
165
+ """
166
+ Verifica si el vendedor paga el seguro en este INCOTERM
167
+
168
+ Args:
169
+ code: Código INCOTERM
170
+
171
+ Returns:
172
+ True si el vendedor paga seguro
173
+
174
+ Example:
175
+ >>> IncotermsValidator.seller_pays_insurance('CIF')
176
+ True
177
+ >>> IncotermsValidator.seller_pays_insurance('CFR')
178
+ False
179
+ """
180
+ incoterm = cls.get_incoterm(code)
181
+ return incoterm.get("seller_pays_insurance", False) if incoterm else False
182
+
183
+ @classmethod
184
+ def get_all(cls) -> list[dict]:
185
+ """
186
+ Retorna todos los INCOTERMS disponibles
187
+
188
+ Returns:
189
+ Lista completa de INCOTERMS
190
+
191
+ Example:
192
+ >>> all_incoterms = IncotermsValidator.get_all()
193
+ >>> print(len(all_incoterms))
194
+ 11
195
+ """
196
+ cls._load_data()
197
+ return cls._data.copy()
198
+
199
+ @classmethod
200
+ def search(cls, query: str) -> list[dict]:
201
+ """
202
+ Busca INCOTERMS por nombre o descripción
203
+
204
+ Args:
205
+ query: Texto a buscar
206
+
207
+ Returns:
208
+ Lista de INCOTERMS que coinciden
209
+
210
+ Example:
211
+ >>> results = IncotermsValidator.search('insurance')
212
+ >>> print([r['code'] for r in results])
213
+ ['CIP', 'CIF']
214
+ """
215
+ cls._load_data()
216
+ query_lower = query.lower()
217
+
218
+ return [
219
+ item
220
+ for item in cls._data
221
+ if (
222
+ query_lower in item["name"].lower()
223
+ or query_lower in item["name_es"].lower()
224
+ or query_lower in item["description"].lower()
225
+ )
226
+ ]
@@ -0,0 +1,107 @@
1
+ """Catálogo c_Moneda - Códigos de Monedas ISO 4217"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ class MonedaCatalog:
8
+ """Catálogo de monedas para operaciones de comercio exterior"""
9
+
10
+ _data: list[dict] | None = None
11
+ _moneda_by_code: dict[str, dict] | None = None
12
+
13
+ @classmethod
14
+ def _load_data(cls) -> None:
15
+ """Carga los datos del catálogo desde el archivo JSON compartido"""
16
+ if cls._data is None:
17
+ current_file = Path(__file__)
18
+ shared_data_path = (
19
+ current_file.parent.parent.parent.parent.parent.parent
20
+ / "shared-data"
21
+ / "sat"
22
+ / "comercio_exterior"
23
+ / "monedas.json"
24
+ )
25
+
26
+ with open(shared_data_path, encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ # Handle both list and dict formats
29
+ cls._data = data if isinstance(data, list) else data.get("monedas", data)
30
+
31
+ cls._moneda_by_code = {item["codigo"]: item for item in cls._data}
32
+
33
+ @classmethod
34
+ def get_moneda(cls, code: str) -> dict | None:
35
+ """Obtiene una moneda por su código ISO 4217"""
36
+ cls._load_data()
37
+ return cls._moneda_by_code.get(code.upper())
38
+
39
+ @classmethod
40
+ def is_valid(cls, code: str) -> bool:
41
+ """Verifica si un código de moneda es válido"""
42
+ return cls.get_moneda(code) is not None
43
+
44
+ @classmethod
45
+ def validate_conversion_usd(cls, cfdi_data: dict) -> dict:
46
+ """
47
+ Valida la conversión a USD según reglas SAT
48
+
49
+ Args:
50
+ cfdi_data: dict con 'moneda', 'total', 'tipo_cambio_usd', 'total_usd'
51
+
52
+ Returns:
53
+ dict con 'valid' (bool) y 'errors' (list)
54
+ """
55
+ errors = []
56
+ moneda = cfdi_data.get("moneda", "").upper()
57
+ tipo_cambio = cfdi_data.get("tipo_cambio_usd")
58
+ total = cfdi_data.get("total")
59
+ total_usd = cfdi_data.get("total_usd")
60
+
61
+ # Validar que la moneda existe
62
+ if not cls.is_valid(moneda):
63
+ errors.append(f"Moneda {moneda} no válida")
64
+ return {"valid": False, "errors": errors}
65
+
66
+ # Si es USD, tipo_cambio debe ser 1
67
+ if moneda == "USD":
68
+ if tipo_cambio and tipo_cambio != 1:
69
+ errors.append("Para USD, TipoCambioUSD debe ser 1")
70
+
71
+ if total != total_usd:
72
+ errors.append("Para USD, Total debe ser igual a TotalUSD")
73
+
74
+ # Si NO es USD, tipo_cambio es obligatorio
75
+ else:
76
+ if not tipo_cambio:
77
+ errors.append("TipoCambioUSD es obligatorio cuando Moneda != USD")
78
+
79
+ # Validar cálculo de TotalUSD
80
+ if tipo_cambio and total and total_usd:
81
+ expected_total_usd = round(total * tipo_cambio, 2)
82
+ if abs(total_usd - expected_total_usd) > 0.01:
83
+ errors.append(f"TotalUSD incorrecto. Esperado: {expected_total_usd}")
84
+
85
+ return {"valid": len(errors) == 0, "errors": errors}
86
+
87
+ @classmethod
88
+ def get_all(cls) -> list[dict]:
89
+ """Retorna todas las monedas"""
90
+ cls._load_data()
91
+ return cls._data.copy()
92
+
93
+ @classmethod
94
+ def search(cls, query: str) -> list[dict]:
95
+ """Busca monedas por código, nombre o país"""
96
+ cls._load_data()
97
+ query_lower = query.lower()
98
+
99
+ return [
100
+ item
101
+ for item in cls._data
102
+ if (
103
+ query_lower in item["codigo"].lower()
104
+ or query_lower in item["nombre"].lower()
105
+ or query_lower in item.get("pais", "").lower()
106
+ )
107
+ ]
@@ -0,0 +1,54 @@
1
+ """Catálogo c_MotivoTraslado - Motivos de Traslado para CFDI tipo T"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ class MotivoTrasladoCatalog:
8
+ """Catálogo de motivos de traslado para CFDI con comercio exterior"""
9
+
10
+ _data: list[dict] | None = None
11
+ _motivo_by_code: dict[str, dict] | None = None
12
+
13
+ @classmethod
14
+ def _load_data(cls) -> None:
15
+ """Carga los datos del catálogo desde el archivo JSON compartido"""
16
+ if cls._data is None:
17
+ current_file = Path(__file__)
18
+ shared_data_path = (
19
+ current_file.parent.parent.parent.parent.parent.parent
20
+ / "shared-data"
21
+ / "sat"
22
+ / "comercio_exterior"
23
+ / "motivos_traslado.json"
24
+ )
25
+
26
+ with open(shared_data_path, encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ # Handle both list and dict formats
29
+ cls._data = data if isinstance(data, list) else data.get("motivos", data)
30
+
31
+ cls._motivo_by_code = {item["code"]: item for item in cls._data}
32
+
33
+ @classmethod
34
+ def get_motivo(cls, code: str) -> dict | None:
35
+ """Obtiene un motivo de traslado por su código"""
36
+ cls._load_data()
37
+ return cls._motivo_by_code.get(code)
38
+
39
+ @classmethod
40
+ def is_valid(cls, code: str) -> bool:
41
+ """Verifica si un código de motivo es válido"""
42
+ return cls.get_motivo(code) is not None
43
+
44
+ @classmethod
45
+ def requires_propietario(cls, code: str) -> bool:
46
+ """Verifica si el motivo requiere nodo <Propietario>"""
47
+ motivo = cls.get_motivo(code)
48
+ return motivo.get("requires_propietario", False) if motivo else False
49
+
50
+ @classmethod
51
+ def get_all(cls) -> list[dict]:
52
+ """Retorna todos los motivos de traslado"""
53
+ cls._load_data()
54
+ return cls._data.copy()
@@ -0,0 +1,88 @@
1
+ """Catálogo c_Pais - Códigos de Países ISO 3166-1"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from catalogmx.utils.text import normalize_text
7
+
8
+
9
+ class PaisCatalog:
10
+ """Catálogo de países para identificar origen/destino en comercio exterior"""
11
+
12
+ _data: list[dict] | None = None
13
+ _pais_by_code: dict[str, dict] | None = None
14
+ _pais_by_iso2: dict[str, dict] | None = None
15
+
16
+ @classmethod
17
+ def _load_data(cls) -> None:
18
+ """Carga los datos del catálogo desde el archivo JSON compartido"""
19
+ if cls._data is None:
20
+ current_file = Path(__file__)
21
+ shared_data_path = (
22
+ current_file.parent.parent.parent.parent.parent.parent
23
+ / "shared-data"
24
+ / "sat"
25
+ / "comercio_exterior"
26
+ / "paises.json"
27
+ )
28
+
29
+ with open(shared_data_path, encoding="utf-8") as f:
30
+ cls._data = json.load(f)
31
+
32
+ cls._pais_by_code = {item["codigo"]: item for item in cls._data}
33
+ cls._pais_by_iso2 = {item["iso2"]: item for item in cls._data}
34
+
35
+ @classmethod
36
+ def get_pais(cls, code: str) -> dict | None:
37
+ """Obtiene un país por su código ISO 3166-1 Alpha-3"""
38
+ cls._load_data()
39
+ code_upper = code.upper()
40
+
41
+ # Intentar primero con Alpha-3
42
+ pais = cls._pais_by_code.get(code_upper)
43
+ if pais:
44
+ return pais
45
+
46
+ # Intentar con Alpha-2
47
+ return cls._pais_by_iso2.get(code_upper)
48
+
49
+ @classmethod
50
+ def is_valid(cls, code: str) -> bool:
51
+ """Verifica si un código de país es válido"""
52
+ return cls.get_pais(code) is not None
53
+
54
+ @classmethod
55
+ def requires_subdivision(cls, code: str) -> bool:
56
+ """
57
+ Verifica si el país requiere subdivisión (estado/provincia)
58
+
59
+ Args:
60
+ code: Código del país (USA, CAN, etc.)
61
+
62
+ Returns:
63
+ True si requiere estado/provincia
64
+ """
65
+ pais = cls.get_pais(code)
66
+ return pais.get("requiere_subdivision", False) if pais else False
67
+
68
+ @classmethod
69
+ def get_all(cls) -> list[dict]:
70
+ """Retorna todos los países"""
71
+ cls._load_data()
72
+ return cls._data.copy()
73
+
74
+ @classmethod
75
+ def search(cls, query: str) -> list[dict]:
76
+ """Busca países por código o nombre (insensible a acentos)"""
77
+ cls._load_data()
78
+ query_normalized = normalize_text(query)
79
+
80
+ return [
81
+ item
82
+ for item in cls._data
83
+ if (
84
+ query_normalized in normalize_text(item["codigo"])
85
+ or query_normalized in normalize_text(item["nombre"])
86
+ or query_normalized in normalize_text(item.get("iso2", ""))
87
+ )
88
+ ]