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 @@ + +
+
+
+
+

+

Línea de tiempo de actividad

+
+ +
+
+ +
+ +
+ +
+
Sin actividad registrada
+
    + +
+
+
+
+
+
@@ -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)