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. // Usa la primera regla activa para obtener la plantilla y la pasarela de pago; // si no hay regla, usa la primera plantilla disponible con pasarela "bold". func EnviarCorreoManual(contrato *models.Contrato) error { // Intentar obtener plantilla y pasarela desde la primera regla activa var p *models.PlantillaCorreo var reglaID uint gateway := "bold" reglas, err := models.GetReglasActivas() if err == nil && len(reglas) > 0 { regla := reglas[0] reglaID = regla.ID gateway = regla.PasarelaEnlace p, err = models.GetPlantillaByID(regla.PlantillaID) if err != nil { p = nil } } // Fallback: primera plantilla disponible if p == nil { plantillas, err2 := models.GetAllPlantillasSelect() if err2 != 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 } } // Generar o reutilizar el enlace de pago enlacePago := ObtenerOCrearEnlacePago(contrato, gateway) dias := int(time.Until(contrato.FechaVencimiento).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, EnlacePago: enlacePago, } html, err := RenderPlantilla(p, datos) if err != nil { return err } // Crear log previo (pendiente) idsJSON, _ := json.Marshal([]uint{contrato.ID}) logEntry := models.NotificacionLog{ ClienteID: contrato.ClienteID, ReglaID: reglaID, ContratosIDs: string(idsJSON), FechaEnvio: time.Now(), Estado: "pendiente", Asunto: p.Asunto, PreviewHTML: html, } savedLog, _ := models.CreateNotificacionLog(logEntry) sendErr := app.Http.Mail.Send(contrato.Cliente.Email, 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 } // EnviarNotificacionGrupo envía un correo agrupado para un cliente con múltiples contratos. // pasarela indica la pasarela de pago que originó el evento (bold | dlocal | ""). func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cliente, contratos []models.Contrato, pasarela string) 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(time.Until(fechaVenc).Hours() / 24) // Generar o reutilizar el enlace de pago para el primer contrato del grupo enlacePago := "" if len(contratos) > 0 { enlacePago = ObtenerOCrearEnlacePago(&contratos[0], regla.PasarelaEnlace) } 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, Pasarela: pasarela, EnlacePago: enlacePago, } 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 } // 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). // EnviarCorreoConfirmacionPago envía el correo de "pago recibido" para un contrato. // pasarela (opcional): "bold", "dlocal", etc. Se guarda en el historial del cliente. func EnviarCorreoConfirmacionPago(contratoID uint, pasarela ...string) { pas := "" if len(pasarela) > 0 { pas = pasarela[0] } 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] if err := EnviarNotificacionGrupo(regla, &contrato.Cliente, []models.Contrato{*contrato}, pas); 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 (pasarela: %s)", contratoID, contrato.Cliente.Email, pas) } } // 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). // Cuando el pago se aprueba, llamar models.LimpiarEnlacePago para el siguiente ciclo. func ObtenerOCrearEnlacePago(contrato *models.Contrato, gateway string) string { // Normalizar gateway vacío → bold por defecto if gateway == "" { gateway = "bold" } if gateway == "ninguna" { return "" } // Reutilizar si ya hay uno vigente if contrato.EnlacePago != "" { return contrato.EnlacePago } switch gateway { case "dlocal": return obtenerEnlaceDlocal(contrato) default: // "bold" return obtenerEnlaceBold(contrato) } } // obtenerEnlaceBold crea un payment link en Bold y lo persiste en el contrato. func obtenerEnlaceBold(contrato *models.Contrato) string { cfg, err := models.GetBoldConfig() if err != nil { log.Printf("[BOLD] sin config activa para contrato %d", contrato.ID) return "" } 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 + fmt.Sprintf("?ref=contrato-%d", contrato.ID), } result, err := CreateBoldPaymentLink(cfg, req) if err != nil { log.Printf("[BOLD] error creando link para contrato %d: %v", contrato.ID, err) return "" } _ = models.GuardarEnlacePago(contrato.ID, result.Payload.PaymentLink, result.Payload.URL) contrato.EnlacePago = result.Payload.URL contrato.EnlacePagoLinkID = result.Payload.PaymentLink return result.Payload.URL } // obtenerEnlaceDlocal crea un pago en dLocal y devuelve la URL de redirección. func obtenerEnlaceDlocal(contrato *models.Contrato) string { cfg, err := models.GetLastActiveDlocalApi() if err != nil { log.Printf("[DLOCAL] sin config activa para contrato %d", contrato.ID) return "" } desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID) if len(contrato.Servicios) > 0 { desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre) } pagoReq := PagoRequest{ Currency: "COP", Amount: contrato.PrecioAcordado, OrderID: int(contrato.ID), Description: desc, SuccessURL: cfg.UrlProd + "/pago-exitoso", BackURL: cfg.UrlProd + "/pago-cancelado", } body, err := CreatePago(*cfg, pagoReq) if err != nil { log.Printf("[DLOCAL] error creando pago para contrato %d: %v", contrato.ID, err) return "" } var resp struct { RedirectURL string `json:"redirect_url"` ID string `json:"id"` } if err := json.Unmarshal(body, &resp); err != nil || resp.RedirectURL == "" { log.Printf("[DLOCAL] respuesta sin redirect_url para contrato %d: %s", contrato.ID, string(body)) return "" } _ = models.GuardarEnlacePago(contrato.ID, resp.ID, resp.RedirectURL) contrato.EnlacePago = resp.RedirectURL contrato.EnlacePagoLinkID = resp.ID return resp.RedirectURL }