This commit is contained in:
Lizandro Guarnizo
2026-05-12 19:41:17 -05:00
parent 55c68b470b
commit 25e3cd6254
9 changed files with 270 additions and 3 deletions
+17
View File
@@ -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
}
+13
View File
@@ -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
}
+30
View File
@@ -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 := &reglas[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).
@@ -77,6 +77,9 @@
<button @click="enviarCorreo(d)" title="Enviar correo" class="text-gray-400 hover:text-purple-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
</button>
<button @click="openHistorial(d)" title="Ver historial" class="text-gray-400 hover:text-indigo-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
</button>
<button @click="openDelete(d)" title="Eliminar" class="text-gray-400 hover:text-red-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/></svg>
</button>
@@ -174,6 +177,69 @@
</div>
</div>
<!-- Modal Historial -->
<div x-show="historialModal" x-cloak class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50">
<div class="bg-white rounded-t-2xl sm:rounded-xl shadow-2xl w-full sm:max-w-lg mx-0 sm:mx-4 flex flex-col max-h-[85vh]" @click.stop>
<div class="flex items-center justify-between px-5 py-4 border-b">
<div>
<h2 class="text-base font-semibold" x-text="historialTitulo"></h2>
<p class="text-xs text-gray-400">Línea de tiempo de actividad</p>
</div>
<button @click="historialModal=false" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="overflow-y-auto p-5 flex-1">
<!-- Loading -->
<div x-show="historialLoading" class="flex justify-center py-8">
<svg class="animate-spin w-6 h-6 text-indigo-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
</div>
<!-- Timeline -->
<div x-show="!historialLoading">
<div x-show="historialTimeline.length===0" class="text-center text-gray-400 py-8 text-sm">Sin actividad registrada</div>
<ol class="relative border-l border-gray-200 ml-3">
<template x-for="(ev, i) in historialTimeline" :key="i">
<li class="mb-6 ml-5">
<!-- Dot -->
<span class="absolute -left-3 flex items-center justify-center w-6 h-6 rounded-full ring-4 ring-white"
:class="{
'bg-indigo-100': ev.tipo==='creacion',
'bg-blue-100': ev.tipo==='renovacion',
'bg-purple-100': ev.tipo==='notificacion',
'bg-green-100': ev.tipo==='pago' && ev.estado==='ok',
'bg-red-100': ev.estado==='error'
}">
<!-- document -->
<template x-if="ev.icono==='document'">
<svg class="w-3 h-3 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
</template>
<!-- refresh -->
<template x-if="ev.icono==='refresh'">
<svg class="w-3 h-3 text-blue-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
</template>
<!-- mail -->
<template x-if="ev.icono==='mail'">
<svg class="w-3 h-3 text-purple-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
</template>
<!-- currency -->
<template x-if="ev.icono==='currency'">
<svg class="w-3 h-3 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z"/></svg>
</template>
</span>
<div class="ml-1">
<p class="text-sm font-medium text-gray-800" x-text="ev.titulo"></p>
<p class="text-xs text-gray-500 mt-0.5" x-text="ev.detalle"></p>
<time class="text-xs text-gray-400" x-text="fmtDatetime(ev.fecha)"></time>
<span x-show="ev.estado==='error'" class="ml-2 text-xs text-red-500 font-medium">Fallido</span>
</div>
</li>
</template>
</ol>
</div>
</div>
</div>
</div>
<!-- Modal Enviar Notificación -->
<div x-show="notifModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4 p-6" @click.stop>
@@ -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' });
+2 -1
View File
@@ -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)
}
}
}
+3 -2
View File
@@ -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)
}
}
}
+3
View File
@@ -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{
+117
View File
@@ -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":
+1
View File
@@ -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)