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,370 @@
1
+ """
2
+ IMSS (Instituto Mexicano del Seguro Social) Calculator for Mexico
3
+ Calculates employer/employee contributions and voluntary modalities (10, 40)
4
+ Uses centralized JSON tables from shared-data
5
+
6
+ Official sources:
7
+ - Ley del Seguro Social
8
+ - Sistema Único de Autodeterminación (SUA)
9
+ """
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Literal, TypedDict
14
+
15
+ IMSSYear = Literal[2024, 2025, 2026]
16
+ ZonaSalario = Literal["general", "frontera"]
17
+ ClaseRiesgo = Literal[1, 2, 3, 4, 5]
18
+
19
+
20
+ class CuotasIMSSResult(TypedDict):
21
+ """IMSS contributions breakdown result"""
22
+
23
+ salario_diario: float
24
+ dias: int
25
+ salario_base_cotizacion: float
26
+ year: int
27
+ cuotas_patron: dict[str, float]
28
+ cuotas_trabajador: dict[str, float]
29
+ total_patron: float
30
+ total_trabajador: float
31
+ total_imss: float
32
+
33
+
34
+ class Modalidad40Result(TypedDict):
35
+ """Modalidad 40 calculation result"""
36
+
37
+ salario_base_cotizacion: float
38
+ year: int
39
+ cuota_mensual: float
40
+ porcentaje_total: float
41
+ componentes: dict[str, float]
42
+
43
+
44
+ class Modalidad10Result(TypedDict):
45
+ """Modalidad 10 calculation result"""
46
+
47
+ salario_base_cotizacion: float
48
+ year: int
49
+ cuota_mensual: float
50
+ cuota_fija_uma: float
51
+ cuota_variable: float
52
+ porcentaje_variable: float
53
+ componentes: dict[str, float]
54
+
55
+
56
+ class UMAInfo(TypedDict):
57
+ """UMA (Unidad de Medida y Actualización) information"""
58
+
59
+ diaria: float
60
+ mensual: float
61
+ anual: float
62
+
63
+
64
+ # Load IMSS tables from centralized JSON
65
+ _IMSS_TABLES: dict | None = None
66
+ _IMSS_CATALOGS: dict | None = None
67
+
68
+
69
+ def _load_imss_tables() -> dict:
70
+ """Load IMSS tables from shared JSON file"""
71
+ global _IMSS_TABLES
72
+ if _IMSS_TABLES is None:
73
+ json_path = (
74
+ Path(__file__).parent.parent.parent.parent.parent
75
+ / "packages"
76
+ / "shared-data"
77
+ / "imss-tables.json"
78
+ )
79
+ with open(json_path, encoding="utf-8") as f:
80
+ _IMSS_TABLES = json.load(f)
81
+ return _IMSS_TABLES
82
+
83
+
84
+ def _load_imss_catalogs() -> dict:
85
+ """Load IMSS catalogs from shared JSON file"""
86
+ global _IMSS_CATALOGS
87
+ if _IMSS_CATALOGS is None:
88
+ json_path = (
89
+ Path(__file__).parent.parent.parent.parent.parent
90
+ / "packages"
91
+ / "shared-data"
92
+ / "imss-catalogs.json"
93
+ )
94
+ with open(json_path, encoding="utf-8") as f:
95
+ _IMSS_CATALOGS = json.load(f)
96
+ return _IMSS_CATALOGS
97
+
98
+
99
+ def get_uma(year: IMSSYear) -> UMAInfo:
100
+ """
101
+ Get UMA (Unidad de Medida y Actualización) values for a specific year
102
+
103
+ Args:
104
+ year: Year (2024, 2025, or 2026)
105
+
106
+ Returns:
107
+ UMA values (diaria, mensual, anual)
108
+
109
+ Examples:
110
+ >>> uma = get_uma(2026)
111
+ >>> print(f"UMA diaria 2026: ${uma['diaria']}")
112
+ UMA diaria 2026: $113.14
113
+ """
114
+ tables = _load_imss_tables()
115
+ year_str = str(year)
116
+ uma_data = tables["uma"][year_str]
117
+ return UMAInfo(
118
+ diaria=uma_data["diaria"],
119
+ mensual=uma_data["mensual"],
120
+ anual=uma_data["anual"],
121
+ )
122
+
123
+
124
+ def get_salario_minimo(year: IMSSYear, zona: ZonaSalario = "general") -> float:
125
+ """
126
+ Get minimum wage for a specific year and zone
127
+
128
+ Args:
129
+ year: Year (2024, 2025, or 2026)
130
+ zona: Zone type ("general" or "frontera")
131
+
132
+ Returns:
133
+ Daily minimum wage amount
134
+
135
+ Examples:
136
+ >>> salario = get_salario_minimo(2026, "general")
137
+ >>> print(f"Salario mínimo general 2026: ${salario}")
138
+ Salario mínimo general 2026: $278.80
139
+ """
140
+ tables = _load_imss_tables()
141
+ year_str = str(year)
142
+ return tables["salario_minimo"][year_str][zona]
143
+
144
+
145
+ def calcular_cuotas_obrero_patronales(
146
+ salario_diario: float,
147
+ dias: int = 30,
148
+ year: IMSSYear = 2026,
149
+ clase_riesgo: ClaseRiesgo = 1,
150
+ ) -> CuotasIMSSResult:
151
+ """
152
+ Calculate IMSS employer and employee contributions
153
+
154
+ Args:
155
+ salario_diario: Daily wage
156
+ dias: Number of days (default: 30)
157
+ year: Year (default: 2026)
158
+ clase_riesgo: Work risk class 1-5 (default: 1 - minimum risk)
159
+
160
+ Returns:
161
+ Complete breakdown of IMSS contributions
162
+
163
+ Examples:
164
+ >>> result = calcular_cuotas_obrero_patronales(500.0, 30, 2026)
165
+ >>> print(f"Total patrón: ${result['total_patron']:.2f}")
166
+ >>> print(f"Total trabajador: ${result['total_trabajador']:.2f}")
167
+ """
168
+ tables = _load_imss_tables()
169
+ uma = get_uma(year)
170
+ cuotas = tables["cuotas_imss"]
171
+
172
+ # Calculate base salary for contributions
173
+ salario_base = salario_diario * dias
174
+
175
+ # Límite de 3 UMAs diario para algunas cuotas
176
+ uma_diaria = uma["diaria"]
177
+ tres_uma_mensual = uma_diaria * 3 * 30
178
+
179
+ # Initialize contribution dictionaries
180
+ cuotas_patron: dict[str, float] = {}
181
+ cuotas_trabajador: dict[str, float] = {}
182
+
183
+ # 1. Enfermedad y Maternidad
184
+ em = cuotas["enfermedad_maternidad"]
185
+
186
+ # Cuota fija (sobre 3 UMAs)
187
+ cuotas_patron["enfermedad_mat_cuota_fija"] = (
188
+ uma_diaria * 3 * dias * em["prestaciones_en_especie"]["patron"]
189
+ )
190
+
191
+ # Excedente de 3 UMAs
192
+ if salario_diario > tres_uma_mensual / 30:
193
+ excedente_base = salario_base - tres_uma_mensual
194
+ cuotas_patron["enfermedad_mat_excedente"] = (
195
+ excedente_base * em["prestaciones_en_especie_excedente"]["patron"]
196
+ )
197
+ cuotas_trabajador["enfermedad_mat_excedente"] = (
198
+ excedente_base * em["prestaciones_en_especie_excedente"]["trabajador"]
199
+ )
200
+ else:
201
+ cuotas_patron["enfermedad_mat_excedente"] = 0.0
202
+ cuotas_trabajador["enfermedad_mat_excedente"] = 0.0
203
+
204
+ # Prestaciones en dinero
205
+ cuotas_patron["enfermedad_mat_dinero"] = salario_base * em["prestaciones_en_dinero"]["patron"]
206
+ cuotas_trabajador["enfermedad_mat_dinero"] = (
207
+ salario_base * em["prestaciones_en_dinero"]["trabajador"]
208
+ )
209
+
210
+ # Gastos médicos pensionados
211
+ cuotas_patron["gastos_medicos_pensionados"] = (
212
+ salario_base * em["gastos_medicos_pensionados"]["patron"]
213
+ )
214
+ cuotas_trabajador["gastos_medicos_pensionados"] = (
215
+ salario_base * em["gastos_medicos_pensionados"]["trabajador"]
216
+ )
217
+
218
+ # 2. Invalidez y Vida
219
+ iv = cuotas["invalidez_vida"]
220
+ cuotas_patron["invalidez_vida"] = salario_base * iv["patron"]
221
+ cuotas_trabajador["invalidez_vida"] = salario_base * iv["trabajador"]
222
+
223
+ # 3. Retiro, Cesantía y Vejez
224
+ rcv = cuotas["retiro_cesantia_vejez"]
225
+ cuotas_patron["retiro"] = salario_base * rcv["retiro"]["patron"]
226
+ cuotas_patron["cesantia_vejez"] = salario_base * rcv["cesantia_vejez"]["patron"]
227
+ cuotas_trabajador["cesantia_vejez"] = salario_base * rcv["cesantia_vejez"]["trabajador"]
228
+
229
+ # 4. Guarderías y Prestaciones Sociales
230
+ gps = cuotas["guarderias_prestaciones_sociales"]
231
+ cuotas_patron["guarderias"] = salario_base * gps["patron"]
232
+
233
+ # 5. Riesgos de Trabajo
234
+ rt = cuotas["riesgo_trabajo"]
235
+ prima_riesgo = rt[f"clase_{clase_riesgo}"]
236
+ cuotas_patron["riesgo_trabajo"] = salario_base * prima_riesgo
237
+
238
+ # Calculate totals
239
+ total_patron = sum(cuotas_patron.values())
240
+ total_trabajador = sum(cuotas_trabajador.values())
241
+
242
+ return CuotasIMSSResult(
243
+ salario_diario=salario_diario,
244
+ dias=dias,
245
+ salario_base_cotizacion=salario_base,
246
+ year=year,
247
+ cuotas_patron=cuotas_patron,
248
+ cuotas_trabajador=cuotas_trabajador,
249
+ total_patron=total_patron,
250
+ total_trabajador=total_trabajador,
251
+ total_imss=total_patron + total_trabajador,
252
+ )
253
+
254
+
255
+ def calcular_modalidad_40(
256
+ salario_base_cotizacion: float, year: IMSSYear = 2026
257
+ ) -> Modalidad40Result:
258
+ """
259
+ Calculate Modalidad 40 voluntary IMSS contributions
260
+ (Continuación voluntaria en el régimen obligatorio)
261
+
262
+ Modalidad 40 allows workers who left formal employment to continue
263
+ contributing to IMSS to increase their pension amount.
264
+
265
+ Args:
266
+ salario_base_cotizacion: Monthly base salary (between 1 and 25 UMAs)
267
+ year: Year (default: 2026)
268
+
269
+ Returns:
270
+ Modalidad 40 calculation result
271
+
272
+ Examples:
273
+ >>> result = calcular_modalidad_40(15000, 2026)
274
+ >>> print(f"Cuota mensual: ${result['cuota_mensual']:.2f}")
275
+ """
276
+ tables = _load_imss_tables()
277
+ uma = get_uma(year)
278
+ mod40 = tables["modalidad_40"]
279
+
280
+ # Validate salary limits
281
+ uma_mensual = uma["mensual"]
282
+ salario_minimo = uma_mensual * mod40["limites_salario"]["minimo_uma"]
283
+ salario_maximo = uma_mensual * mod40["limites_salario"]["maximo_uma"]
284
+
285
+ if salario_base_cotizacion < salario_minimo:
286
+ salario_base_cotizacion = salario_minimo
287
+ elif salario_base_cotizacion > salario_maximo:
288
+ salario_base_cotizacion = salario_maximo
289
+
290
+ # Calculate monthly contribution (10.47% total)
291
+ porcentaje_total = mod40["cuota_mensual"]["porcentaje_total"]
292
+ cuota_mensual = salario_base_cotizacion * porcentaje_total
293
+
294
+ # Get component breakdown
295
+ componentes = {
296
+ key: salario_base_cotizacion * value
297
+ for key, value in mod40["cuota_mensual"]["componentes"].items()
298
+ }
299
+
300
+ return Modalidad40Result(
301
+ salario_base_cotizacion=salario_base_cotizacion,
302
+ year=year,
303
+ cuota_mensual=cuota_mensual,
304
+ porcentaje_total=porcentaje_total,
305
+ componentes=componentes,
306
+ )
307
+
308
+
309
+ def calcular_modalidad_10(
310
+ salario_base_cotizacion: float, year: IMSSYear = 2026
311
+ ) -> Modalidad10Result:
312
+ """
313
+ Calculate Modalidad 10 voluntary IMSS contributions
314
+ (Incorporación voluntaria al régimen obligatorio - trabajadores independientes)
315
+
316
+ Modalidad 10 allows independent workers to enroll in IMSS and access
317
+ all social security benefits including healthcare, retirement, and more.
318
+
319
+ Args:
320
+ salario_base_cotizacion: Monthly base salary (between 1 and 25 UMAs)
321
+ year: Year (default: 2026)
322
+
323
+ Returns:
324
+ Modalidad 10 calculation result
325
+
326
+ Examples:
327
+ >>> result = calcular_modalidad_10(10000, 2026)
328
+ >>> print(f"Cuota mensual: ${result['cuota_mensual']:.2f}")
329
+ """
330
+ tables = _load_imss_tables()
331
+ uma = get_uma(year)
332
+ mod10 = tables["modalidad_10"]
333
+
334
+ # Validate salary limits
335
+ uma_mensual = uma["mensual"]
336
+ salario_minimo = uma_mensual * mod10["limites_salario"]["minimo_uma"]
337
+ salario_maximo = uma_mensual * mod10["limites_salario"]["maximo_uma"]
338
+
339
+ if salario_base_cotizacion < salario_minimo:
340
+ salario_base_cotizacion = salario_minimo
341
+ elif salario_base_cotizacion > salario_maximo:
342
+ salario_base_cotizacion = salario_maximo
343
+
344
+ # Fixed fee: 3.3 UMAs for healthcare
345
+ cuota_fija_uma = uma["diaria"] * mod10["cuota_mensual"]["cuota_fija_uma_factor"]
346
+
347
+ # Variable percentage: 10.47%
348
+ porcentaje_variable = mod10["cuota_mensual"]["porcentaje_variable"]
349
+ cuota_variable = salario_base_cotizacion * porcentaje_variable
350
+
351
+ # Total monthly contribution
352
+ cuota_mensual = cuota_fija_uma + cuota_variable
353
+
354
+ # Get component breakdown
355
+ componentes = {
356
+ "prestaciones_en_especie_fija": cuota_fija_uma,
357
+ }
358
+ for key, value in mod10["cuota_mensual"]["componentes"].items():
359
+ if isinstance(value, (int, float)):
360
+ componentes[key] = salario_base_cotizacion * value
361
+
362
+ return Modalidad10Result(
363
+ salario_base_cotizacion=salario_base_cotizacion,
364
+ year=year,
365
+ cuota_mensual=cuota_mensual,
366
+ cuota_fija_uma=cuota_fija_uma,
367
+ cuota_variable=cuota_variable,
368
+ porcentaje_variable=porcentaje_variable,
369
+ componentes=componentes,
370
+ )
@@ -0,0 +1,290 @@
1
+ """
2
+ ISR (Impuesto Sobre la Renta) Calculator for Mexico
3
+ Supports multi-year calculations: 2024, 2025, 2026
4
+ Uses centralized JSON tables from shared-data
5
+
6
+ Official source: Anexo 8 RMF (Resolución Miscelánea Fiscal)
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Literal, TypedDict
12
+
13
+ ISRYear = Literal[2024, 2025, 2026]
14
+ ISRPeriod = Literal["diaria", "semanal", "decenal", "quincenal", "mensual", "anual"]
15
+
16
+
17
+ class ISRBracket(TypedDict):
18
+ """Tax bracket structure"""
19
+
20
+ limiteInferior: float
21
+ limiteSuperior: float | None
22
+ cuotaFija: float
23
+ tasa: float
24
+
25
+
26
+ class ISRSubsidyBracket(TypedDict):
27
+ """Subsidy bracket for tiered system (2024)"""
28
+
29
+ desde: float
30
+ hasta: float | None
31
+ subsidio: float
32
+
33
+
34
+ class ISRSubsidyFlat(TypedDict):
35
+ """Flat subsidy system (2025/2026)"""
36
+
37
+ amount: float
38
+ maxIncome: float
39
+
40
+
41
+ class ISRCalculationResult(TypedDict):
42
+ """Complete ISR calculation result"""
43
+
44
+ ingresoGravable: float
45
+ periodo: str
46
+ year: int
47
+ ingresoMensualizado: float
48
+ bracket: dict
49
+ excedente: float
50
+ impuestoMarginal: float
51
+ isrAntesSubsidio: float
52
+ subsidio: float
53
+ isrFinal: float
54
+ tasaEfectiva: float
55
+
56
+
57
+ # Load ISR tables from centralized JSON
58
+ _ISR_TABLES: dict | None = None
59
+
60
+
61
+ def _load_isr_tables() -> dict:
62
+ """Load ISR tables from shared JSON file"""
63
+ global _ISR_TABLES
64
+ if _ISR_TABLES is None:
65
+ # Path from catalogmx/calculators/isr.py -> packages/shared-data/isr-tables.json
66
+ # Go up: isr.py -> calculators -> catalogmx -> python -> packages -> catalogmx (root)
67
+ json_path = (
68
+ Path(__file__).parent.parent.parent.parent.parent
69
+ / "packages"
70
+ / "shared-data"
71
+ / "isr-tables.json"
72
+ )
73
+ with open(json_path, encoding="utf-8") as f:
74
+ _ISR_TABLES = json.load(f)
75
+ return _ISR_TABLES
76
+
77
+
78
+ # Period mapping
79
+ PERIOD_MAPPING = {
80
+ "diaria": "daily",
81
+ "semanal": "weekly",
82
+ "decenal": "monthly", # No specific decenal table, use monthly
83
+ "quincenal": "biweekly",
84
+ "mensual": "monthly",
85
+ "anual": "annual",
86
+ }
87
+
88
+ # Period factors for conversion
89
+ PERIOD_FACTORS = {
90
+ "diaria": 30.4,
91
+ "semanal": 4.33,
92
+ "decenal": 3.0,
93
+ "quincenal": 2.0,
94
+ "mensual": 1.0,
95
+ "anual": 1 / 12,
96
+ }
97
+
98
+
99
+ def get_isr_brackets(year: ISRYear, period: ISRPeriod) -> list[ISRBracket]:
100
+ """
101
+ Get ISR tax brackets for a specific year and period
102
+
103
+ Args:
104
+ year: Tax year (2024, 2025, or 2026)
105
+ period: Payment period
106
+
107
+ Returns:
108
+ List of tax brackets
109
+ """
110
+ tables = _load_isr_tables()
111
+ year_str = str(year)
112
+ period_key = PERIOD_MAPPING[period]
113
+
114
+ brackets_data = tables["brackets"][year_str][period_key]
115
+
116
+ # Convert None to float('inf') for limiteSuperior
117
+ brackets = []
118
+ for b in brackets_data:
119
+ bracket = ISRBracket(
120
+ limiteInferior=b["limiteInferior"],
121
+ limiteSuperior=float("inf") if b["limiteSuperior"] is None else b["limiteSuperior"],
122
+ cuotaFija=b["cuotaFija"],
123
+ tasa=b["tasa"],
124
+ )
125
+ brackets.append(bracket)
126
+
127
+ return brackets
128
+
129
+
130
+ def calculate_subsidy(income: float, year: ISRYear) -> float:
131
+ """
132
+ Calculate employment subsidy (subsidio al empleo) for a given income
133
+
134
+ Args:
135
+ income: Monthly income amount
136
+ year: Tax year
137
+
138
+ Returns:
139
+ Subsidy amount
140
+ """
141
+ tables = _load_isr_tables()
142
+ year_str = str(year)
143
+ subsidy_data = tables["subsidies"][year_str]
144
+
145
+ if subsidy_data["type"] == "tiered":
146
+ # 2024: tiered system
147
+ monthly_data = subsidy_data["monthly"]
148
+ for s in monthly_data:
149
+ hasta = float("inf") if s["hasta"] is None else s["hasta"]
150
+ if s["desde"] <= income <= hasta:
151
+ return s["subsidio"]
152
+ return 0.0
153
+ else:
154
+ # 2025/2026: flat system
155
+ flat_data = subsidy_data["monthly"]
156
+ if income <= flat_data["maxIncome"]:
157
+ return flat_data["amount"]
158
+ return 0.0
159
+
160
+
161
+ def calculate_isr(
162
+ ingreso_gravable: float, periodo: ISRPeriod = "mensual", year: ISRYear = 2026
163
+ ) -> ISRCalculationResult:
164
+ """
165
+ Calculate ISR (Income Tax) for Mexico
166
+
167
+ Args:
168
+ ingreso_gravable: Taxable income for the period
169
+ periodo: Payment period (default: mensual)
170
+ year: Tax year (default: 2026)
171
+
172
+ Returns:
173
+ Complete calculation result with breakdown
174
+
175
+ Examples:
176
+ >>> result = calculate_isr(15000, "mensual", 2026)
177
+ >>> print(f"ISR: ${result['isrFinal']:.2f}")
178
+ ISR: $1234.56
179
+
180
+ >>> result = calculate_isr(500, "diaria", 2026)
181
+ >>> print(f"ISR diario: ${result['isrFinal']:.2f}")
182
+ ISR diario: $45.67
183
+ """
184
+ # For 2026, use period-specific tables directly
185
+ use_period_tables = year == 2026 and periodo in [
186
+ "diaria",
187
+ "semanal",
188
+ "quincenal",
189
+ "mensual",
190
+ "anual",
191
+ ]
192
+
193
+ if use_period_tables:
194
+ # Use official tables for the period (no conversion needed)
195
+ brackets = get_isr_brackets(year, periodo)
196
+
197
+ # Find applicable bracket
198
+ bracket = None
199
+ for b in brackets:
200
+ if b["limiteInferior"] <= ingreso_gravable <= b["limiteSuperior"]:
201
+ bracket = b
202
+ break
203
+
204
+ if bracket is None:
205
+ bracket = brackets[-1] # Last bracket (highest income)
206
+
207
+ # Calculate excess over lower limit
208
+ excedente = ingreso_gravable - bracket["limiteInferior"]
209
+
210
+ # Calculate marginal tax
211
+ impuesto_marginal = excedente * (bracket["tasa"] / 100)
212
+
213
+ # Add fixed fee
214
+ isr_antes_subsidio = bracket["cuotaFija"] + impuesto_marginal
215
+
216
+ # Calculate subsidy (convert to monthly equivalent)
217
+ factor = PERIOD_FACTORS[periodo]
218
+ ingreso_mensual_equivalente = ingreso_gravable * factor
219
+ subsidio_mensual = calculate_subsidy(ingreso_mensual_equivalente, year)
220
+ subsidio_prorrateado = subsidio_mensual / factor
221
+
222
+ # Final ISR
223
+ isr_final = max(0, isr_antes_subsidio - subsidio_prorrateado)
224
+
225
+ tasa_efectiva = (isr_final / ingreso_gravable * 100) if ingreso_gravable > 0 else 0
226
+
227
+ return ISRCalculationResult(
228
+ ingresoGravable=ingreso_gravable,
229
+ periodo=periodo,
230
+ year=year,
231
+ ingresoMensualizado=ingreso_mensual_equivalente,
232
+ bracket=dict(bracket),
233
+ excedente=excedente,
234
+ impuestoMarginal=impuesto_marginal,
235
+ isrAntesSubsidio=isr_antes_subsidio,
236
+ subsidio=subsidio_prorrateado,
237
+ isrFinal=isr_final,
238
+ tasaEfectiva=tasa_efectiva,
239
+ )
240
+
241
+ # For 2024/2025 or decenal period, use factor-based conversion
242
+ brackets = get_isr_brackets(year, "mensual")
243
+
244
+ # Convert to monthly
245
+ factor = PERIOD_FACTORS[periodo]
246
+ ingreso_mensualizado = ingreso_gravable * factor
247
+
248
+ # Find applicable bracket
249
+ bracket = None
250
+ for b in brackets:
251
+ if b["limiteInferior"] <= ingreso_mensualizado <= b["limiteSuperior"]:
252
+ bracket = b
253
+ break
254
+
255
+ if bracket is None:
256
+ bracket = brackets[-1]
257
+
258
+ # Calculate excess over lower limit
259
+ excedente = ingreso_mensualizado - bracket["limiteInferior"]
260
+
261
+ # Calculate marginal tax
262
+ impuesto_marginal = excedente * (bracket["tasa"] / 100)
263
+
264
+ # Add fixed fee
265
+ isr_antes_subsidio = bracket["cuotaFija"] + impuesto_marginal
266
+
267
+ # Calculate subsidy
268
+ subsidio = calculate_subsidy(ingreso_mensualizado, year)
269
+
270
+ # Final ISR (monthly)
271
+ isr_final_mensual = max(0, isr_antes_subsidio - subsidio)
272
+
273
+ # Convert back to original period
274
+ isr_periodo = isr_final_mensual / factor
275
+
276
+ tasa_efectiva = (isr_periodo / ingreso_gravable * 100) if ingreso_gravable > 0 else 0
277
+
278
+ return ISRCalculationResult(
279
+ ingresoGravable=ingreso_gravable,
280
+ periodo=periodo,
281
+ year=year,
282
+ ingresoMensualizado=ingreso_mensualizado,
283
+ bracket=dict(bracket),
284
+ excedente=excedente,
285
+ impuestoMarginal=impuesto_marginal,
286
+ isrAntesSubsidio=isr_antes_subsidio,
287
+ subsidio=subsidio,
288
+ isrFinal=isr_periodo,
289
+ tasaEfectiva=tasa_efectiva,
290
+ )