Files
soft_usite/rest/controllers/contrato_controller.go
T
2026-06-10 00:18:20 -05:00

445 lines
14 KiB
Go

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!"})
}