coati-payroll 0.0.2__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.

Potentially problematic release.


This version of coati-payroll might be problematic. Click here for more details.

Files changed (243) hide show
  1. coati_payroll/__init__.py +415 -0
  2. coati_payroll/app.py +95 -0
  3. coati_payroll/audit_helpers.py +904 -0
  4. coati_payroll/auth.py +123 -0
  5. coati_payroll/cli.py +1318 -0
  6. coati_payroll/config.py +219 -0
  7. coati_payroll/demo_data.py +813 -0
  8. coati_payroll/enums.py +278 -0
  9. coati_payroll/forms.py +1769 -0
  10. coati_payroll/formula_engine/__init__.py +81 -0
  11. coati_payroll/formula_engine/ast/__init__.py +110 -0
  12. coati_payroll/formula_engine/ast/ast_visitor.py +259 -0
  13. coati_payroll/formula_engine/ast/expression_evaluator.py +228 -0
  14. coati_payroll/formula_engine/ast/safe_operators.py +131 -0
  15. coati_payroll/formula_engine/ast/type_converter.py +172 -0
  16. coati_payroll/formula_engine/data_sources.py +752 -0
  17. coati_payroll/formula_engine/engine.py +247 -0
  18. coati_payroll/formula_engine/exceptions.py +52 -0
  19. coati_payroll/formula_engine/execution/__init__.py +24 -0
  20. coati_payroll/formula_engine/execution/execution_context.py +52 -0
  21. coati_payroll/formula_engine/execution/step_executor.py +62 -0
  22. coati_payroll/formula_engine/execution/variable_store.py +59 -0
  23. coati_payroll/formula_engine/novelty_codes.py +206 -0
  24. coati_payroll/formula_engine/results/__init__.py +20 -0
  25. coati_payroll/formula_engine/results/execution_result.py +59 -0
  26. coati_payroll/formula_engine/steps/__init__.py +30 -0
  27. coati_payroll/formula_engine/steps/assignment_step.py +71 -0
  28. coati_payroll/formula_engine/steps/base_step.py +48 -0
  29. coati_payroll/formula_engine/steps/calculation_step.py +42 -0
  30. coati_payroll/formula_engine/steps/conditional_step.py +122 -0
  31. coati_payroll/formula_engine/steps/step_factory.py +58 -0
  32. coati_payroll/formula_engine/steps/tax_lookup_step.py +45 -0
  33. coati_payroll/formula_engine/tables/__init__.py +24 -0
  34. coati_payroll/formula_engine/tables/bracket_calculator.py +51 -0
  35. coati_payroll/formula_engine/tables/table_lookup.py +161 -0
  36. coati_payroll/formula_engine/tables/tax_table.py +32 -0
  37. coati_payroll/formula_engine/validation/__init__.py +24 -0
  38. coati_payroll/formula_engine/validation/schema_validator.py +37 -0
  39. coati_payroll/formula_engine/validation/security_validator.py +52 -0
  40. coati_payroll/formula_engine/validation/tax_table_validator.py +205 -0
  41. coati_payroll/formula_engine_examples.py +153 -0
  42. coati_payroll/i18n.py +54 -0
  43. coati_payroll/initial_data.py +613 -0
  44. coati_payroll/interes_engine.py +450 -0
  45. coati_payroll/liquidacion_engine/__init__.py +25 -0
  46. coati_payroll/liquidacion_engine/engine.py +267 -0
  47. coati_payroll/locale_config.py +165 -0
  48. coati_payroll/log.py +138 -0
  49. coati_payroll/model.py +2410 -0
  50. coati_payroll/nomina_engine/__init__.py +87 -0
  51. coati_payroll/nomina_engine/calculators/__init__.py +30 -0
  52. coati_payroll/nomina_engine/calculators/benefit_calculator.py +79 -0
  53. coati_payroll/nomina_engine/calculators/concept_calculator.py +254 -0
  54. coati_payroll/nomina_engine/calculators/deduction_calculator.py +105 -0
  55. coati_payroll/nomina_engine/calculators/exchange_rate_calculator.py +51 -0
  56. coati_payroll/nomina_engine/calculators/perception_calculator.py +75 -0
  57. coati_payroll/nomina_engine/calculators/salary_calculator.py +86 -0
  58. coati_payroll/nomina_engine/domain/__init__.py +27 -0
  59. coati_payroll/nomina_engine/domain/calculation_items.py +52 -0
  60. coati_payroll/nomina_engine/domain/employee_calculation.py +53 -0
  61. coati_payroll/nomina_engine/domain/payroll_context.py +44 -0
  62. coati_payroll/nomina_engine/engine.py +188 -0
  63. coati_payroll/nomina_engine/processors/__init__.py +28 -0
  64. coati_payroll/nomina_engine/processors/accounting_processor.py +171 -0
  65. coati_payroll/nomina_engine/processors/accumulation_processor.py +90 -0
  66. coati_payroll/nomina_engine/processors/loan_processor.py +227 -0
  67. coati_payroll/nomina_engine/processors/novelty_processor.py +42 -0
  68. coati_payroll/nomina_engine/processors/vacation_processor.py +67 -0
  69. coati_payroll/nomina_engine/repositories/__init__.py +32 -0
  70. coati_payroll/nomina_engine/repositories/acumulado_repository.py +83 -0
  71. coati_payroll/nomina_engine/repositories/base_repository.py +40 -0
  72. coati_payroll/nomina_engine/repositories/config_repository.py +102 -0
  73. coati_payroll/nomina_engine/repositories/employee_repository.py +34 -0
  74. coati_payroll/nomina_engine/repositories/exchange_rate_repository.py +58 -0
  75. coati_payroll/nomina_engine/repositories/novelty_repository.py +54 -0
  76. coati_payroll/nomina_engine/repositories/planilla_repository.py +52 -0
  77. coati_payroll/nomina_engine/results/__init__.py +24 -0
  78. coati_payroll/nomina_engine/results/error_result.py +28 -0
  79. coati_payroll/nomina_engine/results/payroll_result.py +53 -0
  80. coati_payroll/nomina_engine/results/validation_result.py +39 -0
  81. coati_payroll/nomina_engine/services/__init__.py +22 -0
  82. coati_payroll/nomina_engine/services/accounting_voucher_service.py +708 -0
  83. coati_payroll/nomina_engine/services/employee_processing_service.py +173 -0
  84. coati_payroll/nomina_engine/services/payroll_execution_service.py +374 -0
  85. coati_payroll/nomina_engine/services/snapshot_service.py +295 -0
  86. coati_payroll/nomina_engine/validators/__init__.py +31 -0
  87. coati_payroll/nomina_engine/validators/base_validator.py +48 -0
  88. coati_payroll/nomina_engine/validators/currency_validator.py +50 -0
  89. coati_payroll/nomina_engine/validators/employee_validator.py +87 -0
  90. coati_payroll/nomina_engine/validators/period_validator.py +44 -0
  91. coati_payroll/nomina_engine/validators/planilla_validator.py +136 -0
  92. coati_payroll/plugin_manager.py +176 -0
  93. coati_payroll/queue/__init__.py +33 -0
  94. coati_payroll/queue/driver.py +127 -0
  95. coati_payroll/queue/drivers/__init__.py +22 -0
  96. coati_payroll/queue/drivers/dramatiq_driver.py +268 -0
  97. coati_payroll/queue/drivers/huey_driver.py +390 -0
  98. coati_payroll/queue/drivers/noop_driver.py +54 -0
  99. coati_payroll/queue/selector.py +121 -0
  100. coati_payroll/queue/tasks.py +764 -0
  101. coati_payroll/rate_limiting.py +83 -0
  102. coati_payroll/rbac.py +183 -0
  103. coati_payroll/report_engine.py +512 -0
  104. coati_payroll/report_export.py +208 -0
  105. coati_payroll/schema_validator.py +167 -0
  106. coati_payroll/security.py +77 -0
  107. coati_payroll/static/styles.css +1044 -0
  108. coati_payroll/system_reports.py +573 -0
  109. coati_payroll/templates/auth/login.html +189 -0
  110. coati_payroll/templates/base.html +283 -0
  111. coati_payroll/templates/index.html +227 -0
  112. coati_payroll/templates/macros.html +146 -0
  113. coati_payroll/templates/modules/calculation_rule/form.html +78 -0
  114. coati_payroll/templates/modules/calculation_rule/index.html +102 -0
  115. coati_payroll/templates/modules/calculation_rule/schema_editor.html +1159 -0
  116. coati_payroll/templates/modules/carga_inicial_prestacion/form.html +170 -0
  117. coati_payroll/templates/modules/carga_inicial_prestacion/index.html +170 -0
  118. coati_payroll/templates/modules/carga_inicial_prestacion/reporte.html +193 -0
  119. coati_payroll/templates/modules/config_calculos/index.html +44 -0
  120. coati_payroll/templates/modules/configuracion/index.html +90 -0
  121. coati_payroll/templates/modules/currency/form.html +47 -0
  122. coati_payroll/templates/modules/currency/index.html +64 -0
  123. coati_payroll/templates/modules/custom_field/form.html +62 -0
  124. coati_payroll/templates/modules/custom_field/index.html +78 -0
  125. coati_payroll/templates/modules/deduccion/form.html +1 -0
  126. coati_payroll/templates/modules/deduccion/index.html +1 -0
  127. coati_payroll/templates/modules/employee/form.html +254 -0
  128. coati_payroll/templates/modules/employee/index.html +76 -0
  129. coati_payroll/templates/modules/empresa/form.html +74 -0
  130. coati_payroll/templates/modules/empresa/index.html +71 -0
  131. coati_payroll/templates/modules/exchange_rate/form.html +47 -0
  132. coati_payroll/templates/modules/exchange_rate/import.html +93 -0
  133. coati_payroll/templates/modules/exchange_rate/index.html +114 -0
  134. coati_payroll/templates/modules/liquidacion/index.html +58 -0
  135. coati_payroll/templates/modules/liquidacion/nueva.html +51 -0
  136. coati_payroll/templates/modules/liquidacion/ver.html +91 -0
  137. coati_payroll/templates/modules/payroll_concepts/audit_log.html +146 -0
  138. coati_payroll/templates/modules/percepcion/form.html +1 -0
  139. coati_payroll/templates/modules/percepcion/index.html +1 -0
  140. coati_payroll/templates/modules/planilla/config.html +190 -0
  141. coati_payroll/templates/modules/planilla/config_deducciones.html +129 -0
  142. coati_payroll/templates/modules/planilla/config_empleados.html +116 -0
  143. coati_payroll/templates/modules/planilla/config_percepciones.html +113 -0
  144. coati_payroll/templates/modules/planilla/config_prestaciones.html +118 -0
  145. coati_payroll/templates/modules/planilla/config_reglas.html +120 -0
  146. coati_payroll/templates/modules/planilla/ejecutar_nomina.html +106 -0
  147. coati_payroll/templates/modules/planilla/form.html +197 -0
  148. coati_payroll/templates/modules/planilla/index.html +144 -0
  149. coati_payroll/templates/modules/planilla/listar_nominas.html +91 -0
  150. coati_payroll/templates/modules/planilla/log_nomina.html +135 -0
  151. coati_payroll/templates/modules/planilla/novedades/form.html +177 -0
  152. coati_payroll/templates/modules/planilla/novedades/index.html +170 -0
  153. coati_payroll/templates/modules/planilla/ver_nomina.html +477 -0
  154. coati_payroll/templates/modules/planilla/ver_nomina_empleado.html +231 -0
  155. coati_payroll/templates/modules/plugins/index.html +71 -0
  156. coati_payroll/templates/modules/prestacion/form.html +1 -0
  157. coati_payroll/templates/modules/prestacion/index.html +1 -0
  158. coati_payroll/templates/modules/prestacion_management/dashboard.html +150 -0
  159. coati_payroll/templates/modules/prestacion_management/initial_balance_bulk.html +195 -0
  160. coati_payroll/templates/modules/prestamo/approve.html +156 -0
  161. coati_payroll/templates/modules/prestamo/condonacion.html +249 -0
  162. coati_payroll/templates/modules/prestamo/detail.html +443 -0
  163. coati_payroll/templates/modules/prestamo/form.html +203 -0
  164. coati_payroll/templates/modules/prestamo/index.html +150 -0
  165. coati_payroll/templates/modules/prestamo/pago_extraordinario.html +211 -0
  166. coati_payroll/templates/modules/prestamo/tabla_pago_pdf.html +181 -0
  167. coati_payroll/templates/modules/report/admin_index.html +125 -0
  168. coati_payroll/templates/modules/report/detail.html +129 -0
  169. coati_payroll/templates/modules/report/execute.html +266 -0
  170. coati_payroll/templates/modules/report/index.html +95 -0
  171. coati_payroll/templates/modules/report/permissions.html +64 -0
  172. coati_payroll/templates/modules/settings/index.html +274 -0
  173. coati_payroll/templates/modules/shared/concept_form.html +201 -0
  174. coati_payroll/templates/modules/shared/concept_index.html +145 -0
  175. coati_payroll/templates/modules/tipo_planilla/form.html +70 -0
  176. coati_payroll/templates/modules/tipo_planilla/index.html +68 -0
  177. coati_payroll/templates/modules/user/form.html +65 -0
  178. coati_payroll/templates/modules/user/index.html +76 -0
  179. coati_payroll/templates/modules/user/profile.html +81 -0
  180. coati_payroll/templates/modules/vacation/account_detail.html +149 -0
  181. coati_payroll/templates/modules/vacation/account_form.html +52 -0
  182. coati_payroll/templates/modules/vacation/account_index.html +68 -0
  183. coati_payroll/templates/modules/vacation/dashboard.html +156 -0
  184. coati_payroll/templates/modules/vacation/initial_balance_bulk.html +149 -0
  185. coati_payroll/templates/modules/vacation/initial_balance_form.html +93 -0
  186. coati_payroll/templates/modules/vacation/leave_request_detail.html +158 -0
  187. coati_payroll/templates/modules/vacation/leave_request_form.html +61 -0
  188. coati_payroll/templates/modules/vacation/leave_request_index.html +98 -0
  189. coati_payroll/templates/modules/vacation/policy_detail.html +176 -0
  190. coati_payroll/templates/modules/vacation/policy_form.html +152 -0
  191. coati_payroll/templates/modules/vacation/policy_index.html +79 -0
  192. coati_payroll/templates/modules/vacation/register_taken_form.html +178 -0
  193. coati_payroll/translations/en/LC_MESSAGES/messages.mo +0 -0
  194. coati_payroll/translations/en/LC_MESSAGES/messages.po +7283 -0
  195. coati_payroll/translations/es/LC_MESSAGES/messages.mo +0 -0
  196. coati_payroll/translations/es/LC_MESSAGES/messages.po +7374 -0
  197. coati_payroll/vacation_service.py +451 -0
  198. coati_payroll/version.py +18 -0
  199. coati_payroll/vistas/__init__.py +64 -0
  200. coati_payroll/vistas/calculation_rule.py +307 -0
  201. coati_payroll/vistas/carga_inicial_prestacion.py +423 -0
  202. coati_payroll/vistas/config_calculos.py +72 -0
  203. coati_payroll/vistas/configuracion.py +87 -0
  204. coati_payroll/vistas/constants.py +17 -0
  205. coati_payroll/vistas/currency.py +112 -0
  206. coati_payroll/vistas/custom_field.py +120 -0
  207. coati_payroll/vistas/employee.py +305 -0
  208. coati_payroll/vistas/empresa.py +153 -0
  209. coati_payroll/vistas/exchange_rate.py +341 -0
  210. coati_payroll/vistas/liquidacion.py +205 -0
  211. coati_payroll/vistas/payroll_concepts.py +580 -0
  212. coati_payroll/vistas/planilla/__init__.py +38 -0
  213. coati_payroll/vistas/planilla/association_routes.py +238 -0
  214. coati_payroll/vistas/planilla/config_routes.py +158 -0
  215. coati_payroll/vistas/planilla/export_routes.py +175 -0
  216. coati_payroll/vistas/planilla/helpers/__init__.py +34 -0
  217. coati_payroll/vistas/planilla/helpers/association_helpers.py +161 -0
  218. coati_payroll/vistas/planilla/helpers/excel_helpers.py +29 -0
  219. coati_payroll/vistas/planilla/helpers/form_helpers.py +97 -0
  220. coati_payroll/vistas/planilla/nomina_routes.py +488 -0
  221. coati_payroll/vistas/planilla/novedad_routes.py +227 -0
  222. coati_payroll/vistas/planilla/routes.py +145 -0
  223. coati_payroll/vistas/planilla/services/__init__.py +26 -0
  224. coati_payroll/vistas/planilla/services/export_service.py +687 -0
  225. coati_payroll/vistas/planilla/services/nomina_service.py +233 -0
  226. coati_payroll/vistas/planilla/services/novedad_service.py +126 -0
  227. coati_payroll/vistas/planilla/services/planilla_service.py +34 -0
  228. coati_payroll/vistas/planilla/validators/__init__.py +18 -0
  229. coati_payroll/vistas/planilla/validators/planilla_validators.py +40 -0
  230. coati_payroll/vistas/plugins.py +45 -0
  231. coati_payroll/vistas/prestacion.py +272 -0
  232. coati_payroll/vistas/prestamo.py +808 -0
  233. coati_payroll/vistas/report.py +432 -0
  234. coati_payroll/vistas/settings.py +29 -0
  235. coati_payroll/vistas/tipo_planilla.py +134 -0
  236. coati_payroll/vistas/user.py +172 -0
  237. coati_payroll/vistas/vacation.py +1045 -0
  238. coati_payroll-0.0.2.dist-info/LICENSE +201 -0
  239. coati_payroll-0.0.2.dist-info/METADATA +581 -0
  240. coati_payroll-0.0.2.dist-info/RECORD +243 -0
  241. coati_payroll-0.0.2.dist-info/WHEEL +5 -0
  242. coati_payroll-0.0.2.dist-info/entry_points.txt +2 -0
  243. coati_payroll-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,808 @@
1
+ # Copyright 2025 BMO Soluciones, S.A.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Views for managing loans and salary advances (Préstamos y Adelantos).
15
+
16
+ This module handles:
17
+ - Creating loan/advance requests
18
+ - Approving/rejecting loans
19
+ - Viewing payment schedules
20
+ - Exporting payment tables to Excel/PDF
21
+ - Tracking loan balances and payments
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from datetime import date
27
+ from decimal import Decimal
28
+ from io import BytesIO
29
+
30
+ from flask import (
31
+ Blueprint,
32
+ flash,
33
+ redirect,
34
+ render_template,
35
+ request,
36
+ url_for,
37
+ send_file,
38
+ Response,
39
+ )
40
+ from flask_login import current_user
41
+
42
+ from coati_payroll.model import (
43
+ db,
44
+ Adelanto,
45
+ Empleado,
46
+ Moneda,
47
+ Deduccion,
48
+ AdelantoAbono,
49
+ )
50
+ from coati_payroll.forms import (
51
+ PrestamoForm,
52
+ PrestamoApprovalForm,
53
+ PagoExtraordinarioForm,
54
+ CondonacionForm,
55
+ )
56
+ from coati_payroll.i18n import _
57
+ from coati_payroll.enums import AdelantoEstado, AdelantoTipo
58
+ from coati_payroll.rbac import require_read_access, require_write_access
59
+
60
+ prestamo_bp = Blueprint("prestamo", __name__, url_prefix="/prestamo")
61
+
62
+
63
+ @prestamo_bp.route("/")
64
+ @require_read_access()
65
+ def index():
66
+ """List all loans and advances with filtering options."""
67
+ # Get filter parameters
68
+ empleado_id = request.args.get("empleado_id", "")
69
+ estado = request.args.get("estado", "")
70
+ tipo = request.args.get("tipo", "")
71
+
72
+ # Build query
73
+ query = db.select(Adelanto).join(Empleado)
74
+
75
+ if empleado_id:
76
+ query = query.filter(Adelanto.empleado_id == empleado_id)
77
+ if estado:
78
+ query = query.filter(Adelanto.estado == estado)
79
+ if tipo:
80
+ query = query.filter(Adelanto.tipo == tipo)
81
+
82
+ # Order by most recent first
83
+ query = query.order_by(Adelanto.fecha_solicitud.desc())
84
+
85
+ prestamos = db.session.execute(query).scalars().all()
86
+
87
+ # Get all employees for filter dropdown
88
+ empleados = (
89
+ db.session.execute(db.select(Empleado).filter_by(activo=True).order_by(Empleado.primer_apellido))
90
+ .scalars()
91
+ .all()
92
+ )
93
+
94
+ return render_template(
95
+ "modules/prestamo/index.html",
96
+ prestamos=prestamos,
97
+ empleados=empleados,
98
+ filtro_empleado=empleado_id,
99
+ filtro_estado=estado,
100
+ filtro_tipo=tipo,
101
+ estados=AdelantoEstado,
102
+ tipos=AdelantoTipo,
103
+ )
104
+
105
+
106
+ @prestamo_bp.route("/new", methods=["GET", "POST"])
107
+ @require_write_access()
108
+ def new():
109
+ """Create a new loan or salary advance."""
110
+ form = PrestamoForm()
111
+
112
+ # Populate select fields
113
+ empleados = (
114
+ db.session.execute(db.select(Empleado).filter_by(activo=True).order_by(Empleado.primer_apellido))
115
+ .scalars()
116
+ .all()
117
+ )
118
+ form.empleado_id.choices = [
119
+ (emp.id, f"{emp.primer_nombre} {emp.primer_apellido} - {emp.codigo_empleado}") for emp in empleados
120
+ ]
121
+
122
+ monedas = db.session.execute(db.select(Moneda).filter_by(activo=True).order_by(Moneda.nombre)).scalars().all()
123
+ form.moneda_id.choices = [(m.id, f"{m.nombre} ({m.codigo})") for m in monedas]
124
+
125
+ deducciones = (
126
+ db.session.execute(db.select(Deduccion).filter_by(activo=True).order_by(Deduccion.nombre)).scalars().all()
127
+ )
128
+ form.deduccion_id.choices = [("", _("-- Sin deducción asociada --"))] + [(d.id, d.nombre) for d in deducciones]
129
+
130
+ if form.validate_on_submit():
131
+ prestamo = Adelanto()
132
+ prestamo.empleado_id = form.empleado_id.data
133
+ prestamo.tipo = form.tipo.data
134
+ prestamo.fecha_solicitud = form.fecha_solicitud.data
135
+ prestamo.monto_solicitado = form.monto_solicitado.data
136
+ prestamo.moneda_id = form.moneda_id.data
137
+ prestamo.cuotas_pactadas = form.cuotas_pactadas.data
138
+ prestamo.tasa_interes = form.tasa_interes.data or Decimal("0.0000")
139
+ prestamo.tipo_interes = form.tipo_interes.data
140
+ prestamo.metodo_amortizacion = form.metodo_amortizacion.data
141
+ prestamo.cuenta_debe = form.cuenta_debe.data
142
+ prestamo.cuenta_haber = form.cuenta_haber.data
143
+ prestamo.motivo = form.motivo.data
144
+ prestamo.estado = AdelantoEstado.BORRADOR
145
+ prestamo.saldo_pendiente = Decimal("0.00") # Will be set upon approval
146
+ prestamo.creado_por = current_user.usuario
147
+
148
+ # Set deduccion_id only if a valid value was selected
149
+ if form.deduccion_id.data:
150
+ prestamo.deduccion_id = form.deduccion_id.data
151
+
152
+ db.session.add(prestamo)
153
+ db.session.commit()
154
+
155
+ flash(
156
+ _("Préstamo/Adelanto creado exitosamente. Estado: Borrador."),
157
+ "success",
158
+ )
159
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo.id))
160
+
161
+ # Set default values
162
+ if request.method == "GET":
163
+ form.fecha_solicitud.data = date.today()
164
+ form.tipo_interes.data = "ninguno"
165
+ form.tasa_interes.data = Decimal("0.0000")
166
+ form.metodo_amortizacion.data = "frances"
167
+
168
+ return render_template("modules/prestamo/form.html", form=form, prestamo=None)
169
+
170
+
171
+ @prestamo_bp.route("/<prestamo_id>")
172
+ @require_read_access()
173
+ def detail(prestamo_id):
174
+ """View loan details including payment schedule."""
175
+ prestamo = db.session.get(Adelanto, prestamo_id)
176
+ if not prestamo:
177
+ flash("Préstamo no encontrado.", "danger")
178
+ return redirect(url_for("prestamo.index"))
179
+
180
+ # Touch relationship to ensure it is loaded before rendering
181
+ prestamo.empleado
182
+
183
+ # Generate payment schedule
184
+ tabla_pago = generar_tabla_pago(prestamo)
185
+
186
+ # Get payment history
187
+ abonos = (
188
+ db.session.execute(
189
+ db.select(AdelantoAbono).filter_by(adelanto_id=prestamo_id).order_by(AdelantoAbono.fecha_abono.desc())
190
+ )
191
+ .scalars()
192
+ .all()
193
+ )
194
+
195
+ # Get interest journal if loan has interest
196
+ from coati_payroll.model import InteresAdelanto
197
+
198
+ intereses = []
199
+ if prestamo.tasa_interes and prestamo.tasa_interes > 0:
200
+ intereses = (
201
+ db.session.execute(
202
+ db.select(InteresAdelanto)
203
+ .filter_by(adelanto_id=prestamo_id)
204
+ .order_by(InteresAdelanto.fecha_hasta.desc())
205
+ )
206
+ .scalars()
207
+ .all()
208
+ )
209
+
210
+ return render_template(
211
+ "modules/prestamo/detail.html",
212
+ prestamo=prestamo,
213
+ tabla_pago=tabla_pago,
214
+ abonos=abonos,
215
+ intereses=intereses,
216
+ )
217
+
218
+
219
+ @prestamo_bp.route("/<prestamo_id>/edit", methods=["GET", "POST"])
220
+ @require_write_access()
221
+ def edit(prestamo_id):
222
+ """Edit a loan (only allowed in draft or pending state)."""
223
+ prestamo = db.session.get(Adelanto, prestamo_id)
224
+ if not prestamo:
225
+ flash(_("Préstamo no encontrado."), "danger")
226
+ return redirect(url_for("prestamo.index"))
227
+
228
+ # Only allow editing in draft or pending state
229
+ if prestamo.estado not in [AdelantoEstado.BORRADOR, AdelantoEstado.PENDIENTE]:
230
+ flash(
231
+ _("No se puede editar un préstamo en estado '{estado}'.".format(estado=prestamo.estado)),
232
+ "warning",
233
+ )
234
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
235
+
236
+ form = PrestamoForm(obj=prestamo)
237
+
238
+ # Populate select fields
239
+ empleados = (
240
+ db.session.execute(db.select(Empleado).filter_by(activo=True).order_by(Empleado.primer_apellido))
241
+ .scalars()
242
+ .all()
243
+ )
244
+ form.empleado_id.choices = [
245
+ (emp.id, f"{emp.primer_nombre} {emp.primer_apellido} - {emp.codigo_empleado}") for emp in empleados
246
+ ]
247
+
248
+ monedas = db.session.execute(db.select(Moneda).filter_by(activo=True).order_by(Moneda.nombre)).scalars().all()
249
+ form.moneda_id.choices = [(m.id, f"{m.nombre} ({m.codigo})") for m in monedas]
250
+
251
+ deducciones = (
252
+ db.session.execute(db.select(Deduccion).filter_by(activo=True).order_by(Deduccion.nombre)).scalars().all()
253
+ )
254
+ form.deduccion_id.choices = [("", _("-- Sin deducción asociada --"))] + [(d.id, d.nombre) for d in deducciones]
255
+
256
+ if form.validate_on_submit():
257
+ prestamo.empleado_id = form.empleado_id.data
258
+ prestamo.tipo = form.tipo.data
259
+ prestamo.fecha_solicitud = form.fecha_solicitud.data
260
+ prestamo.monto_solicitado = form.monto_solicitado.data
261
+ prestamo.moneda_id = form.moneda_id.data
262
+ prestamo.cuotas_pactadas = form.cuotas_pactadas.data
263
+ prestamo.tasa_interes = form.tasa_interes.data or Decimal("0.0000")
264
+ prestamo.tipo_interes = form.tipo_interes.data
265
+ prestamo.metodo_amortizacion = form.metodo_amortizacion.data
266
+ prestamo.cuenta_debe = form.cuenta_debe.data
267
+ prestamo.cuenta_haber = form.cuenta_haber.data
268
+ prestamo.motivo = form.motivo.data
269
+ prestamo.modificado_por = current_user.usuario
270
+
271
+ # Set deduccion_id
272
+ if form.deduccion_id.data:
273
+ prestamo.deduccion_id = form.deduccion_id.data
274
+ else:
275
+ prestamo.deduccion_id = None
276
+
277
+ db.session.commit()
278
+
279
+ flash(_("Préstamo/Adelanto actualizado exitosamente."), "success")
280
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
281
+
282
+ return render_template("modules/prestamo/form.html", form=form, prestamo=prestamo)
283
+
284
+
285
+ @prestamo_bp.route("/<prestamo_id>/submit", methods=["POST"])
286
+ @require_write_access()
287
+ def submit(prestamo_id):
288
+ """Submit a loan for approval (change from draft to pending)."""
289
+ prestamo = db.session.get(Adelanto, prestamo_id)
290
+ if not prestamo:
291
+ flash(_("Préstamo no encontrado."), "danger")
292
+ return redirect(url_for("prestamo.index"))
293
+
294
+ if prestamo.estado != AdelantoEstado.BORRADOR:
295
+ flash(_("Solo los préstamos en borrador pueden ser enviados."), "warning")
296
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
297
+
298
+ prestamo.estado = AdelantoEstado.PENDIENTE
299
+ prestamo.modificado_por = current_user.usuario
300
+ db.session.commit()
301
+
302
+ flash(_("Préstamo enviado para aprobación."), "success")
303
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
304
+
305
+
306
+ @prestamo_bp.route("/<prestamo_id>/approve", methods=["GET", "POST"])
307
+ @require_write_access()
308
+ def approve(prestamo_id):
309
+ """Approve a loan and set it as active."""
310
+ prestamo = db.session.get(Adelanto, prestamo_id)
311
+ if not prestamo:
312
+ flash(_("Préstamo no encontrado."), "danger")
313
+ return redirect(url_for("prestamo.index"))
314
+
315
+ if prestamo.estado not in [AdelantoEstado.PENDIENTE, AdelantoEstado.BORRADOR]:
316
+ flash(_("Este préstamo no puede ser aprobado en su estado actual."), "warning")
317
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
318
+
319
+ form = PrestamoApprovalForm()
320
+
321
+ if form.validate_on_submit():
322
+ if form.aprobar.data:
323
+ # Approve the loan
324
+ prestamo.monto_aprobado = form.monto_aprobado.data
325
+ prestamo.fecha_aprobacion = form.fecha_aprobacion.data
326
+ prestamo.fecha_desembolso = form.fecha_desembolso.data
327
+ prestamo.estado = AdelantoEstado.APROBADO
328
+ prestamo.aprobado_por = current_user.usuario
329
+
330
+ # Calculate installment amount based on amortization method
331
+ if prestamo.cuotas_pactadas and prestamo.cuotas_pactadas > 0:
332
+ from coati_payroll.interes_engine import calcular_cuota_frances
333
+
334
+ tasa_interes = prestamo.tasa_interes or Decimal("0.0000")
335
+ metodo = prestamo.metodo_amortizacion or "frances"
336
+
337
+ # For French method, calculate constant payment
338
+ # For German method, payment varies so we store the first payment
339
+ if metodo == "frances":
340
+ prestamo.monto_por_cuota = calcular_cuota_frances(
341
+ prestamo.monto_aprobado, tasa_interes, prestamo.cuotas_pactadas
342
+ )
343
+ else:
344
+ # For German method, store average payment for reference
345
+ # Actual payment will be calculated per installment
346
+ prestamo.monto_por_cuota = prestamo.monto_aprobado / prestamo.cuotas_pactadas
347
+ else:
348
+ prestamo.monto_por_cuota = prestamo.monto_aprobado
349
+
350
+ # Set pending balance and initialize interest tracking
351
+ prestamo.saldo_pendiente = prestamo.monto_aprobado
352
+ prestamo.interes_acumulado = Decimal("0.00")
353
+ prestamo.fecha_ultimo_calculo_interes = prestamo.fecha_aprobacion or date.today()
354
+ prestamo.modificado_por = current_user.usuario
355
+
356
+ db.session.commit()
357
+ flash(_("Préstamo aprobado exitosamente."), "success")
358
+
359
+ elif form.rechazar.data:
360
+ # Reject the loan
361
+ prestamo.estado = AdelantoEstado.RECHAZADO
362
+ prestamo.rechazado_por = current_user.usuario
363
+ prestamo.motivo_rechazo = form.motivo_rechazo.data
364
+ prestamo.modificado_por = current_user.usuario
365
+
366
+ db.session.commit()
367
+ flash(_("Préstamo rechazado."), "info")
368
+
369
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
370
+
371
+ # Pre-populate form with loan data
372
+ if request.method == "GET":
373
+ form.monto_aprobado.data = prestamo.monto_solicitado
374
+ form.fecha_aprobacion.data = date.today()
375
+
376
+ return render_template("modules/prestamo/approve.html", form=form, prestamo=prestamo)
377
+
378
+
379
+ @prestamo_bp.route("/<prestamo_id>/cancel", methods=["POST"])
380
+ @require_write_access()
381
+ def cancel(prestamo_id):
382
+ """Cancel a loan."""
383
+ prestamo = db.session.get(Adelanto, prestamo_id)
384
+ if not prestamo:
385
+ flash(_("Préstamo no encontrado."), "danger")
386
+ return redirect(url_for("prestamo.index"))
387
+
388
+ if prestamo.estado in [AdelantoEstado.PAGADO, AdelantoEstado.CANCELADO]:
389
+ flash(_("Este préstamo ya está finalizado."), "warning")
390
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
391
+
392
+ prestamo.estado = AdelantoEstado.CANCELADO
393
+ prestamo.modificado_por = current_user.usuario
394
+ db.session.commit()
395
+
396
+ flash(_("Préstamo cancelado."), "info")
397
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
398
+
399
+
400
+ @prestamo_bp.route("/<prestamo_id>/pago-extraordinario", methods=["GET", "POST"])
401
+ @require_write_access()
402
+ def pago_extraordinario(prestamo_id):
403
+ """Register an extraordinary/manual payment on a loan."""
404
+ prestamo = db.session.get(Adelanto, prestamo_id)
405
+ if not prestamo:
406
+ flash("Préstamo no encontrado.", "danger")
407
+ return redirect(url_for("prestamo.index"))
408
+
409
+ # Touch relationship to ensure it is loaded before rendering
410
+ prestamo.empleado
411
+
412
+ # Only allow payments on approved/active loans
413
+ if prestamo.estado not in [AdelantoEstado.APROBADO, AdelantoEstado.APLICADO]:
414
+ flash(
415
+ _("Solo se pueden registrar pagos en préstamos aprobados o aplicados."),
416
+ "warning",
417
+ )
418
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
419
+
420
+ if prestamo.saldo_pendiente <= 0:
421
+ flash(_("Este préstamo ya está totalmente pagado."), "info")
422
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
423
+
424
+ form = PagoExtraordinarioForm()
425
+
426
+ if form.validate_on_submit():
427
+ monto_abonado = form.monto_abonado.data
428
+
429
+ # Validate payment amount
430
+ if monto_abonado > prestamo.saldo_pendiente:
431
+ flash(
432
+ _("El monto del pago ({monto}) excede el saldo pendiente ({saldo}).").format(
433
+ monto=monto_abonado, saldo=prestamo.saldo_pendiente
434
+ ),
435
+ "warning",
436
+ )
437
+ return render_template(
438
+ "modules/prestamo/pago_extraordinario.html",
439
+ form=form,
440
+ prestamo=prestamo,
441
+ )
442
+
443
+ # Record the payment
444
+ abono = AdelantoAbono()
445
+ abono.adelanto_id = prestamo.id
446
+ abono.fecha_abono = form.fecha_abono.data
447
+ abono.monto_abonado = monto_abonado
448
+ abono.saldo_anterior = prestamo.saldo_pendiente
449
+ abono.saldo_posterior = prestamo.saldo_pendiente - monto_abonado
450
+ abono.tipo_abono = "manual"
451
+ abono.observaciones = form.observaciones.data or "Pago extraordinario"
452
+ # Audit trail information
453
+ abono.tipo_comprobante = form.tipo_comprobante.data
454
+ abono.numero_comprobante = form.numero_comprobante.data
455
+ abono.referencia_bancaria = form.referencia_bancaria.data
456
+ # Optional accounting entries
457
+ abono.cuenta_debe = form.cuenta_debe.data
458
+ abono.cuenta_haber = form.cuenta_haber.data
459
+ abono.creado_por = current_user.usuario
460
+
461
+ # Update loan balance
462
+ prestamo.saldo_pendiente = abono.saldo_posterior
463
+ prestamo.modificado_por = current_user.usuario
464
+
465
+ # Apply payment according to selected method
466
+ tipo_aplicacion = form.tipo_aplicacion.data
467
+
468
+ # Calculate remaining installments (those not yet paid)
469
+ total_abonado_previo = sum(a.monto_abonado for a in prestamo.abonos if a.tipo_abono in ["nomina", "manual"])
470
+ cuotas_pagadas = 0
471
+ if prestamo.monto_por_cuota and prestamo.monto_por_cuota > 0:
472
+ cuotas_pagadas = int(total_abonado_previo / prestamo.monto_por_cuota)
473
+
474
+ cuotas_pendientes = prestamo.cuotas_pactadas - cuotas_pagadas
475
+
476
+ if tipo_aplicacion == "reducir_cuotas":
477
+ # Option 1: Reduce number of installments, keep installment amount
478
+ if prestamo.monto_por_cuota and prestamo.monto_por_cuota > 0:
479
+ cuotas_a_eliminar = int(monto_abonado / prestamo.monto_por_cuota)
480
+ # Store original values for the observation
481
+ cuotas_originales = prestamo.cuotas_pactadas
482
+ # Adjust total installments
483
+ nueva_cuotas_pactadas = max(cuotas_pagadas, prestamo.cuotas_pactadas - cuotas_a_eliminar)
484
+ prestamo.cuotas_pactadas = nueva_cuotas_pactadas
485
+
486
+ abono.observaciones = (
487
+ f"{abono.observaciones or 'Pago extraordinario'} - "
488
+ f"Cuotas reducidas de {cuotas_originales} a {nueva_cuotas_pactadas}"
489
+ )
490
+
491
+ elif tipo_aplicacion == "reducir_monto":
492
+ # Option 2: Reduce installment amount, keep number of installments
493
+ if cuotas_pendientes > 0:
494
+ # Recalculate installment amount based on remaining balance
495
+ nueva_cuota = prestamo.saldo_pendiente / cuotas_pendientes
496
+ monto_original = prestamo.monto_por_cuota
497
+ prestamo.monto_por_cuota = nueva_cuota
498
+
499
+ abono.observaciones = (
500
+ f"{abono.observaciones or 'Pago extraordinario'} - "
501
+ f"Monto por cuota reducido de {monto_original:.2f} a {nueva_cuota:.2f}"
502
+ )
503
+
504
+ # Check if loan is fully paid
505
+ if prestamo.saldo_pendiente <= Decimal("0.01"): # Allow for rounding
506
+ prestamo.saldo_pendiente = Decimal("0.00")
507
+ prestamo.estado = AdelantoEstado.PAGADO
508
+
509
+ db.session.add(abono)
510
+ db.session.commit()
511
+
512
+ flash(
513
+ _("Pago extraordinario registrado exitosamente."),
514
+ "success",
515
+ )
516
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
517
+
518
+ # Pre-populate form
519
+ if request.method == "GET":
520
+ form.fecha_abono.data = date.today()
521
+ # Default to reducing installment amount (usually more beneficial for employee)
522
+ form.tipo_aplicacion.data = "reducir_monto"
523
+
524
+ return render_template(
525
+ "modules/prestamo/pago_extraordinario.html",
526
+ form=form,
527
+ prestamo=prestamo,
528
+ )
529
+
530
+
531
+ @prestamo_bp.route("/<prestamo_id>/condonacion", methods=["GET", "POST"])
532
+ @require_write_access()
533
+ def condonacion(prestamo_id):
534
+ """Record a loan forgiveness/write-off (condonación de deuda)."""
535
+ prestamo = db.session.get(Adelanto, prestamo_id)
536
+ if not prestamo:
537
+ flash(_("Préstamo no encontrado."), "danger")
538
+ return redirect(url_for("prestamo.index"))
539
+
540
+ # Only allow forgiveness on approved/active loans
541
+ if prestamo.estado not in [AdelantoEstado.APROBADO, AdelantoEstado.APLICADO]:
542
+ flash(
543
+ _("Solo se pueden condonar préstamos aprobados o aplicados."),
544
+ "warning",
545
+ )
546
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
547
+
548
+ if prestamo.saldo_pendiente <= 0:
549
+ flash(_("Este préstamo ya está totalmente pagado."), "info")
550
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
551
+
552
+ form = CondonacionForm()
553
+
554
+ if form.validate_on_submit():
555
+ monto_condonado = form.monto_condonado.data
556
+
557
+ # Validate forgiveness amount
558
+ if monto_condonado > prestamo.saldo_pendiente:
559
+ flash(
560
+ _("El monto a condonar ({monto}) excede el saldo pendiente ({saldo}).").format(
561
+ monto=monto_condonado, saldo=prestamo.saldo_pendiente
562
+ ),
563
+ "warning",
564
+ )
565
+ return render_template(
566
+ "modules/prestamo/condonacion.html",
567
+ form=form,
568
+ prestamo=prestamo,
569
+ )
570
+
571
+ # Record the forgiveness as a special type of payment
572
+ abono = AdelantoAbono()
573
+ abono.adelanto_id = prestamo.id
574
+ abono.fecha_abono = form.fecha_condonacion.data
575
+ abono.monto_abonado = monto_condonado
576
+ abono.saldo_anterior = prestamo.saldo_pendiente
577
+ abono.saldo_posterior = prestamo.saldo_pendiente - monto_condonado
578
+ abono.tipo_abono = "condonacion"
579
+
580
+ # Store complete audit trail
581
+ abono.autorizado_por = form.autorizado_por.data
582
+ abono.documento_soporte = form.documento_soporte.data
583
+ abono.referencia_documento = form.referencia_documento.data
584
+ abono.justificacion = form.justificacion.data
585
+ # Optional accounting entries
586
+ abono.cuenta_debe = form.cuenta_debe.data
587
+ abono.cuenta_haber = form.cuenta_haber.data
588
+
589
+ # Build observation summary
590
+ porcentaje = ""
591
+ if form.porcentaje_condonado.data:
592
+ porcentaje = f" ({form.porcentaje_condonado.data}%)"
593
+
594
+ abono.observaciones = (
595
+ f"CONDONACIÓN DE DEUDA{porcentaje} - "
596
+ f"Autorizado por: {form.autorizado_por.data}. "
597
+ f"Documento: {form.documento_soporte.data} - {form.referencia_documento.data}"
598
+ )
599
+ abono.creado_por = current_user.usuario
600
+
601
+ # Update loan balance
602
+ prestamo.saldo_pendiente = abono.saldo_posterior
603
+ prestamo.modificado_por = current_user.usuario
604
+
605
+ # If loan is fully forgiven/paid, mark as paid
606
+ if prestamo.saldo_pendiente <= Decimal("0.01"): # Allow for rounding
607
+ prestamo.saldo_pendiente = Decimal("0.00")
608
+ prestamo.estado = AdelantoEstado.PAGADO
609
+
610
+ db.session.add(abono)
611
+ db.session.commit()
612
+
613
+ flash(
614
+ _("Condonación de deuda registrada exitosamente. Monto condonado: {monto}").format(monto=monto_condonado),
615
+ "success",
616
+ )
617
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
618
+
619
+ # Pre-populate form
620
+ if request.method == "GET":
621
+ form.fecha_condonacion.data = date.today()
622
+ form.monto_condonado.data = prestamo.saldo_pendiente
623
+
624
+ return render_template(
625
+ "modules/prestamo/condonacion.html",
626
+ form=form,
627
+ prestamo=prestamo,
628
+ )
629
+
630
+
631
+ @prestamo_bp.route("/<prestamo_id>/tabla-pago/excel")
632
+ @require_read_access()
633
+ def export_excel(prestamo_id):
634
+ """Export payment schedule to Excel."""
635
+ prestamo = db.session.get(Adelanto, prestamo_id)
636
+ if not prestamo:
637
+ flash(_("Préstamo no encontrado."), "danger")
638
+ return redirect(url_for("prestamo.index"))
639
+
640
+ try:
641
+ from openpyxl import Workbook
642
+ from openpyxl.styles import Font, PatternFill
643
+ except ImportError:
644
+ flash(
645
+ _("Excel export no disponible. Instale openpyxl."),
646
+ "warning",
647
+ )
648
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
649
+
650
+ # Generate payment schedule
651
+ tabla_pago = generar_tabla_pago(prestamo)
652
+
653
+ # Create workbook
654
+ wb = Workbook()
655
+ ws = wb.active
656
+ ws.title = "Tabla de Pagos"
657
+
658
+ # Header
659
+ ws["A1"] = "TABLA DE PAGOS - PRÉSTAMO/ADELANTO"
660
+ ws["A1"].font = Font(bold=True, size=14)
661
+ ws.merge_cells("A1:E1")
662
+
663
+ # Loan details
664
+ row = 3
665
+ ws[f"A{row}"] = "Empleado:"
666
+ ws[f"B{row}"] = f"{prestamo.empleado.primer_nombre} {prestamo.empleado.primer_apellido}"
667
+ row += 1
668
+ ws[f"A{row}"] = "Tipo:"
669
+ ws[f"B{row}"] = prestamo.tipo
670
+ row += 1
671
+ ws[f"A{row}"] = "Monto:"
672
+ ws[f"B{row}"] = float(prestamo.monto_aprobado or prestamo.monto_solicitado)
673
+ row += 1
674
+ ws[f"A{row}"] = "Cuotas:"
675
+ ws[f"B{row}"] = prestamo.cuotas_pactadas
676
+ row += 2
677
+
678
+ # Table header
679
+ headers = ["#", "Fecha Estimada", "Cuota", "Interés", "Capital", "Saldo"]
680
+ for col, header in enumerate(headers, start=1):
681
+ cell = ws.cell(row=row, column=col, value=header)
682
+ cell.font = Font(bold=True)
683
+ cell.fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
684
+ cell.font = Font(bold=True, color="FFFFFF")
685
+
686
+ # Table data
687
+ for item in tabla_pago:
688
+ row += 1
689
+ ws.cell(row=row, column=1, value=item["numero"])
690
+ ws.cell(row=row, column=2, value=item["fecha_estimada"])
691
+ ws.cell(row=row, column=3, value=float(item["cuota"]))
692
+ ws.cell(row=row, column=4, value=float(item["interes"]))
693
+ ws.cell(row=row, column=5, value=float(item["capital"]))
694
+ ws.cell(row=row, column=6, value=float(item["saldo"]))
695
+
696
+ # Save to BytesIO
697
+ output = BytesIO()
698
+ wb.save(output)
699
+ output.seek(0)
700
+
701
+ # Use first 8 chars of ULID (26 chars total) for short filename
702
+ filename = f"tabla_pago_{prestamo.empleado.codigo_empleado}_{prestamo.id[:8]}.xlsx"
703
+
704
+ return send_file(
705
+ output,
706
+ mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
707
+ as_attachment=True,
708
+ download_name=filename,
709
+ )
710
+
711
+
712
+ @prestamo_bp.route("/<prestamo_id>/tabla-pago/pdf")
713
+ @require_read_access()
714
+ def export_pdf(prestamo_id):
715
+ """Export payment schedule to PDF."""
716
+ prestamo = db.session.get(Adelanto, prestamo_id)
717
+ if not prestamo:
718
+ flash(_("Préstamo no encontrado."), "danger")
719
+ return redirect(url_for("prestamo.index"))
720
+
721
+ # Generate payment schedule
722
+ tabla_pago = generar_tabla_pago(prestamo)
723
+
724
+ # Render HTML template for PDF
725
+ html = render_template(
726
+ "modules/prestamo/tabla_pago_pdf.html",
727
+ prestamo=prestamo,
728
+ tabla_pago=tabla_pago,
729
+ )
730
+
731
+ try:
732
+ from flask_weasyprint import HTML, render_pdf
733
+
734
+ pdf = render_pdf(HTML(string=html))
735
+ # Use first 8 chars of ULID (26 chars total) for short filename
736
+ filename = f"tabla_pago_{prestamo.empleado.codigo_empleado}_{prestamo.id[:8]}.pdf"
737
+
738
+ return Response(
739
+ pdf,
740
+ mimetype="application/pdf",
741
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
742
+ )
743
+ except ImportError:
744
+ flash(
745
+ _("PDF export no disponible. Instale WeasyPrint."),
746
+ "warning",
747
+ )
748
+ return redirect(url_for("prestamo.detail", prestamo_id=prestamo_id))
749
+
750
+
751
+ def generar_tabla_pago(prestamo: Adelanto) -> list[dict]:
752
+ """Generate payment schedule for a loan.
753
+
754
+ Args:
755
+ prestamo: Loan object
756
+
757
+ Returns:
758
+ List of payment schedule items with fields:
759
+ - numero: Payment number
760
+ - fecha_estimada: Estimated payment date
761
+ - cuota: Total payment amount
762
+ - interes: Interest portion
763
+ - capital: Principal portion
764
+ - saldo: Remaining balance
765
+ """
766
+ if not prestamo.cuotas_pactadas or prestamo.cuotas_pactadas <= 0:
767
+ return []
768
+
769
+ monto_base = prestamo.monto_aprobado or prestamo.monto_solicitado
770
+ if not monto_base or monto_base <= 0:
771
+ return []
772
+
773
+ # Determine start date
774
+ fecha_inicio = prestamo.fecha_aprobacion or prestamo.fecha_solicitud or date.today()
775
+
776
+ # Import interest engine
777
+ from coati_payroll.interes_engine import generar_tabla_amortizacion
778
+
779
+ # Get interest rate and type
780
+ tasa_interes = prestamo.tasa_interes or Decimal("0.0000")
781
+ tipo_interes = prestamo.tipo_interes or "ninguno"
782
+ metodo_amortizacion = prestamo.metodo_amortizacion or "frances"
783
+
784
+ # Generate amortization schedule using the interest engine
785
+ cuotas = generar_tabla_amortizacion(
786
+ principal=monto_base,
787
+ tasa_anual=tasa_interes,
788
+ num_cuotas=prestamo.cuotas_pactadas,
789
+ fecha_inicio=fecha_inicio,
790
+ metodo=metodo_amortizacion,
791
+ tipo_interes=tipo_interes,
792
+ )
793
+
794
+ # Convert to dict format for template
795
+ tabla = []
796
+ for cuota in cuotas:
797
+ tabla.append(
798
+ {
799
+ "numero": cuota.numero,
800
+ "fecha_estimada": cuota.fecha_estimada,
801
+ "cuota": cuota.cuota_total,
802
+ "interes": cuota.interes,
803
+ "capital": cuota.capital,
804
+ "saldo": cuota.saldo,
805
+ }
806
+ )
807
+
808
+ return tabla