package services import ( "bytes" "encoding/json" "fmt" "log" "text/template" "time" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) // DatosPlantilla es el contexto inyectado al renderizar una plantilla de correo type DatosPlantilla struct { ClienteNombre string ClienteEmpresa string ClienteEmail string Servicios []ItemServicio Total float64 FechaVencimiento string DiasRestantes int Asunto string // EnlacePago es la URL de pago Bold para el ciclo actual. // Mismo link en todas las notificaciones hasta que el pago sea aprobado. EnlacePago string } type ItemServicio struct { Nombre string Precio float64 Moneda string FechaVenc string } // DatosEjemplo devuelve datos de prueba para la previsualización de plantillas func DatosEjemplo() DatosPlantilla { return DatosPlantilla{ ClienteNombre: "Juan Pérez", ClienteEmpresa: "Empresa Demo S.A.", ClienteEmail: "cliente@ejemplo.com", Servicios: []ItemServicio{ {Nombre: "Hosting Basic", Precio: 29.99, Moneda: "USD", FechaVenc: "2025-12-31"}, {Nombre: "Dominio .com", Precio: 14.99, Moneda: "USD", FechaVenc: "2025-12-31"}, }, Total: 44.98, FechaVencimiento: "31/12/2025", DiasRestantes: 15, Asunto: "Recordatorio de vencimiento", EnlacePago: "https://checkout.bold.co/payment/LNK_ejemplo123", } } // RenderPlantilla renderiza el CuerpoHTML de una PlantillaCorreo con los datos dados func RenderPlantilla(p *models.PlantillaCorreo, datos DatosPlantilla) (string, error) { tmpl, err := template.New("correo").Parse(p.CuerpoHTML) if err != nil { return "", fmt.Errorf("plantilla HTML inválida: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, datos); err != nil { return "", fmt.Errorf("error al renderizar plantilla: %w", err) } return buf.String(), nil } // EnviarCorreoPrueba envía la plantilla con datos de ejemplo al email dado func EnviarCorreoPrueba(email string, p *models.PlantillaCorreo) error { html, err := RenderPlantilla(p, DatosEjemplo()) if err != nil { return err } return app.Http.Mail.Send(email, "[PRUEBA] "+p.Asunto, html) } // EnviarCorreoManual envía correo de aviso para un contrato específico func EnviarCorreoManual(contrato *models.Contrato) error { // Usar la primera plantilla activa de tipo renovacion/vencimiento como fallback plantillas, err := models.GetAllPlantillasSelect() if err != nil || len(plantillas) == 0 { return fmt.Errorf("no hay plantillas de correo disponibles") } p, err := models.GetPlantillaByID(plantillas[0].ID) if err != nil { return err } dias := int(contrato.FechaVencimiento.Sub(time.Now()).Hours() / 24) var items []ItemServicio for _, s := range contrato.Servicios { items = append(items, ItemServicio{ Nombre: s.Nombre, Precio: s.Precio, Moneda: s.Moneda, FechaVenc: contrato.FechaVencimiento.Format("02/01/2006"), }) } datos := DatosPlantilla{ ClienteNombre: contrato.Cliente.Nombre, ClienteEmpresa: contrato.Cliente.Empresa, ClienteEmail: contrato.Cliente.Email, FechaVencimiento: contrato.FechaVencimiento.Format("02/01/2006"), DiasRestantes: dias, Total: contrato.PrecioAcordado, Servicios: items, } html, err := RenderPlantilla(p, datos) if err != nil { return err } return app.Http.Mail.Send(contrato.Cliente.Email, p.Asunto, html) } // EnviarNotificacionGrupo envía un correo agrupado para un cliente con múltiples contratos func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cliente, contratos []models.Contrato) error { p, err := models.GetPlantillaByID(regla.PlantillaID) if err != nil { return fmt.Errorf("plantilla no encontrada: %w", err) } var items []ItemServicio var total float64 var fechaVenc time.Time for _, c := range contratos { for _, s := range c.Servicios { items = append(items, ItemServicio{ Nombre: s.Nombre, Precio: s.Precio, Moneda: s.Moneda, FechaVenc: c.FechaVencimiento.Format("02/01/2006"), }) } total += c.PrecioAcordado if fechaVenc.IsZero() || c.FechaVencimiento.Before(fechaVenc) { fechaVenc = c.FechaVencimiento } } dias := int(fechaVenc.Sub(time.Now()).Hours() / 24) // Generar o reutilizar el enlace de pago para el primer contrato del grupo enlacePago := "" if len(contratos) > 0 { enlacePago = ObtenerOCrearEnlacePago(&contratos[0]) } datos := DatosPlantilla{ ClienteNombre: cliente.Nombre, ClienteEmpresa: cliente.Empresa, ClienteEmail: cliente.Email, Servicios: items, Total: total, FechaVencimiento: fechaVenc.Format("02/01/2006"), DiasRestantes: dias, Asunto: p.Asunto, EnlacePago: enlacePago, } html, err := RenderPlantilla(p, datos) if err != nil { return err } // Construir JSON de IDs de contratos para el log var ids []uint for _, c := range contratos { ids = append(ids, c.ID) } idsJSON, _ := json.Marshal(ids) // Crear log previo (pendiente) logEntry := models.NotificacionLog{ ClienteID: cliente.ID, ReglaID: regla.ID, ContratosIDs: string(idsJSON), FechaEnvio: time.Now(), Estado: "pendiente", Asunto: p.Asunto, PreviewHTML: html, } savedLog, err := models.CreateNotificacionLog(logEntry) if err != nil { log.Printf("Error guardando log de notificación: %v", err) } // Enviar correo (también CC si está definido) sendErr := app.Http.Mail.Send(cliente.Email, p.Asunto, html) if cliente.EmailCC != "" { _ = app.Http.Mail.Send(cliente.EmailCC, "[CC] "+p.Asunto, html) } // Actualizar estado del log if savedLog != nil { if sendErr != nil { savedLog.Estado = "fallido" savedLog.ErrorMsg = sendErr.Error() } else { savedLog.Estado = "enviado" } models.UpdateNotificacionLog(*savedLog) } return sendErr } // ReenviarLog reenvía un correo ya registrado en el historial func ReenviarLog(logEntry *models.NotificacionLog) error { if logEntry.PreviewHTML == "" { return fmt.Errorf("no hay HTML guardado para este envío") } sendErr := app.Http.Mail.Send(logEntry.Cliente.Email, logEntry.Asunto, logEntry.PreviewHTML) if sendErr != nil { logEntry.Estado = "fallido" logEntry.ErrorMsg = sendErr.Error() } else { logEntry.Estado = "enviado" logEntry.ErrorMsg = "" } models.UpdateNotificacionLog(*logEntry) return sendErr } // ObtenerOCrearEnlacePago devuelve el enlace Bold vigente del contrato. // Si ya existe uno (no ha sido pagado aún), lo reutiliza. // Si no existe, crea uno nuevo en Bold y lo guarda en el contrato. // Cuando Bold notifica SALE_APPROVED, llamar models.LimpiarEnlacePago(contratoID) // para que la próxima notificación genere un link nuevo. func ObtenerOCrearEnlacePago(contrato *models.Contrato) string { // Reutilizar si ya hay uno vigente if contrato.EnlacePago != "" { return contrato.EnlacePago } cfg, err := models.GetBoldConfig() if err != nil { log.Printf("[BOLD] ObtenerOCrearEnlacePago: sin config activa para contrato %d", contrato.ID) return "" } // Nombre de servicios en la descripción desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID) if len(contrato.Servicios) > 0 { desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre) } req := BoldPaymentLinkRequest{ AmountType: "CLOSE", Amount: BoldAmountField{ Currency: "COP", TotalAmount: int64(contrato.PrecioAcordado), }, Description: desc, Reference: fmt.Sprintf("contrato-%d", contrato.ID), CallbackURL: cfg.CallbackUrl, } result, err := CreateBoldPaymentLink(cfg, req) if err != nil { log.Printf("[BOLD] ObtenerOCrearEnlacePago: error creando link para contrato %d: %v", contrato.ID, err) return "" } // Persistir en BD _ = models.GuardarEnlacePago(contrato.ID, result.Payload.PaymentLink, result.Payload.URL) contrato.EnlacePago = result.Payload.URL contrato.EnlacePagoLinkID = result.Payload.PaymentLink return result.Payload.URL }