package models import ( "log" "strings" "time" "github.com/sujit-baniya/fiber-boilerplate/app" "gorm.io/gorm" ) type Contrato struct { gorm.Model ClienteID uint `json:"cliente_id" gorm:"column:cliente_id"` Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"` Servicios []Servicio `json:"servicios" gorm:"many2many:contrato_servicios"` FechaInicio time.Time `json:"fecha_inicio" gorm:"column:fecha_inicio"` FechaVencimiento time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"` PrecioAcordado float64 `json:"precio_acordado" gorm:"column:precio_acordado"` Moneda string `json:"moneda" gorm:"column:moneda;default:'COP'"` Estado string `json:"estado" gorm:"column:estado;default:'activo'"` // activo | vencido | cancelado | renovado AutoRenovar bool `json:"auto_renovar" gorm:"column:auto_renovar;default:false"` Notas string `json:"notas" gorm:"column:notas"` // Infraestructura asociada (opcional, múltiple) Servidores []Servidor `json:"servidores" gorm:"many2many:contrato_servidores"` ConxDBs []ConxDb `json:"conx_dbs" gorm:"many2many:contrato_conx_dbs"` AiConfigs []AiConfig `json:"ai_configs" gorm:"many2many:contrato_ai_configs"` // Enlace de pago Bold: único por ciclo de pago. // Se reutiliza en múltiples notificaciones del mismo ciclo. // Se anula (vacía) cuando SALE_APPROVED llega y se registra el pago. EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"` EnlacePagoLinkID string `json:"enlace_pago_link_id" gorm:"column:enlace_pago_link_id;type:varchar(64)"` // Confirmación de pago PagoConfirmado bool `json:"pago_confirmado" gorm:"column:pago_confirmado;default:false"` FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"` } func (Contrato) TableName() string { return "contratos" } func GetAllContratos(limit, offset int, search, estado string) ([]Contrato, int64, error) { var items []Contrato var total int64 db := app.Http.Database.DB.Model(&Contrato{}). Preload("Cliente").Preload("Servicios"). Preload("Servidores").Preload("ConxDBs").Preload("AiConfigs") if search != "" { db = db.Joins("JOIN clientes ON clientes.id = contratos.cliente_id"). Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ?", "%"+search+"%", "%"+search+"%") } if estado != "" { db = db.Where("contratos.estado = ?", estado) } if err := db.Count(&total).Error; err != nil { return nil, 0, err } if err := db.Order("contratos.fecha_vencimiento ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { return nil, 0, err } return items, total, nil } func GetContratoByID(id uint) (*Contrato, error) { var item Contrato if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").Preload("Servidores").Preload("ConxDBs").Preload("AiConfigs").First(&item, id).Error; err != nil { return nil, err } return &item, nil } // GetUltimoContratoByCliente devuelve el contrato más reciente de un cliente func GetUltimoContratoByCliente(clienteID uint) (*Contrato, error) { var item Contrato if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios"). Where("cliente_id = ?", clienteID). Order("id DESC").First(&item).Error; err != nil { return nil, err } return &item, nil } // GetContratosByCliente devuelve contratos de un cliente específico func GetContratosByCliente(clienteID uint) ([]Contrato, error) { var items []Contrato if err := app.Http.Database.DB.Preload("Servicios"). Where("cliente_id = ?", clienteID). Order("fecha_vencimiento ASC").Find(&items).Error; err != nil { return nil, err } return items, nil } // GetContratosProximosVencer retorna contratos activos que vencen exactamente en N días func GetContratosProximosVencer(diasAntes int) ([]Contrato, error) { var items []Contrato target := time.Now().AddDate(0, 0, diasAntes).UTC() startOfDay := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.UTC) endOfDay := startOfDay.Add(24 * time.Hour) if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios"). Where("estado = 'activo' AND fecha_vencimiento >= ? AND fecha_vencimiento < ?", startOfDay, endOfDay). Find(&items).Error; err != nil { log.Printf("Error getting contratos proximos: %v", err) return nil, err } return items, nil } // GetContratosYaVencidos retorna contratos cuya fecha de vencimiento es hoy o anterior // y su estado sigue siendo 'activo' (aún no han sido marcados como vencidos) func GetContratosYaVencidos() ([]Contrato, error) { var items []Contrato hoy := time.Now().UTC() startOfDay := time.Date(hoy.Year(), hoy.Month(), hoy.Day(), 0, 0, 0, 0, time.UTC) if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios"). Where("estado = 'activo' AND fecha_vencimiento < ?", startOfDay). Find(&items).Error; err != nil { log.Printf("Error getting contratos vencidos: %v", err) return nil, err } return items, nil } // GetContratosConEnlacePendiente devuelve contratos que tienen un enlace de pago Bold generado // pero cuyo pago aún no ha sido confirmado. Usado para polling periódico de la API de Bold. func GetContratosConEnlacePendiente() ([]Contrato, error) { var items []Contrato if err := app.Http.Database.DB.Preload("Cliente"). Where("enlace_pago_link_id != '' AND pago_confirmado = false AND deleted_at IS NULL"). Find(&items).Error; err != nil { return nil, err } return items, nil } // syncContratoServicios sincroniza la tabla join contrato_servicios usando SQL directo // para evitar que GORM intente hacer upsert de los servicios existentes. func syncContratoServicios(contratoID uint, servicioIDs []uint) error { db := app.Http.Database.DB if err := db.Exec("DELETE FROM contrato_servicios WHERE contrato_id = ?", contratoID).Error; err != nil { return err } for _, sid := range servicioIDs { if err := db.Exec( "INSERT INTO contrato_servicios (contrato_id, servicio_id) VALUES (?, ?) ON CONFLICT DO NOTHING", contratoID, sid, ).Error; err != nil { return err } } return nil } func syncContratoJoin(db *gorm.DB, table string, contratoID uint, ids []uint) error { if err := db.Exec("DELETE FROM "+table+" WHERE contrato_id = ?", contratoID).Error; err != nil { return err } // Derive FK column: "contrato_servidores" → "servidor_id", "contrato_conx_dbs" → "conx_db_id" entityPlural := strings.TrimPrefix(table, "contrato_") fkCol := strings.TrimSuffix(entityPlural, "s") + "_id" for _, id := range ids { if err := db.Exec("INSERT INTO "+table+" (contrato_id, "+fkCol+") VALUES (?, ?) ON CONFLICT DO NOTHING", contratoID, id, ).Error; err != nil { return err } } return nil } func CreateContrato(c Contrato, servicioIDs, servidorIDs, conxDBIDs, aiConfigIDs []uint) error { db := app.Http.Database.DB if err := db.Omit("Servicios.*", "Servidores.*", "ConxDBs.*", "AiConfigs.*").Create(&c).Error; err != nil { return err } if err := syncContratoServicios(c.ID, servicioIDs); err != nil { return err } if err := syncContratoJoin(db, "contrato_servidores", c.ID, servidorIDs); err != nil { return err } if err := syncContratoJoin(db, "contrato_conx_dbs", c.ID, conxDBIDs); err != nil { return err } return syncContratoJoin(db, "contrato_ai_configs", c.ID, aiConfigIDs) } func UpdateContrato(c Contrato, servicioIDs, servidorIDs, conxDBIDs, aiConfigIDs []uint) error { db := app.Http.Database.DB if err := db.Model(&Contrato{}).Where("id = ?", c.ID).Updates(map[string]interface{}{ "cliente_id": c.ClienteID, "fecha_inicio": c.FechaInicio, "fecha_vencimiento": c.FechaVencimiento, "precio_acordado": c.PrecioAcordado, "moneda": c.Moneda, "estado": c.Estado, "auto_renovar": c.AutoRenovar, "notas": c.Notas, }).Error; err != nil { return err } if servicioIDs != nil { if err := syncContratoServicios(c.ID, servicioIDs); err != nil { return err } } if servidorIDs != nil { if err := syncContratoJoin(db, "contrato_servidores", c.ID, servidorIDs); err != nil { return err } } if conxDBIDs != nil { if err := syncContratoJoin(db, "contrato_conx_dbs", c.ID, conxDBIDs); err != nil { return err } } if aiConfigIDs != nil { if err := syncContratoJoin(db, "contrato_ai_configs", c.ID, aiConfigIDs); err != nil { return err } } return nil } func DeleteContrato(id uint) error { db := app.Http.Database.DB var c Contrato if err := db.First(&c, id).Error; err != nil { return err } db.Exec("DELETE FROM contrato_servicios WHERE contrato_id = ?", id) db.Exec("DELETE FROM contrato_servidores WHERE contrato_id = ?", id) db.Exec("DELETE FROM contrato_conx_dbs WHERE contrato_id = ?", id) db.Exec("DELETE FROM contrato_ai_configs WHERE contrato_id = ?", id) return db.Delete(&c).Error } // GuardarEnlacePago persiste el enlace de cobro de un ciclo en el contrato. // // Generar un enlace nuevo significa que empieza un ciclo de cobro nuevo, así que // también se reinicia pago_confirmado. Sin esto el flag quedaba en true para // siempre desde el primer pago: en la segunda renovación el cliente pagaba, pero // GetContratosConEnlacePendiente lo excluía del polling y MarcarContratoPagado // salía por "ya estaba pagado" sin extender la fecha de vencimiento. func GuardarEnlacePago(contratoID uint, linkID, url string) error { return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{ "enlace_pago": url, "enlace_pago_link_id": linkID, "pago_confirmado": false, "fecha_pago": nil, }).Error } // MarcarContratosVencidos pasa a estado "vencido" los contratos activos sin // auto-renovación cuya fecha de vencimiento quedó atrás hace más de los días de // gracia indicados. // // Sin esto ningún contrato salía nunca de "activo", y el cron de vencidos les // seguía mandando correo con enlace de pago todos los días indefinidamente. func MarcarContratosVencidos(diasGracia int) (int64, error) { limite := time.Now().AddDate(0, 0, -diasGracia) result := app.Http.Database.DB.Model(&Contrato{}). Where("estado = ? AND fecha_vencimiento < ? AND pago_confirmado = ?", "activo", limite, false). Update("estado", "vencido") if result.Error != nil { return 0, result.Error } if result.RowsAffected > 0 { log.Printf("[CONTRATOS] %d contrato(s) pasaron a estado 'vencido' tras %d días sin pago", result.RowsAffected, diasGracia) } return result.RowsAffected, nil } // LimpiarEnlacePago borra el enlace vigente cuando se registra un pago aprobado, // para que la próxima notificación genere un link nuevo. func LimpiarEnlacePago(contratoID uint) error { return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{ "enlace_pago": "", "enlace_pago_link_id": "", }).Error } // periodicidadAMeses convierte el string de periodicidad en número de meses. func periodicidadAMeses(p string) int { switch p { case "mensual": return 1 case "trimestral": return 3 case "semestral": return 6 case "anual": return 12 default: return 12 } } // MarcarContratoPagado marca el contrato como pagado y renueva la fecha de vencimiento. // Retorna (true, nil) si el contrato pasó de no-pagado a pagado en esta llamada. // Retorna (false, nil) si ya estaba pagado (idempotente). // WHERE pago_confirmado = false garantiza atomicidad ante llamadas concurrentes. func MarcarContratoPagado(contratoID uint) (bool, error) { now := time.Now() // Cargar contrato con servicios para calcular nueva fecha de vencimiento var c Contrato if err := app.Http.Database.DB.Preload("Servicios").First(&c, contratoID).Error; err != nil { log.Printf("[PAGO] No se pudo cargar contrato %d para renovar: %v", contratoID, err) // Fallback: solo marcar pago sin renovar fecha result := app.Http.Database.DB.Model(&Contrato{}). Where("id = ? AND pago_confirmado = false", contratoID). Updates(map[string]interface{}{ "pago_confirmado": true, "fecha_pago": now, "enlace_pago": "", "enlace_pago_link_id": "", }) return result.RowsAffected > 0, result.Error } // Ya estaba pagado — nada que hacer if c.PagoConfirmado { return false, nil } updates := map[string]interface{}{ "pago_confirmado": true, "fecha_pago": now, "enlace_pago": "", "enlace_pago_link_id": "", "estado": "activo", } // Buscar el servicio renovable con mayor período para calcular nueva fecha meses := 0 for _, s := range c.Servicios { if s.Tipo == "renovable" { if m := periodicidadAMeses(s.Periodicidad); m > meses { meses = m } } } if meses > 0 { // Extender desde la fecha de vencimiento actual; si ya venció, extender desde hoy base := c.FechaVencimiento if base.Before(now) { base = now } nuevaFecha := base.AddDate(0, meses, 0) updates["fecha_vencimiento"] = nuevaFecha log.Printf("[PAGO] Contrato %d renovado: %s → %s (%d meses)", contratoID, c.FechaVencimiento.Format("02/01/2006"), nuevaFecha.Format("02/01/2006"), meses, ) } result := app.Http.Database.DB.Model(&Contrato{}). Where("id = ? AND pago_confirmado = false", contratoID). Updates(updates) return result.RowsAffected > 0, result.Error } // ConfirmarPagoManual marca el pago como confirmado sin modificar estado ni fecha_vencimiento. // Útil cuando el admin ya ajustó manualmente el contrato y solo necesita limpiar el enlace pendiente. func ConfirmarPagoManual(contratoID uint) (bool, error) { now := time.Now() result := app.Http.Database.DB.Model(&Contrato{}). Where("id = ? AND pago_confirmado = false", contratoID). Updates(map[string]interface{}{ "pago_confirmado": true, "fecha_pago": now, "enlace_pago": "", "enlace_pago_link_id": "", }) return result.RowsAffected > 0, result.Error } // GetEstadoPago devuelve si el contrato tiene pago confirmado (para polling desde el frontend). func GetEstadoPago(contratoID uint) (bool, *time.Time, error) { var c Contrato if err := app.Http.Database.DB.Select("pago_confirmado", "fecha_pago").First(&c, contratoID).Error; err != nil { return false, nil, err } return c.PagoConfirmado, c.FechaPago, nil } // GetContratoParaVerificacion devuelve los campos necesarios para verificar el pago directamente // con la pasarela (pago_confirmado, enlace_pago_link_id). Preload de Cliente para poder usar el email. func GetContratoParaVerificacion(contratoID uint) (*Contrato, error) { var c Contrato if err := app.Http.Database.DB. Preload("Cliente"). Select("contratos.id", "contratos.pago_confirmado", "contratos.fecha_pago", "contratos.enlace_pago_link_id", "contratos.cliente_id"). First(&c, contratoID).Error; err != nil { return nil, err } return &c, nil }