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>
This commit is contained in:
Lizandro GD
2026-08-03 03:20:00 +00:00
co-authored by Claude Opus 5
parent 5607836261
commit c8e5afceb2
26 changed files with 1163 additions and 235 deletions
+49 -36
View File
@@ -163,6 +163,43 @@ func Migrate() {
log.Println("Migration Completed...")
}
// asignarSubmodulosSiFaltan agrega submódulos a un rol solo si el rol todavía no
// tiene NINGUNO de ellos.
//
// Antes los seeds hacían Append incondicional en cada arranque: si un admin le
// quitaba un módulo a un rol desde el panel, el siguiente despliegue se lo
// devolvía y la gestión de permisos era, en la práctica, imposible de sostener.
func asignarSubmodulosSiFaltan(rol *models.Roles, subs []models.Submodules) {
if rol == nil || len(subs) == 0 {
return
}
db := app.Http.Database.DB
var ids []uint
for _, s := range subs {
if s.ID > 0 {
ids = append(ids, s.ID)
}
}
if len(ids) == 0 {
return
}
var yaAsignados int64
db.Table("roles_submodules").
Where("roles_id = ? AND submodules_id IN ?", rol.ID, ids).
Count(&yaAsignados)
if yaAsignados > 0 {
// El rol ya conoce este grupo de submódulos: lo que tenga hoy es una
// decisión del administrador y no se toca.
return
}
if err := db.Model(rol).Association("Submodules").Append(&subs); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol %s: %v", rol.Name, err)
}
}
// SeedStatuspage agrega el submódulo "Statuspage" (Atlassian) al módulo "Integraciones"
// y lo asigna a todos los roles. Es idempotente.
func SeedStatuspage() {
@@ -207,11 +244,7 @@ func SeedStatuspage() {
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulo Statuspage al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulo Statuspage asignado al rol '%s'", rol.Name)
}
asignarSubmodulosSiFaltan(&rol, insertados)
}
log.Println("[SEED] Seed de Statuspage completado.")
@@ -407,7 +440,7 @@ func SeedRenovaciones() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Renovaciones asignados al rol 'Administrador'")
}
@@ -481,7 +514,7 @@ func SeedAutomatizacionIA() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'")
}
@@ -555,11 +588,7 @@ func SeedIntegraciones() {
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos de Integraciones asignados al rol '%s'", rol.Name)
}
asignarSubmodulosSiFaltan(&rol, insertados)
}
log.Println("[SEED] Seed de Integraciones completado.")
@@ -609,11 +638,7 @@ func SeedPasarelas() {
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulo Pasarelas asignado al rol '%s'", rol.Name)
}
asignarSubmodulosSiFaltan(&rol, insertados)
}
log.Println("[SEED] Seed de Pasarelas completado.")
@@ -686,11 +711,7 @@ func SeedServidores() {
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos de Servidores asignados al rol '%s'", rol.Name)
}
asignarSubmodulosSiFaltan(&rol, insertados)
}
log.Println("[SEED] Seed de Servidores completado.")
@@ -757,11 +778,7 @@ func SeedAdministracion() {
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos de Administración asignados al rol '%s'", rol.Name)
}
asignarSubmodulosSiFaltan(&rol, insertados)
}
log.Println("[SEED] Seed de Administración completado.")
@@ -812,11 +829,7 @@ func SeedSaas() {
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos SaaS al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos SaaS asignados al rol '%s'", rol.Name)
}
asignarSubmodulosSiFaltan(&rol, insertados)
}
log.Println("[SEED] Seed de SaaS completado.")
@@ -888,7 +901,7 @@ func SeedTelegram() {
return
}
for _, rol := range roles {
db.Model(&rol).Association("Submodules").Append(&sub)
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
}
log.Println("[SEED] Seed de Telegram completado.")
}
@@ -946,7 +959,7 @@ func SeedPortalClientes() {
return
}
for _, rol := range roles {
db.Model(&rol).Association("Submodules").Append(&insertados)
asignarSubmodulosSiFaltan(&rol, insertados)
}
log.Println("[SEED] Seed de Portal de Clientes completado.")
}
@@ -1030,7 +1043,7 @@ func SeedPartnerRecursos() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&sub)
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Partner Recursos' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Partner Recursos completado.")
@@ -1084,7 +1097,7 @@ func SeedContabilidadMenu() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Contabilidad asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de Contabilidad completado.")