Files
soft_usite/pkg/models/contrato.go
T
Lizandro GDandClaude Opus 5 c8e5afceb2 fix: seguridad de pagos y accesos, integración PayPal y Coolify ampliado
Seguridad (crítico):
- Los webhooks de Bold y dLocal solo validaban la firma si el atacante la
  enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria.
- GET /pago-exitoso marcaba contratos como pagados leyendo un query param del
  navegador. Ahora solo muestra estado; la confirmación la hace la verificación
  contra la API de la pasarela o el webhook firmado.
- /uploads se servía como estático público: se descargaban RUTs, facturas y
  entregables sabiendo la ruta. Ahora exige sesión.
- Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:)
  y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora
  son configurables y el arranque se detiene si siguen con el valor publicado.
- .env y session.db salen del control de versiones.
- Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a
  administradores; antes bastaba con tener sesión.

Pasarelas de pago:
- dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en
  vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se
  enviaba notification_url, así que su webhook jamás se disparaba.
- PayPal solo tenía pantalla de configuración. Se implementa el servicio
  completo (OAuth, orden, captura, verificación de webhook) y queda
  seleccionable como pasarela.
- La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa
  cifra en pesos.

Contratos:
- pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación
  no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo.
- Los contratos vencidos nunca cambiaban de estado y recibían correo a diario
  de forma indefinida; ahora se cierran tras 30 días de gracia.

Otros:
- Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito.
  El agente pasa de 10 a cobertura completa (servicios, bases de datos,
  variables de entorno, proyectos, equipos y recursos de servidor).
- SeedBalanceData ya no corre en cada arranque (recreaba transacciones
  borradas); ahora se invoca con SEED_BALANCE=1.
- Los seeds dejan de devolver permisos revocados en cada despliegue.
- Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 03:20:00 +00:00

403 lines
15 KiB
Go

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
}