package controllers import ( "math" "strconv" "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) func ContratosView(c *fiber.Ctx) error { return c.Render("renovaciones/contratos", fiber.Map{ "user": c.Locals("user"), "modules": c.Locals("userModules"), }, "layouts/main") } func GetContratos(c *fiber.Ctx) error { page, _ := strconv.Atoi(c.Query("page", "1")) limit, _ := strconv.Atoi(c.Query("limit", "10")) if page < 1 { page = 1 } offset := (page - 1) * limit records, total, err := models.GetAllContratos(limit, offset, c.Query("search"), c.Query("estado")) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } // Enriquecer con días restantes type ContratoDTO struct { models.Contrato DiasRestantes int `json:"dias_restantes"` Urgencia string `json:"urgencia"` // verde | amarillo | rojo PrecioSugerido float64 `json:"precio_sugerido"` } dtos := make([]ContratoDTO, len(records)) now := time.Now() for i, r := range records { dias := int(r.FechaVencimiento.Sub(now).Hours() / 24) urgencia := "verde" if dias <= 0 { urgencia = "rojo" } else if dias <= 30 { urgencia = "amarillo" } var sugerido float64 for _, s := range r.Servicios { sugerido += s.Precio } dtos[i] = ContratoDTO{Contrato: r, DiasRestantes: dias, Urgencia: urgencia, PrecioSugerido: sugerido} } return c.JSON(fiber.Map{ "registros": dtos, "total": total, "totalPages": int(math.Ceil(float64(total) / float64(limit))), "page": page, "limit": limit, }) } func CreateContrato(c *fiber.Ctx) error { type Input struct { ClienteID uint `json:"cliente_id"` ServicioIDs []uint `json:"servicio_ids"` FechaInicio string `json:"fecha_inicio"` FechaVencimiento string `json:"fecha_vencimiento"` PrecioAcordado float64 `json:"precio_acordado"` AutoRenovar bool `json:"auto_renovar"` Notas string `json:"notas"` ServidorIDs []uint `json:"servidor_ids"` ConxDbIDs []uint `json:"conx_db_ids"` AiConfigIDs []uint `json:"ai_config_ids"` } var inp Input if err := c.BodyParser(&inp); err != nil { return c.Status(400).JSON(fiber.Map{"error": err.Error()}) } fi, err := time.Parse("2006-01-02", inp.FechaInicio) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Fecha inicio inválida"}) } fv, err := time.Parse("2006-01-02", inp.FechaVencimiento) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Fecha vencimiento inválida"}) } m := models.Contrato{ ClienteID: inp.ClienteID, FechaInicio: fi, FechaVencimiento: fv, PrecioAcordado: float64(inp.PrecioAcordado), AutoRenovar: inp.AutoRenovar, Notas: inp.Notas, Estado: "activo", } if inp.ServidorIDs == nil { inp.ServidorIDs = []uint{} } if inp.ConxDbIDs == nil { inp.ConxDbIDs = []uint{} } if inp.AiConfigIDs == nil { inp.AiConfigIDs = []uint{} } if err := models.CreateContrato(m, inp.ServicioIDs, inp.ServidorIDs, inp.ConxDbIDs, inp.AiConfigIDs); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } // Devolver el ID del contrato creado para que el frontend pueda enviar bienvenida created, _ := models.GetUltimoContratoByCliente(inp.ClienteID) contratoID := uint(0) if created != nil { contratoID = created.ID } return c.Status(201).JSON(fiber.Map{"message": "Contrato creado", "ok": true, "id": contratoID}) } func UpdateContrato(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"}) } type Input struct { ServicioIDs []uint `json:"servicio_ids"` Estado string `json:"estado"` FechaVencimiento string `json:"fecha_vencimiento"` PrecioAcordado float64 `json:"precio_acordado"` AutoRenovar bool `json:"auto_renovar"` Notas string `json:"notas"` ServidorIDs []uint `json:"servidor_ids"` ConxDbIDs []uint `json:"conx_db_ids"` AiConfigIDs []uint `json:"ai_config_ids"` } var inp Input if err := c.BodyParser(&inp); err != nil { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } existing, err := models.GetContratoByID(uint(id)) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) } if inp.FechaVencimiento != "" { fv, err := time.Parse("2006-01-02", inp.FechaVencimiento) if err == nil { existing.FechaVencimiento = fv } } if inp.Estado != "" { existing.Estado = inp.Estado } existing.PrecioAcordado = inp.PrecioAcordado existing.AutoRenovar = inp.AutoRenovar existing.Notas = inp.Notas if err := models.UpdateContrato(*existing, inp.ServicioIDs, inp.ServidorIDs, inp.ConxDbIDs, inp.AiConfigIDs); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) } func RenovarContrato(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"}) } existing, err := models.GetContratoByID(uint(id)) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) } // Usar periodicidad del primer servicio asociado periodicidad := "" if len(existing.Servicios) > 0 { periodicidad = existing.Servicios[0].Periodicidad } nuevaInicio := existing.FechaVencimiento.AddDate(0, 0, 1) nuevaVenc := calcularFechaVencimiento(nuevaInicio, periodicidad) // Copiar IDs de servicios del contrato anterior var servicioIDs []uint for _, s := range existing.Servicios { servicioIDs = append(servicioIDs, s.ID) } nuevo := models.Contrato{ ClienteID: existing.ClienteID, FechaInicio: nuevaInicio, FechaVencimiento: nuevaVenc, PrecioAcordado: existing.PrecioAcordado, AutoRenovar: existing.AutoRenovar, Estado: "activo", Notas: "Renovación automática desde contrato #" + strconv.Itoa(int(existing.ID)), } if err := models.CreateContrato(nuevo, servicioIDs, nil, nil, nil); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } // Marcar anterior como renovado existing.Estado = "renovado" models.UpdateContrato(*existing, nil, nil, nil, nil) return c.JSON(fiber.Map{"message": "Renovado", "ok": true}) } func EnviarCorreoContrato(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"}) } // Si se pasa regla_id, usar esa regla específica (EnviarNotificacionGrupo) var body struct { ReglaID uint `json:"regla_id"` } _ = c.BodyParser(&body) if body.ReglaID > 0 { regla, err := models.GetReglaByID(body.ReglaID) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "Regla no encontrada"}) } if err := services.EnviarNotificacionGrupo(regla, &contrato.Cliente, []models.Contrato{*contrato}, ""); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"message": "Correo enviado", "ok": true}) } // Sin regla_id → flujo manual genérico if err := services.EnviarCorreoManual(contrato); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"message": "Correo enviado", "ok": true}) } func DeleteContrato(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"}) } if err := models.DeleteContrato(uint(id)); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.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"` Enlace string `json:"enlace"` // URL de pago generada en este envío (opcional) 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: pago confirmado (directo del contrato, siempre visible si se pagó) if contrato.PagoConfirmado && contrato.FechaPago != nil { timeline = append(timeline, Evento{ Tipo: "pago", Icono: "currency", Titulo: "Pago confirmado", Detalle: "Nuevo vencimiento: " + contrato.FechaVencimiento.Format("02/01/2006"), Estado: "ok", FechaISO: contrato.FechaPago.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, Enlace: n.EnlacePago, 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": return desde.AddDate(0, 1, 0) case "trimestral": return desde.AddDate(0, 3, 0) case "semestral": return desde.AddDate(0, 6, 0) case "anual": return desde.AddDate(1, 0, 0) default: return desde.AddDate(1, 0, 0) } } // VerificarPagoBold consulta la API de Bold para un contrato específico y confirma el pago si está pagado. // POST /api/contratos/:id/verificar-pago func VerificarPagoBold(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 64) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } contrato, err := models.GetContratoParaVerificacion(uint(id)) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "Contrato no encontrado"}) } if contrato.PagoConfirmado { return c.JSON(fiber.Map{"ok": true, "confirmado": true, "mensaje": "El pago ya estaba confirmado"}) } if contrato.EnlacePagoLinkID == "" { return c.JSON(fiber.Map{"ok": false, "confirmado": false, "mensaje": "Este contrato no tiene enlace de pago generado"}) } boldCfg, err := models.GetBoldConfig() if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Sin configuración Bold activa"}) } paid, paymentID, monto, err := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Error consultando Bold: " + err.Error()}) } if !paid { return c.JSON(fiber.Map{"ok": true, "confirmado": false, "mensaje": "El enlace aún no ha sido pagado"}) } ref := "contrato-" + strconv.FormatUint(id, 10) if ok, _ := models.MarcarContratoPagado(uint(id)); ok { go services.EnviarCorreoConfirmacionPago(uint(id), "bold") } go models.EnrichBoldCallbackLog(ref, contrato.Cliente.Email, monto) notifID := "api-check-" + paymentID if paymentID != "" && !models.IsBoldNotificationDuplicate(notifID) { _ = models.SaveBoldWebhookLog(models.BoldWebhookLog{ NotificationID: notifID, Tipo: "API_CHECK", PaymentID: paymentID, Referencia: ref, PayerEmail: contrato.Cliente.Email, Monto: monto, Procesado: true, }) } return c.JSON(fiber.Map{"ok": true, "confirmado": true, "monto": monto, "mensaje": "¡Pago confirmado!"}) }