From 25e3cd62540fa58d293b5396c4495b0215541c79 Mon Sep 17 00:00:00 2001
From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com>
Date: Tue, 12 May 2026 19:41:17 -0500
Subject: [PATCH] up
---
pkg/models/notificacion_log.go | 17 +++
pkg/models/saas_api_config.go | 13 +++
pkg/services/renovacion_service.go | 30 +++++
resources/views/renovaciones/contratos.html | 84 ++++++++++++++
rest/controllers/api/bold_controller.go | 3 +-
rest/controllers/api/dlocal_controller.go | 5 +-
rest/controllers/api/pago_controller.go | 3 +
rest/controllers/contrato_controller.go | 117 ++++++++++++++++++++
rest/routes/renovaciones.go | 1 +
9 files changed, 270 insertions(+), 3 deletions(-)
diff --git a/pkg/models/notificacion_log.go b/pkg/models/notificacion_log.go
index f835eba..8ec6f60 100644
--- a/pkg/models/notificacion_log.go
+++ b/pkg/models/notificacion_log.go
@@ -1,6 +1,7 @@
package models
import (
+ "fmt"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -85,3 +86,19 @@ func GetAllLogs(limit, offset int) ([]NotificacionLog, int64, error) {
func GetLogByID(id uint) (*NotificacionLog, error) {
return GetNotificacionLogByID(id)
}
+
+// GetLogsByContratoID devuelve los logs de notificaciones que incluyen un contrato específico
+func GetLogsByContratoID(contratoID uint) ([]NotificacionLog, error) {
+ var items []NotificacionLog
+ // contratos_ids es un JSON array, buscamos que contenga el ID
+ pattern := fmt.Sprintf(`%%[%d]%%`, contratoID)
+ if err := app.Http.Database.DB.
+ Preload("Regla").
+ Where("contratos_ids LIKE ?", pattern).
+ Order("created_at DESC").
+ Limit(50).
+ Find(&items).Error; err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/pkg/models/saas_api_config.go b/pkg/models/saas_api_config.go
index c6192e7..9160a32 100644
--- a/pkg/models/saas_api_config.go
+++ b/pkg/models/saas_api_config.go
@@ -120,3 +120,16 @@ func GetSaasDispatchLogs(limit, offset int) ([]SaasDispatchLog, int64, error) {
}
return items, total, nil
}
+
+// GetDispatchLogsByContratoID devuelve los pagos/dispatches de un contrato específico
+func GetDispatchLogsByContratoID(contratoID uint) ([]SaasDispatchLog, error) {
+ var items []SaasDispatchLog
+ if err := app.Http.Database.DB.
+ Where("contrato_id = ?", contratoID).
+ Order("created_at DESC").
+ Limit(50).
+ Find(&items).Error; err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go
index 3d4620d..d108eb9 100644
--- a/pkg/services/renovacion_service.go
+++ b/pkg/services/renovacion_service.go
@@ -273,6 +273,36 @@ func ReenviarLog(logEntry *models.NotificacionLog) error {
return sendErr
}
+// EnviarCorreoConfirmacionPago envía el correo de "pago recibido" para un contrato.
+// Usa la regla/plantilla con tipo_evento = 'pago_recibido'. Si no existe, no hace nada.
+// Es idempotente: no reenvía si ya se envió hoy (YaEnviadoHoy por regla+cliente).
+func EnviarCorreoConfirmacionPago(contratoID uint) {
+ contrato, err := models.GetContratoByID(contratoID)
+ if err != nil {
+ log.Printf("[PAGO-CORREO] contrato %d no encontrado: %v", contratoID, err)
+ return
+ }
+
+ reglas, err := models.GetReglasByTipoEvento("pago_recibido")
+ if err != nil || len(reglas) == 0 {
+ log.Printf("[PAGO-CORREO] sin regla pago_recibido configurada — se omite correo de confirmación")
+ return
+ }
+ regla := ®las[0]
+
+ // Idempotencia: si ya se envió hoy para esta regla+cliente, no reenviar
+ if models.YaEnviadoHoy(contrato.ClienteID, regla.ID) {
+ log.Printf("[PAGO-CORREO] correo de confirmación ya enviado hoy para cliente %d", contrato.ClienteID)
+ return
+ }
+
+ if err := EnviarNotificacionGrupo(regla, &contrato.Cliente, []models.Contrato{*contrato}); err != nil {
+ log.Printf("[PAGO-CORREO] error enviando correo de confirmación para contrato %d: %v", contratoID, err)
+ } else {
+ log.Printf("[PAGO-CORREO] correo de confirmación enviado para contrato %d → %s", contratoID, contrato.Cliente.Email)
+ }
+}
+
// ObtenerOCrearEnlacePago devuelve el enlace de pago vigente del contrato.
// gateway puede ser "bold", "dlocal" o "ninguna"/"".
// Si ya existe un enlace guardado, lo reutiliza (mismo ciclo).
diff --git a/resources/views/renovaciones/contratos.html b/resources/views/renovaciones/contratos.html
index faca086..4700653 100644
--- a/resources/views/renovaciones/contratos.html
+++ b/resources/views/renovaciones/contratos.html
@@ -77,6 +77,9 @@
+
@@ -174,6 +177,69 @@
+
+
@@ -250,6 +316,7 @@ document.addEventListener('alpine:init', () => {
addModal: false, editModal: false, deleteModal: false,
notifModal: false, notifReglaId: '', notifContratoId: null, notifCliente: '',
bienvenidaModal: false, bienvenidaReglaId: '', bienvenidaContratoId: null,
+ historialModal: false, historialTitulo: '', historialTimeline: [], historialLoading: false,
clientes: [], servicios: [], reglas: [], reglasBienvenida: [],
selectedId: null,
form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' },
@@ -404,6 +471,23 @@ document.addEventListener('alpine:init', () => {
return m[tipo] || tipo;
},
+ async openHistorial(d) {
+ this.historialTitulo = 'Historial — ' + (d.cliente?.nombre || '#'+d.ID);
+ this.historialTimeline = [];
+ this.historialLoading = true;
+ this.historialModal = true;
+ try {
+ const res = await axios.get(`/app/api/contratos/${d.ID}/historial`);
+ this.historialTimeline = res.data.timeline || [];
+ } catch(e) { this.showToast('Error cargando historial', 'error'); }
+ this.historialLoading = false;
+ },
+
+ fmtDatetime(raw) {
+ if (!raw) return '—';
+ return new Date(raw).toLocaleString('es-ES', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
+ },
+
fmtDate(raw) {
if (!raw) return '—';
return new Date(raw).toLocaleDateString('es-ES', { day:'2-digit', month:'2-digit', year:'numeric' });
diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go
index 4f1167e..1617007 100644
--- a/rest/controllers/api/bold_controller.go
+++ b/rest/controllers/api/bold_controller.go
@@ -118,8 +118,9 @@ func BoldWebhook(c *fiber.Ctx) error {
log.Printf("[BOLD] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
} else {
log.Printf("[BOLD] Webhook: contrato %d marcado como pagado", contratoID)
- // Notificar SaaS externos asociados al contrato (goroutine, no bloquea respuesta)
+ // Notificar SaaS externos + enviar correo de confirmación (goroutine, no bloquea respuesta)
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "bold", float64(monto), "COP")
+ go services.EnviarCorreoConfirmacionPago(contratoID)
}
}
}
diff --git a/rest/controllers/api/dlocal_controller.go b/rest/controllers/api/dlocal_controller.go
index bb61110..ea6a50b 100644
--- a/rest/controllers/api/dlocal_controller.go
+++ b/rest/controllers/api/dlocal_controller.go
@@ -152,7 +152,7 @@ func SeePlanes(c *fiber.Ctx) error {
response, err := services.SeePlanes(*dlocalConfig)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
- "error": fmt.Sprintf("Error al obtener los planes", err),
+ "error": fmt.Sprintf("Error al obtener los planes: %v", err),
})
}
@@ -324,8 +324,9 @@ func DlocalWebhook(c *fiber.Ctx) error {
log.Printf("[DLOCAL] Webhook: error marcando contrato %d como pagado: %v", contratoID, err)
} else {
log.Printf("[DLOCAL] Webhook: contrato %d marcado como pagado", contratoID)
- // Notificar SaaS externos asociados al contrato (goroutine, no bloquea respuesta)
+ // Notificar SaaS externos + enviar correo de confirmación (goroutine, no bloquea respuesta)
go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "dlocal", monto, moneda)
+ go services.EnviarCorreoConfirmacionPago(contratoID)
}
}
}
diff --git a/rest/controllers/api/pago_controller.go b/rest/controllers/api/pago_controller.go
index cee6180..c24dd47 100644
--- a/rest/controllers/api/pago_controller.go
+++ b/rest/controllers/api/pago_controller.go
@@ -118,6 +118,7 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) {
if l.Estado == "PAID" || l.Estado == "AUTHORIZED" {
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dlocal_payment_log (id=%d)", contratoID, l.ID)
_ = models.MarcarContratoPagado(contratoID)
+ go services.EnviarCorreoConfirmacionPago(contratoID)
return true, l.CreatedAt.Format("02/01/2006 15:04")
}
}
@@ -133,6 +134,7 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) {
} else if paid {
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via Bold API (payment_id=%s)", contratoID, paymentID)
_ = models.MarcarContratoPagado(contratoID)
+ go services.EnviarCorreoConfirmacionPago(contratoID)
if paymentID != "" && !models.IsBoldNotificationDuplicate("api-check-"+paymentID) {
_ = models.SaveBoldWebhookLog(models.BoldWebhookLog{
NotificationID: "api-check-" + paymentID,
@@ -156,6 +158,7 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) {
} else if paid {
log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dLocal API (payment_id=%s)", contratoID, paymentID)
_ = models.MarcarContratoPagado(contratoID)
+ go services.EnviarCorreoConfirmacionPago(contratoID)
notifID := "api-check-" + ref
if !models.IsDlocalNotificationDuplicate(notifID) {
_ = models.SaveDlocalPaymentLog(models.DlocalPaymentLog{
diff --git a/rest/controllers/contrato_controller.go b/rest/controllers/contrato_controller.go
index 07ef488..bd8d494 100644
--- a/rest/controllers/contrato_controller.go
+++ b/rest/controllers/contrato_controller.go
@@ -228,6 +228,123 @@ func DeleteContrato(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
}
+// GetHistorialContrato devuelve la línea de tiempo de actividad de un contrato
+func GetHistorialContrato(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ contrato, err := models.GetContratoByID(uint(id))
+ if err != nil {
+ return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
+ }
+
+ notifLogs, _ := models.GetLogsByContratoID(uint(id))
+ pagoLogs, _ := models.GetDispatchLogsByContratoID(uint(id))
+
+ // Construir timeline unificado
+ type Evento struct {
+ Tipo string `json:"tipo"` // creacion | renovacion | notificacion | pago
+ Icono string `json:"icono"`
+ Titulo string `json:"titulo"`
+ Detalle string `json:"detalle"`
+ Estado string `json:"estado"` // ok | error | info
+ FechaISO string `json:"fecha"`
+ }
+
+ var timeline []Evento
+
+ // Evento: creación del contrato
+ timeline = append(timeline, Evento{
+ Tipo: "creacion",
+ Icono: "document",
+ Titulo: "Contrato creado",
+ Detalle: "Inicio: " + contrato.FechaInicio.Format("02/01/2006") + " · Vence: " + contrato.FechaVencimiento.Format("02/01/2006"),
+ Estado: "info",
+ FechaISO: contrato.CreatedAt.Format(time.RFC3339),
+ })
+
+ // Evento: renovaciones (updated_at con estado renovado — heurístico por estado)
+ if contrato.Estado == "renovado" {
+ timeline = append(timeline, Evento{
+ Tipo: "renovacion",
+ Icono: "refresh",
+ Titulo: "Contrato renovado",
+ Detalle: "Nuevo vencimiento: " + contrato.FechaVencimiento.Format("02/01/2006"),
+ Estado: "ok",
+ FechaISO: contrato.UpdatedAt.Format(time.RFC3339),
+ })
+ }
+
+ // Eventos: notificaciones enviadas
+ for _, n := range notifLogs {
+ estado := "ok"
+ if n.Estado == "fallido" {
+ estado = "error"
+ } else if n.Estado == "pendiente" {
+ estado = "info"
+ }
+ detalle := n.Asunto
+ if n.Estado == "fallido" && n.ErrorMsg != "" {
+ detalle += " · Error: " + n.ErrorMsg
+ }
+ reglaLabel := ""
+ if n.Regla.ID > 0 {
+ reglaLabel = " (" + n.Regla.Nombre + ")"
+ }
+ timeline = append(timeline, Evento{
+ Tipo: "notificacion",
+ Icono: "mail",
+ Titulo: "Notificación enviada" + reglaLabel,
+ Detalle: detalle,
+ Estado: estado,
+ FechaISO: n.CreatedAt.Format(time.RFC3339),
+ })
+ }
+
+ // Eventos: pagos / dispatches
+ for _, p := range pagoLogs {
+ estado := "ok"
+ if p.Estado == "failed" {
+ estado = "error"
+ }
+ detalle := p.Referencia
+ if p.PayerEmail != "" {
+ detalle += " · " + p.PayerEmail
+ }
+ if p.Fuente != "" {
+ detalle += " · " + p.Fuente
+ }
+ timeline = append(timeline, Evento{
+ Tipo: "pago",
+ Icono: "currency",
+ Titulo: "Pago recibido",
+ Detalle: detalle,
+ Estado: estado,
+ FechaISO: p.CreatedAt.Format(time.RFC3339),
+ })
+ }
+
+ // Ordenar por fecha desc
+ for i := 0; i < len(timeline)-1; i++ {
+ for j := i + 1; j < len(timeline); j++ {
+ if timeline[j].FechaISO > timeline[i].FechaISO {
+ timeline[i], timeline[j] = timeline[j], timeline[i]
+ }
+ }
+ }
+
+ return c.JSON(fiber.Map{
+ "contrato": fiber.Map{
+ "id": contrato.ID,
+ "cliente": contrato.Cliente.Nombre,
+ "estado": contrato.Estado,
+ },
+ "timeline": timeline,
+ "total": len(timeline),
+ })
+}
+
func calcularFechaVencimiento(desde time.Time, periodicidad string) time.Time {
switch periodicidad {
case "mensual":
diff --git a/rest/routes/renovaciones.go b/rest/routes/renovaciones.go
index ae3f347..5c02a58 100644
--- a/rest/routes/renovaciones.go
+++ b/rest/routes/renovaciones.go
@@ -33,6 +33,7 @@ func RenovacionesRoutes(protected fiber.Router) {
protected.Delete("/api/contratos/:id", controllers.DeleteContrato)
protected.Post("/api/contratos/:id/renovar", controllers.RenovarContrato)
protected.Post("/api/contratos/:id/enviar-correo", controllers.EnviarCorreoContrato)
+ protected.Get("/api/contratos/:id/historial", controllers.GetHistorialContrato)
// ─── Plantillas de correo ─────────────────────────────────────────
protected.Get("/plantillas-correo", middlewares.MenuMiddleware, controllers.PlantillasView)