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
@@ -0,0 +1,920 @@
1
+ """
2
+ Tax Calculators: IVA, IEPS, Retenciones, and Impuestos Locales
3
+ Provides methods for calculating taxes according to Mexican tax laws
4
+
5
+ Official sources:
6
+ - Ley del IVA (Value Added Tax Law)
7
+ - Ley del IEPS (Special Production and Services Tax Law)
8
+ - Ley del ISR (Income Tax Law - for withholdings)
9
+ - State and Municipal Tax Laws
10
+ """
11
+
12
+ import json
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Literal, TypedDict
16
+
17
+ # Type aliases
18
+ IVATipoTasa = Literal["general", "frontera", "tasa_cero"]
19
+
20
+
21
+ class IVATasa(TypedDict):
22
+ """IVA tax rate structure"""
23
+
24
+ tipo: str
25
+ tasa: float
26
+ vigencia_inicio: str
27
+ vigencia_fin: str | None
28
+ descripcion: str
29
+ aplica_en: str
30
+
31
+
32
+ class IVACalculationResult(TypedDict):
33
+ """IVA calculation result"""
34
+
35
+ base: float
36
+ tasa: float
37
+ iva: float
38
+ total_con_iva: float
39
+ tipo_tasa: str
40
+
41
+
42
+ class IEPSTasa(TypedDict):
43
+ """IEPS tax rate structure"""
44
+
45
+ subcategoria: str
46
+ tasa: float
47
+ tipo: str
48
+ vigencia_inicio: str
49
+ vigencia_fin: str | None
50
+ descripcion: str
51
+
52
+
53
+ class IEPSCategoria(TypedDict):
54
+ """IEPS category structure"""
55
+
56
+ categoria: str
57
+ descripcion: str
58
+ tasas: list[IEPSTasa]
59
+
60
+
61
+ class IEPSCalculationResult(TypedDict):
62
+ """IEPS calculation result"""
63
+
64
+ base: float
65
+ tasa: float
66
+ ieps: float
67
+ tipo_calculo: str
68
+ unidad: str | None
69
+ cantidad: float | None
70
+
71
+
72
+ class RetencionISR(TypedDict):
73
+ """ISR withholding structure"""
74
+
75
+ concepto: str
76
+ tasa: float | str
77
+ descripcion: str
78
+ base: str
79
+ articulo: str
80
+
81
+
82
+ class RetencionIVA(TypedDict):
83
+ """IVA withholding structure"""
84
+
85
+ concepto: str
86
+ tasa: float
87
+ descripcion: str
88
+ base: str
89
+ articulo: str
90
+
91
+
92
+ class RetencionCalculationResult(TypedDict):
93
+ """Withholding calculation result"""
94
+
95
+ concepto: str
96
+ base: float
97
+ tasa: float
98
+ retencion: float
99
+ impuesto_base: float | None
100
+
101
+
102
+ class ImpuestoEstatal(TypedDict):
103
+ """State tax structure"""
104
+
105
+ estado: str
106
+ cve_estado: str
107
+ tasa: float
108
+ base: str
109
+
110
+
111
+ class IVAData(TypedDict):
112
+ """IVA data file structure"""
113
+
114
+ metadata: dict
115
+ tasas: list[IVATasa]
116
+ exenciones: list
117
+ tasa_cero_productos: list
118
+
119
+
120
+ class RetencionesData(TypedDict):
121
+ """Retenciones data file structure"""
122
+
123
+ metadata: dict
124
+ isr_retenciones: list[RetencionISR]
125
+ iva_retenciones: list[RetencionIVA]
126
+ retenciones_definitivas: list
127
+
128
+
129
+ class ImpuestosLocalesData(TypedDict):
130
+ """Impuestos Locales data file structure"""
131
+
132
+ metadata: dict
133
+ impuesto_nomina: list[ImpuestoEstatal]
134
+ impuesto_hospedaje: list[ImpuestoEstatal]
135
+ otros_impuestos_estatales: list
136
+ predial: dict
137
+
138
+
139
+ class IVACalculator:
140
+ """
141
+ IVA (Value Added Tax) Calculator
142
+
143
+ Calculates IVA according to Mexican tax law with support for:
144
+ - General rate (16%)
145
+ - Border rate (8%)
146
+ - Zero rate (0%)
147
+ - Historical rates
148
+ """
149
+
150
+ _data: IVAData | None = None
151
+
152
+ @classmethod
153
+ def _load_data(cls) -> None:
154
+ """Lazy load IVA data from JSON file"""
155
+ if cls._data is not None:
156
+ return
157
+
158
+ # Path from catalogmx/calculators/impuestos.py -> shared-data/sat/impuestos/iva_tasas.json
159
+ json_path = (
160
+ Path(__file__).parent.parent.parent.parent.parent
161
+ / "packages"
162
+ / "shared-data"
163
+ / "sat"
164
+ / "impuestos"
165
+ / "iva_tasas.json"
166
+ )
167
+ with open(json_path, encoding="utf-8") as f:
168
+ cls._data = json.load(f)
169
+
170
+ @classmethod
171
+ def get_tasa_vigente(
172
+ cls,
173
+ fecha: str | datetime | None = None,
174
+ tipo_tasa: IVATipoTasa = "general",
175
+ ) -> IVATasa | None:
176
+ """
177
+ Get the current IVA rate for a specific date and type
178
+
179
+ Args:
180
+ fecha: Date in ISO format (YYYY-MM-DD) or datetime object (default: today)
181
+ tipo_tasa: Rate type (general, frontera, tasa_cero)
182
+
183
+ Returns:
184
+ IVA rate object or None if not found
185
+
186
+ Examples:
187
+ >>> tasa = IVACalculator.get_tasa_vigente()
188
+ >>> print(f"Tasa actual: {tasa['tasa']}%")
189
+ Tasa actual: 16.0%
190
+
191
+ >>> tasa = IVACalculator.get_tasa_vigente("2024-01-01", "frontera")
192
+ >>> print(f"Tasa frontera: {tasa['tasa']}%")
193
+ Tasa frontera: 8.0%
194
+ """
195
+ cls._load_data()
196
+
197
+ # Convert fecha to datetime
198
+ if fecha is None:
199
+ fecha_date = datetime.now()
200
+ elif isinstance(fecha, str):
201
+ fecha_date = datetime.fromisoformat(fecha)
202
+ else:
203
+ fecha_date = fecha
204
+
205
+ # Filter by rate type
206
+ tasas_del_tipo = [t for t in cls._data["tasas"] if t["tipo"] == tipo_tasa]
207
+
208
+ # Find applicable rate
209
+ for tasa in tasas_del_tipo:
210
+ inicio_date = datetime.fromisoformat(tasa["vigencia_inicio"])
211
+ fin_date = (
212
+ datetime.fromisoformat(tasa["vigencia_fin"]) if tasa["vigencia_fin"] else None
213
+ )
214
+
215
+ despues_inicio = fecha_date >= inicio_date
216
+ antes_fin = not fin_date or fecha_date <= fin_date
217
+
218
+ if despues_inicio and antes_fin:
219
+ return tasa
220
+
221
+ return None
222
+
223
+ @classmethod
224
+ def calcular(
225
+ cls,
226
+ base: float,
227
+ tipo_tasa: IVATipoTasa = "general",
228
+ fecha: str | datetime | None = None,
229
+ ) -> IVACalculationResult:
230
+ """
231
+ Calculate IVA for a base amount
232
+
233
+ Args:
234
+ base: Base amount without IVA
235
+ tipo_tasa: Rate type (general, frontera, tasa_cero)
236
+ fecha: Date to determine applicable rate (default: today)
237
+
238
+ Returns:
239
+ Complete calculation result with breakdown
240
+
241
+ Raises:
242
+ ValueError: If no applicable rate is found
243
+
244
+ Examples:
245
+ >>> result = IVACalculator.calcular(1000)
246
+ >>> print(f"IVA: ${result['iva']:.2f}")
247
+ IVA: $160.00
248
+
249
+ >>> result = IVACalculator.calcular(1000, "frontera")
250
+ >>> print(f"IVA frontera: ${result['iva']:.2f}")
251
+ IVA frontera: $80.00
252
+ """
253
+ tasa_obj = cls.get_tasa_vigente(fecha, tipo_tasa)
254
+
255
+ if not tasa_obj:
256
+ raise ValueError(
257
+ f"No se encontró tasa de IVA vigente para tipo {tipo_tasa} y fecha {fecha}"
258
+ )
259
+
260
+ tasa = tasa_obj["tasa"]
261
+ iva = base * (tasa / 100)
262
+ total_con_iva = base + iva
263
+
264
+ return IVACalculationResult(
265
+ base=base,
266
+ tasa=tasa,
267
+ iva=iva,
268
+ total_con_iva=total_con_iva,
269
+ tipo_tasa=tipo_tasa,
270
+ )
271
+
272
+ @classmethod
273
+ def calcular_incluido(
274
+ cls,
275
+ total_con_iva: float,
276
+ tipo_tasa: IVATipoTasa = "general",
277
+ fecha: str | datetime | None = None,
278
+ ) -> IVACalculationResult:
279
+ """
280
+ Calculate IVA already included in a total amount
281
+ (Break down IVA when price includes IVA)
282
+
283
+ Args:
284
+ total_con_iva: Total amount that includes IVA
285
+ tipo_tasa: Rate type (general, frontera, tasa_cero)
286
+ fecha: Date to determine applicable rate (default: today)
287
+
288
+ Returns:
289
+ Complete calculation result with breakdown
290
+
291
+ Raises:
292
+ ValueError: If no applicable rate is found
293
+
294
+ Examples:
295
+ >>> result = IVACalculator.calcular_incluido(1160)
296
+ >>> print(f"Base: ${result['base']:.2f}, IVA: ${result['iva']:.2f}")
297
+ Base: $1000.00, IVA: $160.00
298
+ """
299
+ tasa_obj = cls.get_tasa_vigente(fecha, tipo_tasa)
300
+
301
+ if not tasa_obj:
302
+ raise ValueError("No se encontró tasa de IVA vigente")
303
+
304
+ tasa = tasa_obj["tasa"]
305
+ base = total_con_iva / (1 + tasa / 100)
306
+ iva = total_con_iva - base
307
+
308
+ return IVACalculationResult(
309
+ base=base,
310
+ tasa=tasa,
311
+ iva=iva,
312
+ total_con_iva=total_con_iva,
313
+ tipo_tasa=tipo_tasa,
314
+ )
315
+
316
+ @classmethod
317
+ def get_all_tasas(cls) -> list[IVATasa]:
318
+ """
319
+ Get all historical IVA rates
320
+
321
+ Returns:
322
+ List of all IVA rates (current and historical)
323
+
324
+ Examples:
325
+ >>> tasas = IVACalculator.get_all_tasas()
326
+ >>> print(f"Total tasas: {len(tasas)}")
327
+ Total tasas: 7
328
+ """
329
+ cls._load_data()
330
+ return cls._data["tasas"].copy()
331
+
332
+
333
+ class IEPSCalculator:
334
+ """
335
+ IEPS (Special Production and Services Tax) Calculator
336
+
337
+ Calculates IEPS for:
338
+ - Alcoholic beverages (ad valorem)
339
+ - Tobacco products (ad valorem + fixed quota)
340
+ - Fuels (fixed quota)
341
+ - Sugary drinks (fixed quota)
342
+ - High-calorie foods (ad valorem)
343
+ """
344
+
345
+ _data: list[IEPSCategoria] | None = None
346
+
347
+ @classmethod
348
+ def _load_data(cls) -> None:
349
+ """Lazy load IEPS data from JSON file"""
350
+ if cls._data is not None:
351
+ return
352
+
353
+ json_path = (
354
+ Path(__file__).parent.parent.parent.parent.parent
355
+ / "packages"
356
+ / "shared-data"
357
+ / "sat"
358
+ / "impuestos"
359
+ / "ieps_tasas.json"
360
+ )
361
+ with open(json_path, encoding="utf-8") as f:
362
+ cls._data = json.load(f)
363
+
364
+ @classmethod
365
+ def get_categoria(cls, categoria: str) -> IEPSCategoria | None:
366
+ """
367
+ Get an IEPS category by name
368
+
369
+ Args:
370
+ categoria: Category name (e.g., "bebidas_alcoholicas", "tabacos")
371
+
372
+ Returns:
373
+ IEPS category or None if not found
374
+
375
+ Examples:
376
+ >>> cat = IEPSCalculator.get_categoria("bebidas_alcoholicas")
377
+ >>> print(cat["descripcion"])
378
+ Bebidas con contenido alcohólico
379
+ """
380
+ cls._load_data()
381
+ return next((c for c in cls._data if c["categoria"] == categoria), None)
382
+
383
+ @classmethod
384
+ def calcular_ad_valorem(cls, base: float, tasa: float) -> IEPSCalculationResult:
385
+ """
386
+ Calculate IEPS ad-valorem (percentage on value)
387
+
388
+ Args:
389
+ base: Base value
390
+ tasa: IEPS rate as percentage
391
+
392
+ Returns:
393
+ Calculation result
394
+
395
+ Examples:
396
+ >>> result = IEPSCalculator.calcular_ad_valorem(1000, 26.5)
397
+ >>> print(f"IEPS: ${result['ieps']:.2f}")
398
+ IEPS: $265.00
399
+ """
400
+ ieps = base * (tasa / 100)
401
+
402
+ return IEPSCalculationResult(
403
+ base=base,
404
+ tasa=tasa,
405
+ ieps=ieps,
406
+ tipo_calculo="ad_valorem",
407
+ unidad=None,
408
+ cantidad=None,
409
+ )
410
+
411
+ @classmethod
412
+ def calcular_cuota_fija(
413
+ cls, cantidad: float, cuota_por_unidad: float, unidad: str = "litro"
414
+ ) -> IEPSCalculationResult:
415
+ """
416
+ Calculate IEPS by fixed quota (quantity * rate)
417
+
418
+ Args:
419
+ cantidad: Number of units
420
+ cuota_por_unidad: Fixed quota per unit
421
+ unidad: Unit of measure (default: "litro")
422
+
423
+ Returns:
424
+ Calculation result
425
+
426
+ Examples:
427
+ >>> result = IEPSCalculator.calcular_cuota_fija(100, 1.0, "litro")
428
+ >>> print(f"IEPS: ${result['ieps']:.2f}")
429
+ IEPS: $100.00
430
+ """
431
+ ieps = cantidad * cuota_por_unidad
432
+
433
+ return IEPSCalculationResult(
434
+ base=cantidad,
435
+ tasa=cuota_por_unidad,
436
+ ieps=ieps,
437
+ tipo_calculo="cuota_fija",
438
+ unidad=unidad,
439
+ cantidad=cantidad,
440
+ )
441
+
442
+ @classmethod
443
+ def calcular_bebidas_alcoholicas(
444
+ cls, valor: float, grados_alcohol: float
445
+ ) -> IEPSCalculationResult:
446
+ """
447
+ Calculate IEPS for alcoholic beverages based on alcohol content
448
+
449
+ Args:
450
+ valor: Product value
451
+ grados_alcohol: Alcohol content in degrees (GL)
452
+
453
+ Returns:
454
+ Calculation result
455
+
456
+ Examples:
457
+ >>> result = IEPSCalculator.calcular_bebidas_alcoholicas(1000, 5)
458
+ >>> print(f"IEPS cerveza: ${result['ieps']:.2f}")
459
+ IEPS cerveza: $265.00
460
+
461
+ >>> result = IEPSCalculator.calcular_bebidas_alcoholicas(1000, 40)
462
+ >>> print(f"IEPS licor: ${result['ieps']:.2f}")
463
+ IEPS licor: $530.00
464
+ """
465
+ if grados_alcohol <= 14:
466
+ tasa = 26.5 # Beer and drinks up to 14°
467
+ elif grados_alcohol <= 20:
468
+ tasa = 30.0 # Drinks from 14° to 20°
469
+ else:
470
+ tasa = 53.0 # Drinks over 20°
471
+
472
+ return cls.calcular_ad_valorem(valor, tasa)
473
+
474
+ @classmethod
475
+ def calcular_cigarros(cls, valor: float, numero_cigarros: int) -> IEPSCalculationResult:
476
+ """
477
+ Calculate IEPS for cigarettes (ad valorem + fixed quota)
478
+
479
+ Args:
480
+ valor: Sale value
481
+ numero_cigarros: Number of cigarettes
482
+
483
+ Returns:
484
+ Calculation result
485
+
486
+ Examples:
487
+ >>> result = IEPSCalculator.calcular_cigarros(100, 20)
488
+ >>> print(f"IEPS cigarros: ${result['ieps']:.2f}")
489
+ IEPS cigarros: $170.16
490
+ """
491
+ # IEPS = 160% of value + $0.5080 per cigarette
492
+ ieps_ad_valorem = valor * (160 / 100)
493
+ ieps_cuota_fija = numero_cigarros * 0.508
494
+ ieps_total = ieps_ad_valorem + ieps_cuota_fija
495
+
496
+ return IEPSCalculationResult(
497
+ base=valor,
498
+ tasa=160,
499
+ ieps=ieps_total,
500
+ tipo_calculo="ad_valorem",
501
+ unidad=None,
502
+ cantidad=float(numero_cigarros),
503
+ )
504
+
505
+ @classmethod
506
+ def get_all_categorias(cls) -> list[IEPSCategoria]:
507
+ """
508
+ Get all IEPS categories
509
+
510
+ Returns:
511
+ List of all IEPS categories
512
+
513
+ Examples:
514
+ >>> categorias = IEPSCalculator.get_all_categorias()
515
+ >>> print(f"Total categorías: {len(categorias)}")
516
+ Total categorías: 7
517
+ """
518
+ cls._load_data()
519
+ return cls._data.copy()
520
+
521
+
522
+ class RetencionCalculator:
523
+ """
524
+ Tax Withholding Calculator (ISR and IVA)
525
+
526
+ Calculates withholdings for:
527
+ - Professional fees (10% ISR, 2/3 IVA)
528
+ - Rent (10% ISR, 2/3 IVA)
529
+ - Freight (4% ISR, 100% IVA)
530
+ - Other concepts
531
+ """
532
+
533
+ _data: RetencionesData | None = None
534
+
535
+ @classmethod
536
+ def _load_data(cls) -> None:
537
+ """Lazy load retenciones data from JSON file"""
538
+ if cls._data is not None:
539
+ return
540
+
541
+ json_path = (
542
+ Path(__file__).parent.parent.parent.parent.parent
543
+ / "packages"
544
+ / "shared-data"
545
+ / "sat"
546
+ / "impuestos"
547
+ / "retenciones.json"
548
+ )
549
+ with open(json_path, encoding="utf-8") as f:
550
+ cls._data = json.load(f)
551
+
552
+ @classmethod
553
+ def get_retencion_isr(cls, concepto: str) -> RetencionISR | None:
554
+ """
555
+ Get ISR withholding information by concept
556
+
557
+ Args:
558
+ concepto: Withholding concept (e.g., "honorarios", "arrendamiento")
559
+
560
+ Returns:
561
+ ISR withholding info or None if not found
562
+
563
+ Examples:
564
+ >>> ret = RetencionCalculator.get_retencion_isr("honorarios")
565
+ >>> print(f"Tasa: {ret['tasa']}%")
566
+ Tasa: 10.0%
567
+ """
568
+ cls._load_data()
569
+ return next(
570
+ (r for r in cls._data["isr_retenciones"] if r["concepto"] == concepto),
571
+ None,
572
+ )
573
+
574
+ @classmethod
575
+ def get_retencion_iva(cls, concepto: str) -> RetencionIVA | None:
576
+ """
577
+ Get IVA withholding information by concept
578
+
579
+ Args:
580
+ concepto: Withholding concept
581
+
582
+ Returns:
583
+ IVA withholding info or None if not found
584
+
585
+ Examples:
586
+ >>> ret = RetencionCalculator.get_retencion_iva("servicios_profesionales")
587
+ >>> print(f"Tasa: {ret['tasa']}%")
588
+ Tasa: 66.67%
589
+ """
590
+ cls._load_data()
591
+ return next(
592
+ (r for r in cls._data["iva_retenciones"] if r["concepto"] == concepto),
593
+ None,
594
+ )
595
+
596
+ @classmethod
597
+ def calcular_retencion_isr(cls, base: float, concepto: str) -> RetencionCalculationResult:
598
+ """
599
+ Calculate ISR withholding
600
+
601
+ Args:
602
+ base: Base amount for withholding
603
+ concepto: Withholding concept
604
+
605
+ Returns:
606
+ Calculation result
607
+
608
+ Raises:
609
+ ValueError: If concept not found or rate is variable
610
+
611
+ Examples:
612
+ >>> result = RetencionCalculator.calcular_retencion_isr(10000, "honorarios")
613
+ >>> print(f"Retención: ${result['retencion']:.2f}")
614
+ Retención: $1000.00
615
+ """
616
+ retencion = cls.get_retencion_isr(concepto)
617
+
618
+ if not retencion:
619
+ raise ValueError(f"No se encontró retención ISR para concepto: {concepto}")
620
+
621
+ if isinstance(retencion["tasa"], str):
622
+ raise ValueError(f"La tasa de {concepto} es variable. Use método específico.")
623
+
624
+ monto_retencion = base * (retencion["tasa"] / 100)
625
+
626
+ return RetencionCalculationResult(
627
+ concepto=retencion["concepto"],
628
+ base=base,
629
+ tasa=retencion["tasa"],
630
+ retencion=monto_retencion,
631
+ impuesto_base=None,
632
+ )
633
+
634
+ @classmethod
635
+ def calcular_retencion_iva(
636
+ cls, iva_trasladado: float, concepto: str
637
+ ) -> RetencionCalculationResult:
638
+ """
639
+ Calculate IVA withholding (typically 2/3 parts)
640
+
641
+ Args:
642
+ iva_trasladado: IVA amount transferred
643
+ concepto: Withholding concept
644
+
645
+ Returns:
646
+ Calculation result
647
+
648
+ Raises:
649
+ ValueError: If concept not found
650
+
651
+ Examples:
652
+ >>> result = RetencionCalculator.calcular_retencion_iva(160, "servicios_profesionales")
653
+ >>> print(f"Retención IVA: ${result['retencion']:.2f}")
654
+ Retención IVA: $106.67
655
+ """
656
+ retencion = cls.get_retencion_iva(concepto)
657
+
658
+ if not retencion:
659
+ raise ValueError(f"No se encontró retención IVA para concepto: {concepto}")
660
+
661
+ monto_retencion = iva_trasladado * (retencion["tasa"] / 100)
662
+
663
+ return RetencionCalculationResult(
664
+ concepto=retencion["concepto"],
665
+ base=iva_trasladado,
666
+ tasa=retencion["tasa"],
667
+ retencion=monto_retencion,
668
+ impuesto_base=iva_trasladado,
669
+ )
670
+
671
+ @classmethod
672
+ def calcular_honorarios(cls, monto_sin_iva: float) -> RetencionCalculationResult:
673
+ """
674
+ Calculate withholding for professional fees (10% ISR)
675
+
676
+ Args:
677
+ monto_sin_iva: Fee amount without IVA
678
+
679
+ Returns:
680
+ Calculation result
681
+
682
+ Examples:
683
+ >>> result = RetencionCalculator.calcular_honorarios(10000)
684
+ >>> print(f"Retención honorarios: ${result['retencion']:.2f}")
685
+ Retención honorarios: $1000.00
686
+ """
687
+ return cls.calcular_retencion_isr(monto_sin_iva, "honorarios")
688
+
689
+ @classmethod
690
+ def calcular_arrendamiento(cls, monto_sin_iva: float) -> RetencionCalculationResult:
691
+ """
692
+ Calculate withholding for rent (10% ISR)
693
+
694
+ Args:
695
+ monto_sin_iva: Rent amount without IVA
696
+
697
+ Returns:
698
+ Calculation result
699
+
700
+ Examples:
701
+ >>> result = RetencionCalculator.calcular_arrendamiento(15000)
702
+ >>> print(f"Retención arrendamiento: ${result['retencion']:.2f}")
703
+ Retención arrendamiento: $1500.00
704
+ """
705
+ return cls.calcular_retencion_isr(monto_sin_iva, "arrendamiento")
706
+
707
+ @classmethod
708
+ def calcular_fletes(cls, monto_sin_iva: float) -> RetencionCalculationResult:
709
+ """
710
+ Calculate withholding for freight (4% ISR)
711
+
712
+ Args:
713
+ monto_sin_iva: Freight amount without IVA
714
+
715
+ Returns:
716
+ Calculation result
717
+
718
+ Examples:
719
+ >>> result = RetencionCalculator.calcular_fletes(10000)
720
+ >>> print(f"Retención fletes: ${result['retencion']:.2f}")
721
+ Retención fletes: $400.00
722
+ """
723
+ return cls.calcular_retencion_isr(monto_sin_iva, "fletes")
724
+
725
+ @classmethod
726
+ def get_all_retenciones_isr(cls) -> list[RetencionISR]:
727
+ """
728
+ Get all ISR withholding concepts
729
+
730
+ Returns:
731
+ List of all ISR withholdings
732
+
733
+ Examples:
734
+ >>> retenciones = RetencionCalculator.get_all_retenciones_isr()
735
+ >>> print(f"Total retenciones ISR: {len(retenciones)}")
736
+ Total retenciones ISR: 9
737
+ """
738
+ cls._load_data()
739
+ return cls._data["isr_retenciones"].copy()
740
+
741
+ @classmethod
742
+ def get_all_retenciones_iva(cls) -> list[RetencionIVA]:
743
+ """
744
+ Get all IVA withholding concepts
745
+
746
+ Returns:
747
+ List of all IVA withholdings
748
+
749
+ Examples:
750
+ >>> retenciones = RetencionCalculator.get_all_retenciones_iva()
751
+ >>> print(f"Total retenciones IVA: {len(retenciones)}")
752
+ Total retenciones IVA: 4
753
+ """
754
+ cls._load_data()
755
+ return cls._data["iva_retenciones"].copy()
756
+
757
+
758
+ class ImpuestosLocalesCalculator:
759
+ """
760
+ State and Municipal Tax Calculator
761
+
762
+ Calculates local taxes:
763
+ - Payroll tax (varies by state, 2-3%)
764
+ - Lodging tax (varies by state, 2-5%)
765
+ """
766
+
767
+ _data: ImpuestosLocalesData | None = None
768
+
769
+ @classmethod
770
+ def _load_data(cls) -> None:
771
+ """Lazy load impuestos locales data from JSON file"""
772
+ if cls._data is not None:
773
+ return
774
+
775
+ json_path = (
776
+ Path(__file__).parent.parent.parent.parent.parent
777
+ / "packages"
778
+ / "shared-data"
779
+ / "sat"
780
+ / "impuestos"
781
+ / "impuestos_locales.json"
782
+ )
783
+ with open(json_path, encoding="utf-8") as f:
784
+ cls._data = json.load(f)
785
+
786
+ @classmethod
787
+ def get_impuesto_nomina(cls, cve_estado: str) -> ImpuestoEstatal | None:
788
+ """
789
+ Get payroll tax rate for a state
790
+
791
+ Args:
792
+ cve_estado: State code (01-32)
793
+
794
+ Returns:
795
+ State tax info or None if not found
796
+
797
+ Examples:
798
+ >>> impuesto = ImpuestosLocalesCalculator.get_impuesto_nomina("09")
799
+ >>> print(f"{impuesto['estado']}: {impuesto['tasa']}%")
800
+ Ciudad de México: 3.0%
801
+ """
802
+ cls._load_data()
803
+ cve_padded = cve_estado.zfill(2)
804
+ return next(
805
+ (i for i in cls._data["impuesto_nomina"] if i["cve_estado"] == cve_padded),
806
+ None,
807
+ )
808
+
809
+ @classmethod
810
+ def calcular_impuesto_nomina(cls, total_percepciones: float, cve_estado: str) -> float:
811
+ """
812
+ Calculate payroll tax
813
+
814
+ Args:
815
+ total_percepciones: Total payroll for the period
816
+ cve_estado: State code
817
+
818
+ Returns:
819
+ Tax amount
820
+
821
+ Raises:
822
+ ValueError: If state code not found
823
+
824
+ Examples:
825
+ >>> impuesto = ImpuestosLocalesCalculator.calcular_impuesto_nomina(100000, "09")
826
+ >>> print(f"Impuesto sobre nómina CDMX: ${impuesto:.2f}")
827
+ Impuesto sobre nómina CDMX: $3000.00
828
+ """
829
+ impuesto = cls.get_impuesto_nomina(cve_estado)
830
+
831
+ if not impuesto:
832
+ raise ValueError(
833
+ f"No se encontró tasa de impuesto sobre nómina para estado: {cve_estado}"
834
+ )
835
+
836
+ return total_percepciones * (impuesto["tasa"] / 100)
837
+
838
+ @classmethod
839
+ def get_impuesto_hospedaje(cls, cve_estado: str) -> ImpuestoEstatal | None:
840
+ """
841
+ Get lodging tax rate for a state
842
+
843
+ Args:
844
+ cve_estado: State code (01-32)
845
+
846
+ Returns:
847
+ State tax info or None if not found
848
+
849
+ Examples:
850
+ >>> impuesto = ImpuestosLocalesCalculator.get_impuesto_hospedaje("23")
851
+ >>> print(f"{impuesto['estado']}: {impuesto['tasa']}%")
852
+ Quintana Roo: 3.0%
853
+ """
854
+ cls._load_data()
855
+ cve_padded = cve_estado.zfill(2)
856
+ return next(
857
+ (i for i in cls._data["impuesto_hospedaje"] if i["cve_estado"] == cve_padded),
858
+ None,
859
+ )
860
+
861
+ @classmethod
862
+ def calcular_impuesto_hospedaje(cls, monto_hospedaje: float, cve_estado: str) -> float:
863
+ """
864
+ Calculate lodging tax
865
+
866
+ Args:
867
+ monto_hospedaje: Lodging service amount
868
+ cve_estado: State code
869
+
870
+ Returns:
871
+ Tax amount
872
+
873
+ Raises:
874
+ ValueError: If state code not found
875
+
876
+ Examples:
877
+ >>> impuesto = ImpuestosLocalesCalculator.calcular_impuesto_hospedaje(5000, "23")
878
+ >>> print(f"Impuesto sobre hospedaje: ${impuesto:.2f}")
879
+ Impuesto sobre hospedaje: $150.00
880
+ """
881
+ impuesto = cls.get_impuesto_hospedaje(cve_estado)
882
+
883
+ if not impuesto:
884
+ raise ValueError(
885
+ f"No se encontró tasa de impuesto sobre hospedaje para estado: {cve_estado}"
886
+ )
887
+
888
+ return monto_hospedaje * (impuesto["tasa"] / 100)
889
+
890
+ @classmethod
891
+ def get_all_impuestos_nomina(cls) -> list[ImpuestoEstatal]:
892
+ """
893
+ Get all payroll tax rates
894
+
895
+ Returns:
896
+ List of all state payroll taxes
897
+
898
+ Examples:
899
+ >>> impuestos = ImpuestosLocalesCalculator.get_all_impuestos_nomina()
900
+ >>> print(f"Total estados: {len(impuestos)}")
901
+ Total estados: 32
902
+ """
903
+ cls._load_data()
904
+ return cls._data["impuesto_nomina"].copy()
905
+
906
+ @classmethod
907
+ def get_all_impuestos_hospedaje(cls) -> list[ImpuestoEstatal]:
908
+ """
909
+ Get all lodging tax rates
910
+
911
+ Returns:
912
+ List of all state lodging taxes
913
+
914
+ Examples:
915
+ >>> impuestos = ImpuestosLocalesCalculator.get_all_impuestos_hospedaje()
916
+ >>> print(f"Total estados: {len(impuestos)}")
917
+ Total estados: 32
918
+ """
919
+ cls._load_data()
920
+ return cls._data["impuesto_hospedaje"].copy()