From c8e5afceb29bf523b8ddd7d30dd7488d79981eef Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 3 Aug 2026 03:20:00 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20seguridad=20de=20pagos=20y=20accesos,=20?= =?UTF-8?q?integraci=C3=B3n=20PayPal=20y=20Coolify=20ampliado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env | 27 -- .gitignore | 6 +- SEGURIDAD_DESPLIEGUE.md | 81 +++++ config/config.go | 3 + config/secrets_check.go | 64 ++++ config/token.go | 19 +- config/uploads_guard.go | 25 ++ main.go | 20 +- migrations/migrate.go | 85 ++--- pkg/models/contrato.go | 30 +- pkg/services/cron_service.go | 18 +- pkg/services/dlocal_service.go | 20 +- pkg/services/factura_attachment_service.go | 7 +- pkg/services/paypal_service.go | 353 +++++++++++++++++++++ pkg/services/renovacion_service.go | 57 +++- pkg/services/telegram_agent_service.go | 281 +++++++++++----- pkg/services/telegram_service.go | 11 +- resources/views/renovaciones/reglas.html | 1 + rest/controllers/api/bold_controller.go | 14 +- rest/controllers/api/dlocal_controller.go | 16 +- rest/controllers/api/pago_controller.go | 66 ++-- rest/controllers/api/paypal_controller.go | 102 ++++++ rest/middlewares/solo_admin.go | 37 +++ rest/routes/publicas.go | 3 + rest/routes/user.go | 52 +-- session.db | Bin 24576 -> 0 bytes 26 files changed, 1163 insertions(+), 235 deletions(-) delete mode 100755 .env create mode 100644 SEGURIDAD_DESPLIEGUE.md create mode 100644 config/secrets_check.go create mode 100644 config/uploads_guard.go create mode 100644 pkg/services/paypal_service.go create mode 100644 rest/controllers/api/paypal_controller.go create mode 100644 rest/middlewares/solo_admin.go delete mode 100755 session.db diff --git a/.env b/.env deleted file mode 100755 index 1df0764..0000000 --- a/.env +++ /dev/null @@ -1,27 +0,0 @@ -#VITE_API_URL= -#VITE_WS_URL= - -APP_URL=https://admin.u-site.app - -DB_DRIVER=postgres -DB_HOST=t2yq39od6o6cgxaakw6wbu9v -DB_PORT=5432 -DB_USER=usite -DB_PASS=DXPc2nzKWurNThEaWffGFuy47b65CWV4O9X75td2uOGj3kFLimOgLppWplYbx5Fu -DB_NAME=usite - - -MAIL_HOST=smtp.hostinger.com -MAIL_USERNAME=soporte@u-s.app -MAIL_PASSWORD=Nicolas2796*+ -MAIL_ENCRYPTION=TLS -MAIL_FROM_ADDRESS=soporte@u-s.app -MAIL_FROM_NAME=soporte@u-s.app -MAIL_PORT=465 - -APP_PREFORK=false - -SESSION_DATABASE=./session.db - -HERMES_API_KEY=hk_live_usite_2026_S3cur3K3yH3rm3s - diff --git a/.gitignore b/.gitignore index 3529cfd..ed66a3d 100755 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ main /tmp /node_modules/ *.fiber.gz -#.env +.env +.env.local .DS_Store dump.rdb #uploads @@ -17,4 +18,5 @@ stats/ pnpm-lock.yaml package-lock.json .task-project -*.db \ No newline at end of file +*.db +session.db diff --git a/SEGURIDAD_DESPLIEGUE.md b/SEGURIDAD_DESPLIEGUE.md new file mode 100644 index 0000000..bb3cc59 --- /dev/null +++ b/SEGURIDAD_DESPLIEGUE.md @@ -0,0 +1,81 @@ +# Variables de entorno requeridas + +> **Leer antes del siguiente despliegue.** El arranque falla a propósito si los +> secretos JWT siguen teniendo el valor que estuvo publicado en el repositorio. + +## 1. Secretos que hay que definir sí o sí + +`.env` y `config.yml` estuvieron versionados con credenciales reales, así que +**todo lo que aparecía ahí debe considerarse comprometido y rotarse**, no solo +sacarse del repositorio: el historial de git lo sigue conteniendo. + +Genera cada valor con `openssl rand -hex 32` y cárgalos como variables de +entorno del contenedor (en Coolify: *Environment Variables*): + +| Variable | Para qué sirve | Si no se define | +|---|---|---| +| `APP_JWT_SECRET` | Firma las cookies de sesión del panel | **El arranque se detiene** | +| `API_JWT_SECRET` | Firma los tokens de la API v1 | **El arranque se detiene** | +| `APP_KEY` | Cifra las contraseñas de SMTP e integraciones | Arranca, pero avisa en los logs | +| `ADMIN_API_KEY` | Única llave de toda la API `/api/v2` | La API v2 responde 503 | + +Al rotar `APP_JWT_SECRET` se cierran las sesiones abiertas: hay que volver a +iniciar sesión, nada más. + +### Cuidado con `APP_KEY` + +`APP_KEY` cifra las contraseñas guardadas en la base de datos. Si la cambias, +**las contraseñas cifradas con la clave anterior dejan de poder descifrarse** y +hay que volver a guardarlas desde el panel: + +- Configuración SMTP (`/app/smtp-config`) +- Credenciales de las pasarelas de pago (`/app/pasarelas`) +- Cualquier otra integración con contraseña + +Por eso el sistema solo advierte en vez de detenerse: para que elijas el momento. + +## 2. Otras credenciales a rotar + +Estaban en el `.env` versionado: + +- Contraseña de PostgreSQL +- Contraseña del correo saliente +- Cualquier token de integración (Coolify, Cloudflare, Hostinger, Telegram…) + +## 3. Webhooks de pago + +Las notificaciones ahora **exigen firma válida**. Verifica en cada proveedor que +el secreto de firma coincida con el configurado en `/app/pasarelas`: + +| Pasarela | URL del webhook | Requiere | +|---|---|---| +| Bold | `https://TU-DOMINIO/webhooks/bold` | Secret de firma en la config | +| dLocal | `https://TU-DOMINIO/webhooks/dlocal` | Secret de firma en la config | +| PayPal | `https://TU-DOMINIO/webhooks/paypal` | **Webhook ID** en la config | + +Para PayPal hay que crear el webhook en el panel de PayPal suscrito a los +eventos `CHECKOUT.ORDER.APPROVED` y `PAYMENT.CAPTURE.COMPLETED`, y pegar el +Webhook ID que devuelve en `/app/pasarelas`. Sin ese ID no se pueden verificar +las notificaciones y se rechazan. + +> PayPal no opera en pesos colombianos. Los contratos que se cobren por PayPal +> deben tener la moneda en USD (u otra soportada), o PayPal rechazará la orden. + +## 4. Datos contables de ejemplo + +`SeedBalanceData` (27 transacciones de 2026 escritas en el código) ya **no** +corre en cada arranque: si el contador editaba o borraba una, el siguiente +reinicio la recreaba y el balance quedaba duplicado. + +Para cargarla puntualmente: + +```bash +SEED_BALANCE=1 ./apiv2 -config config.yml +``` + +## 5. Archivos subidos + +`/uploads` ya no es público. Antes, sabiendo la ruta se podía descargar el RUT de +un cliente o una factura sin iniciar sesión. Ahora requiere sesión en el panel; +los clientes del portal siguen descargando sus documentos por los endpoints de +siempre, que además validan que el archivo les pertenezca. diff --git a/config/config.go b/config/config.go index ae24f3b..5d1b469 100755 --- a/config/config.go +++ b/config/config.go @@ -40,6 +40,7 @@ func (cfg *AppConfig) Setup() { fmt.Println(err) os.Exit(2) } + cfg.VerificarSecretos() cfg.Server.LoadPath() cfg.View.Load(cfg.Server.Path) cfg.Mail.View = &cfg.View @@ -94,6 +95,8 @@ func (cfg *AppConfig) LoadComponents() { func (cfg *AppConfig) LoadStatic() { cfg.Server.Static("/websocket", "./resources/views/websocket.html") + // El guard se registra antes que el estático para que corra primero. + cfg.Server.Use("/uploads", uploadsProtegidos) cfg.Server.Static("/uploads", "./uploads", fiber.Static{ ByteRange: true, }) diff --git a/config/secrets_check.go b/config/secrets_check.go new file mode 100644 index 0000000..e93b7dd --- /dev/null +++ b/config/secrets_check.go @@ -0,0 +1,64 @@ +package config + +import ( + "log" + "os" + "strings" +) + +// Valores que estuvieron publicados en el repositorio y deben considerarse +// comprometidos. Si siguen en uso, cualquiera que haya visto el repo puede +// fabricarse una sesión de administrador o descifrar las contraseñas guardadas. +const ( + jwtAppComprometido = "SECRET_APP" + jwtApiComprometido = "SECRET_API" + appKeyComprometida = "1894cde6c936a294a478cff0a9227fd276d86df6573b51af5dc59c9064edf426" +) + +// VerificarSecretos se ejecuta al arrancar. Los secretos JWT comprometidos +// abortan el arranque (permiten suplantar a cualquier usuario y el único costo +// de rotarlos es volver a iniciar sesión). La clave de cifrado solo advierte, +// porque cambiarla invalida las contraseñas de integraciones ya guardadas y eso +// requiere volver a capturarlas a mano desde el panel. +func (cfg *AppConfig) VerificarSecretos() { + var criticos []string + + if esSecretoDebil(cfg.Token.AppJwtSecret, jwtAppComprometido) { + criticos = append(criticos, "APP_JWT_SECRET") + } + if esSecretoDebil(cfg.Token.ApiJwtSecret, jwtApiComprometido) { + criticos = append(criticos, "API_JWT_SECRET") + } + + if len(criticos) > 0 { + log.Printf("[SEGURIDAD] Los siguientes secretos siguen con el valor publicado en el repositorio: %s", strings.Join(criticos, ", ")) + log.Printf("[SEGURIDAD] Con ese valor cualquiera puede firmarse una cookie de sesión y entrar como administrador.") + log.Printf("[SEGURIDAD] Define variables de entorno con valores nuevos y aleatorios antes de arrancar, por ejemplo:") + for _, nombre := range criticos { + log.Printf("[SEGURIDAD] %s=$(openssl rand -hex 32)", nombre) + } + log.Fatalf("[SEGURIDAD] Arranque abortado para no exponer el sistema. Al rotarlos, las sesiones abiertas se cierran y hay que volver a iniciar sesión.") + } + + if cfg.Server.Key == "" || cfg.Server.Key == appKeyComprometida { + log.Printf("[SEGURIDAD] APP_KEY tiene el valor por defecto publicado en el repositorio.") + log.Printf("[SEGURIDAD] Con esa clave, cualquiera con acceso a la base de datos puede descifrar las contraseñas SMTP y de integraciones.") + log.Printf("[SEGURIDAD] Define APP_KEY=$(openssl rand -hex 32) y vuelve a guardar las contraseñas de SMTP e integraciones desde el panel") + log.Printf("[SEGURIDAD] (al cambiar la clave, las contraseñas cifradas con la anterior dejan de poder descifrarse).") + } +} + +// esSecretoDebil indica si el secreto está vacío o es el valor comprometido. +func esSecretoDebil(valor, comprometido string) bool { + v := strings.TrimSpace(valor) + return v == "" || v == comprometido +} + +// SecretoDesdeEntorno lee un secreto del entorno con un valor por defecto. +// Se usa para credenciales que no deben vivir en config.yml. +func SecretoDesdeEntorno(nombre, porDefecto string) string { + if v := strings.TrimSpace(os.Getenv(nombre)); v != "" { + return v + } + return porDefecto +} diff --git a/config/token.go b/config/token.go index e10b94a..8ec9c20 100755 --- a/config/token.go +++ b/config/token.go @@ -9,13 +9,16 @@ import ( ) type Token struct { - Hash string `json:"token"` - Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"` - AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret"` - ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret"` + Hash string `json:"token"` + Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"` + // Los secretos JWT firman las cookies de sesión: si se filtran, cualquiera + // puede fabricarse una sesión de administrador válida. El tag env: permite + // definirlos por variable de entorno sin dejarlos escritos en config.yml. + AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret" env:"APP_JWT_SECRET"` + ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret" env:"API_JWT_SECRET"` } -//CreateToken authenticates the user +// CreateToken authenticates the user func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...int64) (*Token, error) { token := jwt.New(jwt.SigningMethodHS256) @@ -47,7 +50,7 @@ func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ... return t, nil } -//ParseToken returns the users id or error +// ParseToken returns the users id or error func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) { tokenString := c.Cookies("Verify-Rest-Token") @@ -74,12 +77,12 @@ func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) { return uint(claims["id"].(float64)), nil } -//DeleteToken deletes the jwt token +// DeleteToken deletes the jwt token func (t *Token) DeleteToken(c *fiber.Ctx) { c.ClearCookie("Verify-Rest-Token") } -//RefreshToken refreshes the token +// RefreshToken refreshes the token func (t *Token) RefreshToken(c *fiber.Ctx, secret string) (*Token, error) { u, err := t.ParseToken(c, secret) diff --git a/config/uploads_guard.go b/config/uploads_guard.go new file mode 100644 index 0000000..ee10f2c --- /dev/null +++ b/config/uploads_guard.go @@ -0,0 +1,25 @@ +package config + +import "github.com/gofiber/fiber/v2" + +// UploadsGuard decide si una petición puede leer archivos de /uploads. +// Se inyecta desde main.go porque el paquete config no puede importar pkg/auth +// (sería una dependencia circular vía app). Se consulta en cada petición, no al +// registrar la ruta, así que puede definirse después de cargar la configuración. +// +// Mientras esté en nil, /uploads queda cerrado: es preferible que un archivo no +// cargue a que quede expuesto sin autenticación. +var UploadsGuard func(*fiber.Ctx) bool + +// uploadsProtegidos bloquea el acceso anónimo a los archivos subidos. +// Antes /uploads se servía como estático público: sabiendo (o adivinando) la +// ruta se podía descargar el RUT de un cliente, una factura o un entregable sin +// iniciar sesión. Los clientes del portal siguen usando los endpoints de +// descarga dedicados, que además validan que el archivo les pertenezca. +func uploadsProtegidos(c *fiber.Ctx) error { + if UploadsGuard != nil && UploadsGuard(c) { + return c.Next() + } + // 404 en vez de 401 para no confirmar si el archivo existe. + return c.Status(fiber.StatusNotFound).SendString("Not found") +} diff --git a/main.go b/main.go index 7ebf5b4..f914d3e 100755 --- a/main.go +++ b/main.go @@ -3,11 +3,15 @@ package main import ( "flag" "log" + "os" + "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/pyroscope-io/pyroscope/pkg/agent/profiler" "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/config" "github.com/sujit-baniya/fiber-boilerplate/migrations" + "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" "github.com/sujit-baniya/fiber-boilerplate/rest/routes" @@ -33,6 +37,13 @@ func main() { // Actualizar versión del servidor app.Http.Server.Version = app.Version + // Los archivos de /uploads dejan de ser públicos: solo se sirven a usuarios + // con sesión iniciada en el panel. Los clientes del portal siguen bajando sus + // documentos por los endpoints dedicados, que validan que les pertenezcan. + config.UploadsGuard = func(c *fiber.Ctx) bool { + return auth.IsLoggedIn(c) + } + // Habilitar CORS app.Http.Server.App.Use(cors.New(cors.Config{ AllowOrigins: "*", @@ -146,7 +157,14 @@ func main() { migrations.SeedShield() migrations.SeedPartnerRecursos() models.SeedContabilidad() - models.SeedBalanceData() + // SeedBalanceData carga transacciones contables reales de 2026. Ya no se + // ejecuta en cada arranque: si el contador editaba o borraba una de esas + // filas, el siguiente reinicio la recreaba y el balance quedaba duplicado. + // Para recargarla puntualmente: SEED_BALANCE=1 ./apiv2 + if os.Getenv("SEED_BALANCE") == "1" { + log.Println("[SEED] SEED_BALANCE=1 — cargando datos de balance") + models.SeedBalanceData() + } migrations.SeedContabilidadMenu() migrations.SeedWebSms() migrations.SeedUrlMonitor() diff --git a/migrations/migrate.go b/migrations/migrate.go index 63e67ad..37bffbc 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -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.") diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 8dcaa51..044e519 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -236,14 +236,42 @@ func DeleteContrato(id uint) error { return db.Delete(&c).Error } -// GuardarEnlacePago persiste el enlace de pago Bold en el contrato. +// 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 { diff --git a/pkg/services/cron_service.go b/pkg/services/cron_service.go index c5af18a..d04e0de 100644 --- a/pkg/services/cron_service.go +++ b/pkg/services/cron_service.go @@ -109,10 +109,20 @@ func ProcesarVencimientosProximos() { log.Println("[CRON] Vencimientos próximos finalizado") } +// diasGraciaVencimiento es cuántos días se sigue insistiendo a un contrato ya +// vencido antes de darlo por perdido y dejar de notificar. +const diasGraciaVencimiento = 30 + // ProcesarYaVencidos procesa reglas de tipo "ya_vencido" func ProcesarYaVencidos() { log.Println("[CRON] Procesando contratos ya vencidos...") + // Cerrar primero los que llevan demasiado tiempo vencidos sin pagar, para que + // dejen de recibir el correo diario de forma indefinida. + if _, err := models.MarcarContratosVencidos(diasGraciaVencimiento); err != nil { + log.Printf("[CRON] Error marcando contratos vencidos: %v", err) + } + reglas, err := models.GetReglasByTipoEvento("ya_vencido") if err != nil { log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err) @@ -280,8 +290,12 @@ func VerificarSaludServidores() { // 2. Recursos (CPU / RAM / Disco) al límite if srv.MetricasJson != "" { var m struct { - CPU struct{ Porcentaje float64 `json:"porcentaje"` } `json:"cpu"` - RAM struct{ Porcentaje float64 `json:"porcentaje"` } `json:"ram"` + CPU struct { + Porcentaje float64 `json:"porcentaje"` + } `json:"cpu"` + RAM struct { + Porcentaje float64 `json:"porcentaje"` + } `json:"ram"` Discos []struct { Porcentaje float64 `json:"porcentaje"` Ruta string `json:"ruta"` diff --git a/pkg/services/dlocal_service.go b/pkg/services/dlocal_service.go index 0594970..fb70aed 100644 --- a/pkg/services/dlocal_service.go +++ b/pkg/services/dlocal_service.go @@ -83,13 +83,17 @@ type PlanUpdated struct { } type PagoRequest struct { - Currency string `json:"currency"` - Amount float64 `json:"amount"` - OrderID int `json:"order_id"` - Description string `json:"description"` - SuccessURL string `json:"success_url"` - BackURL string `json:"back_url"` - NotificationURL string `json:"notification_url"` + Currency string `json:"currency"` + Amount float64 `json:"amount"` + // OrderID es la referencia con la que después se reconcilia el pago. + // Debe ir en formato "contrato-{id}": es lo que espera el webhook + // (dlocal_controller.go) y la verificación por API para vincular el pago con + // su contrato. Antes se enviaba el ID numérico y el pago nunca se asociaba. + OrderID string `json:"order_id"` + Description string `json:"description"` + SuccessURL string `json:"success_url"` + BackURL string `json:"back_url"` + NotificationURL string `json:"notification_url"` } // Llama al endpoint /v1/me de DLocal usando la configuración @@ -117,7 +121,7 @@ func GetDLocalMe(cfg dlocalConfig) ([]byte, error) { req.Header.Set("Authorization", authToken) // Ejecutar la solicitud - client := &http.Client{} + client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err diff --git a/pkg/services/factura_attachment_service.go b/pkg/services/factura_attachment_service.go index 114a0e1..431b8b5 100644 --- a/pkg/services/factura_attachment_service.go +++ b/pkg/services/factura_attachment_service.go @@ -22,6 +22,9 @@ type TelegramAttachment struct { StagedAt time.Time } +// adjuntoHTTPClient limita cuánto se espera al descargar un archivo de Telegram. +var adjuntoHTTPClient = &http.Client{Timeout: 60 * time.Second} + const telegramAttachmentTTL = 15 * time.Minute var ( @@ -33,7 +36,7 @@ var ( // la API getFile + el endpoint de descarga, y lo deja en un directorio temporal. func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string) (string, error) { getFileURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, fileID) - resp, err := http.Get(getFileURL) //nolint:noctx + resp, err := adjuntoHTTPClient.Get(getFileURL) //nolint:noctx if err != nil { return "", fmt.Errorf("no se pudo consultar el archivo en Telegram: %w", err) } @@ -50,7 +53,7 @@ func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string) } downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, result.Result.FilePath) - fresp, err := http.Get(downloadURL) //nolint:noctx + fresp, err := adjuntoHTTPClient.Get(downloadURL) //nolint:noctx if err != nil { return "", fmt.Errorf("no se pudo descargar el archivo: %w", err) } diff --git a/pkg/services/paypal_service.go b/pkg/services/paypal_service.go new file mode 100644 index 0000000..d9b60bf --- /dev/null +++ b/pkg/services/paypal_service.go @@ -0,0 +1,353 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Endpoints de PayPal ────────────────────────────────────────────────────── + +const ( + paypalBaseSandbox = "https://api-m.sandbox.paypal.com" + paypalBaseLive = "https://api-m.paypal.com" +) + +// paypalCredenciales devuelve la URL base y las credenciales según el modo activo. +func paypalCredenciales(cfg *models.PaypalConfig) (baseURL, clientID, clientSecret string) { + if strings.EqualFold(cfg.Modo, "live") { + return paypalBaseLive, cfg.ClientIDProd, cfg.ClientSecretProd + } + return paypalBaseSandbox, cfg.ClientIDSandbox, cfg.ClientSecretSandbox +} + +var paypalHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// ─── Autenticación ──────────────────────────────────────────────────────────── + +type paypalTokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// PaypalAccessToken pide un token OAuth2 con las credenciales de la config activa. +func PaypalAccessToken(cfg *models.PaypalConfig) (string, error) { + baseURL, clientID, clientSecret := paypalCredenciales(cfg) + if clientID == "" || clientSecret == "" { + return "", fmt.Errorf("PayPal: faltan credenciales para el modo '%s'", cfg.Modo) + } + + form := url.Values{} + form.Set("grant_type", "client_credentials") + + req, err := http.NewRequest("POST", baseURL+"/v1/oauth2/token", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.SetBasicAuth(clientID, clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := paypalHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("PayPal: no se pudo conectar: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + + var out paypalTokenResp + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("PayPal: respuesta de token inesperada (%d)", resp.StatusCode) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || out.AccessToken == "" { + detalle := out.ErrorDesc + if detalle == "" { + detalle = out.Error + } + if detalle == "" { + detalle = string(raw) + } + return "", fmt.Errorf("PayPal: autenticación rechazada (%d): %s", resp.StatusCode, detalle) + } + return out.AccessToken, nil +} + +// paypalRequest hace una llamada autenticada a la API de PayPal. +func paypalRequest(cfg *models.PaypalConfig, token, method, endpoint string, body interface{}) ([]byte, int, error) { + baseURL, _, _ := paypalCredenciales(cfg) + + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, baseURL+endpoint, reqBody) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := paypalHTTPClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("PayPal: error de conexión: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + return raw, resp.StatusCode, nil +} + +// ─── Creación de orden de pago ──────────────────────────────────────────────── + +type paypalOrderResp struct { + ID string `json:"id"` + Status string `json:"status"` + Links []struct { + Href string `json:"href"` + Rel string `json:"rel"` + } `json:"links"` + Message string `json:"message"` + Details []struct { + Issue string `json:"issue"` + Description string `json:"description"` + } `json:"details"` +} + +// PaypalCrearOrden crea una orden de cobro y devuelve (orderID, urlDeAprobacion). +// La referencia debe ir en formato "contrato-{id}": es lo que permite reconciliar +// después el pago con su contrato, igual que en Bold y dLocal. +func PaypalCrearOrden(cfg *models.PaypalConfig, referencia, descripcion, moneda string, monto float64) (string, string, error) { + token, err := PaypalAccessToken(cfg) + if err != nil { + return "", "", err + } + + returnURL := cfg.ReturnURL + if returnURL == "" { + returnURL = absAppURL("/pago-exitoso?ref=" + referencia) + } + cancelURL := cfg.CancelURL + if cancelURL == "" { + cancelURL = absAppURL("/pago-cancelado?ref=" + referencia) + } + + orden := map[string]interface{}{ + "intent": "CAPTURE", + "purchase_units": []map[string]interface{}{{ + "reference_id": referencia, + "custom_id": referencia, + "description": truncarPaypal(descripcion, 127), + "amount": map[string]string{ + "currency_code": moneda, + "value": fmt.Sprintf("%.2f", monto), + }, + }}, + "application_context": map[string]string{ + "return_url": returnURL, + "cancel_url": cancelURL, + "shipping_preference": "NO_SHIPPING", + "user_action": "PAY_NOW", + }, + } + + raw, status, err := paypalRequest(cfg, token, "POST", "/v2/checkout/orders", orden) + if err != nil { + return "", "", err + } + + var out paypalOrderResp + if err := json.Unmarshal(raw, &out); err != nil { + return "", "", fmt.Errorf("PayPal: respuesta inesperada al crear la orden (%d)", status) + } + if status < 200 || status >= 300 || out.ID == "" { + return "", "", fmt.Errorf("PayPal rechazó la orden (%d): %s", status, detallePaypal(out)) + } + + for _, l := range out.Links { + if l.Rel == "approve" || l.Rel == "payer-action" { + return out.ID, l.Href, nil + } + } + return out.ID, "", fmt.Errorf("PayPal creó la orden %s pero no devolvió enlace de aprobación", out.ID) +} + +// PaypalCapturarOrden cobra una orden ya aprobada por el cliente. +// Devuelve true si quedó capturada (o ya lo estaba). +func PaypalCapturarOrden(cfg *models.PaypalConfig, orderID string) (bool, error) { + token, err := PaypalAccessToken(cfg) + if err != nil { + return false, err + } + + raw, status, err := paypalRequest(cfg, token, "POST", "/v2/checkout/orders/"+orderID+"/capture", map[string]interface{}{}) + if err != nil { + return false, err + } + + var out paypalOrderResp + _ = json.Unmarshal(raw, &out) + + if out.Status == "COMPLETED" { + return true, nil + } + // PayPal responde 422 ORDER_ALREADY_CAPTURED si ya se cobró: es éxito, no error. + for _, d := range out.Details { + if d.Issue == "ORDER_ALREADY_CAPTURED" { + return true, nil + } + } + if status < 200 || status >= 300 { + return false, fmt.Errorf("PayPal no pudo capturar la orden (%d): %s", status, detallePaypal(out)) + } + return false, nil +} + +// PaypalConsultarOrden devuelve el estado actual de una orden. +func PaypalConsultarOrden(cfg *models.PaypalConfig, orderID string) (string, error) { + token, err := PaypalAccessToken(cfg) + if err != nil { + return "", err + } + raw, status, err := paypalRequest(cfg, token, "GET", "/v2/checkout/orders/"+orderID, nil) + if err != nil { + return "", err + } + var out paypalOrderResp + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("PayPal: respuesta inesperada (%d)", status) + } + if status < 200 || status >= 300 { + return "", fmt.Errorf("PayPal (%d): %s", status, detallePaypal(out)) + } + return out.Status, nil +} + +// ─── Verificación de webhook ────────────────────────────────────────────────── + +// PaypalVerificarWebhook comprueba contra PayPal que la notificación es auténtica. +// Sin esto cualquiera podría enviar un "pago completado" falso. +func PaypalVerificarWebhook(cfg *models.PaypalConfig, headers map[string]string, rawBody []byte) (bool, error) { + if cfg.WebhookID == "" { + return false, fmt.Errorf("PayPal: falta configurar el Webhook ID para poder verificar las notificaciones") + } + token, err := PaypalAccessToken(cfg) + if err != nil { + return false, err + } + + var evento json.RawMessage = rawBody + payload := map[string]interface{}{ + "auth_algo": headers["paypal-auth-algo"], + "cert_url": headers["paypal-cert-url"], + "transmission_id": headers["paypal-transmission-id"], + "transmission_sig": headers["paypal-transmission-sig"], + "transmission_time": headers["paypal-transmission-time"], + "webhook_id": cfg.WebhookID, + "webhook_event": evento, + } + + raw, status, err := paypalRequest(cfg, token, "POST", "/v1/notifications/verify-webhook-signature", payload) + if err != nil { + return false, err + } + var out struct { + VerificationStatus string `json:"verification_status"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return false, fmt.Errorf("PayPal: respuesta inesperada al verificar la firma (%d)", status) + } + return out.VerificationStatus == "SUCCESS", nil +} + +// PaypalEvento es la parte de la notificación que interesa para reconciliar. +type PaypalEvento struct { + ID string `json:"id"` + EventType string `json:"event_type"` + ResourceID string + Referencia string +} + +// ParsePaypalWebhook extrae el tipo de evento y la referencia del contrato. +func ParsePaypalWebhook(rawBody []byte) (*PaypalEvento, error) { + var body struct { + ID string `json:"id"` + EventType string `json:"event_type"` + Resource struct { + ID string `json:"id"` + CustomID string `json:"custom_id"` + PurchaseUnits []struct { + ReferenceID string `json:"reference_id"` + CustomID string `json:"custom_id"` + } `json:"purchase_units"` + SupplementaryData struct { + RelatedIDs struct { + OrderID string `json:"order_id"` + } `json:"related_ids"` + } `json:"supplementary_data"` + } `json:"resource"` + } + if err := json.Unmarshal(rawBody, &body); err != nil { + return nil, fmt.Errorf("PayPal: no se pudo interpretar la notificación: %w", err) + } + + ev := &PaypalEvento{ID: body.ID, EventType: body.EventType, ResourceID: body.Resource.ID} + + // La referencia puede venir en custom_id (capturas) o en purchase_units (órdenes). + ev.Referencia = body.Resource.CustomID + if ev.Referencia == "" { + for _, pu := range body.Resource.PurchaseUnits { + if pu.CustomID != "" { + ev.Referencia = pu.CustomID + break + } + if pu.ReferenceID != "" { + ev.Referencia = pu.ReferenceID + break + } + } + } + // En PAYMENT.CAPTURE.* el ID del recurso es la captura, no la orden. + if body.Resource.SupplementaryData.RelatedIDs.OrderID != "" { + ev.ResourceID = body.Resource.SupplementaryData.RelatedIDs.OrderID + } + return ev, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func detallePaypal(out paypalOrderResp) string { + if len(out.Details) > 0 { + d := out.Details[0] + if d.Description != "" { + return d.Issue + ": " + d.Description + } + return d.Issue + } + if out.Message != "" { + return out.Message + } + return "sin detalle" +} + +func truncarPaypal(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go index dd79a1f..e3494c7 100644 --- a/pkg/services/renovacion_service.go +++ b/pkg/services/renovacion_service.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log" + "math" + "strings" "text/template" "time" @@ -328,11 +330,49 @@ func ObtenerOCrearEnlacePago(contrato *models.Contrato, gateway string) string { switch gateway { case "dlocal": return obtenerEnlaceDlocal(contrato) + case "paypal": + return obtenerEnlacePaypal(contrato) default: // "bold" return obtenerEnlaceBold(contrato) } } +// obtenerEnlacePaypal crea una orden de pago en PayPal y persiste el enlace. +func obtenerEnlacePaypal(contrato *models.Contrato) string { + cfg, err := models.GetPaypalConfig() + if err != nil { + log.Printf("[PAYPAL] sin config activa para contrato %d", contrato.ID) + return "" + } + + desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID) + if len(contrato.Servicios) > 0 { + desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre) + } + + referencia := fmt.Sprintf("contrato-%d", contrato.ID) + orderID, urlAprobacion, err := PaypalCrearOrden(cfg, referencia, desc, monedaDeContrato(contrato), contrato.PrecioAcordado) + if err != nil { + log.Printf("[PAYPAL] error creando orden para contrato %d: %v", contrato.ID, err) + return "" + } + + _ = models.GuardarEnlacePago(contrato.ID, orderID, urlAprobacion) + contrato.EnlacePago = urlAprobacion + contrato.EnlacePagoLinkID = orderID + return urlAprobacion +} + +// monedaDeContrato devuelve la moneda del contrato, con COP como valor por +// defecto. Antes la moneda iba fija a "COP" en ambas pasarelas: un contrato +// pactado en USD generaba un cobro por esa cifra en pesos (500 USD → 500 COP). +func monedaDeContrato(contrato *models.Contrato) string { + if m := strings.ToUpper(strings.TrimSpace(contrato.Moneda)); m != "" { + return m + } + return "COP" +} + // obtenerEnlaceBold crea un payment link en Bold y lo persiste en el contrato. func obtenerEnlaceBold(contrato *models.Contrato) string { cfg, err := models.GetBoldConfig() @@ -349,8 +389,8 @@ func obtenerEnlaceBold(contrato *models.Contrato) string { req := BoldPaymentLinkRequest{ AmountType: "CLOSE", Amount: BoldAmountField{ - Currency: "COP", - TotalAmount: int64(contrato.PrecioAcordado), + Currency: monedaDeContrato(contrato), + TotalAmount: int64(math.Round(contrato.PrecioAcordado)), }, Description: desc, Reference: fmt.Sprintf("contrato-%d", contrato.ID), @@ -382,13 +422,18 @@ func obtenerEnlaceDlocal(contrato *models.Contrato) string { desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre) } + referencia := fmt.Sprintf("contrato-%d", contrato.ID) pagoReq := PagoRequest{ - Currency: "COP", + Currency: monedaDeContrato(contrato), Amount: contrato.PrecioAcordado, - OrderID: int(contrato.ID), + OrderID: referencia, Description: desc, - SuccessURL: cfg.UrlProd + "/pago-exitoso", - BackURL: cfg.UrlProd + "/pago-cancelado", + // Las URLs de retorno y notificación son las del panel, no las de la API + // de dLocal: antes apuntaban a cfg.UrlProd y el cliente terminaba en una + // página inexistente. Sin notification_url, dLocal nunca llamaba al webhook. + SuccessURL: absAppURL("/pago-exitoso?ref=" + referencia), + BackURL: absAppURL("/pago-cancelado?ref=" + referencia), + NotificationURL: absAppURL("/webhooks/dlocal"), } body, err := CreatePago(*cfg, pagoReq) diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 5e38490..6c68843 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -97,44 +97,68 @@ func agentTools() []agentTool { tool("sistema_info", "Información general del sistema y recursos disponibles.", obj(nil, nil)), // ── Coolify ────────────────────────────────────────────────────────── - tool("coolify_instancias", "Lista las instancias de Coolify configuradas.", obj(nil, nil)), - tool("coolify_apps", "Lista las aplicaciones de una instancia Coolify.", - obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})), - tool("coolify_servicios", "Lista los servicios (stacks) de una instancia Coolify.", - obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})), - tool("coolify_servidores", "Lista los servidores de una instancia Coolify.", - obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})), - tool("coolify_deploy", "Despliega una aplicación en Coolify.", + tool("coolify_instancias", "Lista las instancias de Coolify configuradas. Úsala primero para saber qué config_id existe si el usuario no lo especifica.", obj(nil, nil)), + tool("coolify_listar", "Lista recursos de una instancia Coolify: aplicaciones, servicios, bases de datos, servidores, proyectos, deployments recientes o miembros del equipo.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), - "uuid": str("UUID de la aplicación"), - "force": agentToolParam{Type: "boolean", Description: "Forzar rebuild desde cero"}, - }, []string{"config_id", "uuid"})), - tool("coolify_restart", "Reinicia una aplicación en Coolify.", + "tipo": agentToolParam{ + Type: "string", + Description: "Qué listar", + Enum: []string{"apps", "servicios", "bases_datos", "servidores", "proyectos", "deployments", "equipo"}, + }, + }, []string{"config_id", "tipo"})), + tool("coolify_detalle", "Muestra el detalle de un recurso concreto de Coolify por su UUID.", + obj(map[string]agentToolParam{ + "config_id": num("ID de la instancia Coolify"), + "tipo": agentToolParam{ + Type: "string", + Description: "Tipo de recurso", + Enum: []string{"app", "servicio", "base_datos", "servidor", "proyecto", "deployment"}, + }, + "uuid": str("UUID del recurso"), + }, []string{"config_id", "tipo", "uuid"})), + tool("coolify_accion", "Ejecuta una acción sobre una aplicación, servicio o base de datos en Coolify: iniciar, detener, reiniciar o desplegar. 'deploy' solo aplica a aplicaciones.", + obj(map[string]agentToolParam{ + "config_id": num("ID de la instancia Coolify"), + "tipo": agentToolParam{ + Type: "string", + Description: "Sobre qué se ejecuta la acción", + Enum: []string{"app", "servicio", "base_datos"}, + }, + "uuid": str("UUID del recurso"), + "accion": agentToolParam{ + Type: "string", + Description: "Acción a ejecutar", + Enum: []string{"start", "stop", "restart", "deploy"}, + }, + "force": agentToolParam{Type: "boolean", Description: "Solo para deploy: forzar rebuild desde cero sin usar caché"}, + }, []string{"config_id", "tipo", "uuid", "accion"})), + tool("coolify_logs", "Obtiene los logs recientes de una aplicación en Coolify. Útil para diagnosticar por qué algo falla.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), }, []string{"config_id", "uuid"})), - tool("coolify_stop", "Detiene una aplicación en Coolify.", + tool("coolify_deployments", "Lista los deployments recientes de una aplicación, con su estado (para saber si un despliegue terminó bien).", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), }, []string{"config_id", "uuid"})), - tool("coolify_start", "Inicia una aplicación detenida en Coolify.", + tool("coolify_envs", "Lista las variables de entorno de una aplicación o servicio en Coolify. Los valores pueden contener secretos: nunca los repitas completos en la respuesta salvo que el usuario lo pida explícitamente.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), - "uuid": str("UUID de la aplicación"), - }, []string{"config_id", "uuid"})), - tool("coolify_logs", "Obtiene los logs de una aplicación en Coolify.", + "tipo": agentToolParam{Type: "string", Description: "Tipo de recurso", Enum: []string{"app", "servicio"}}, + "uuid": str("UUID del recurso"), + }, []string{"config_id", "tipo", "uuid"})), + tool("coolify_servidor_info", "Consulta información de un servidor de Coolify: recursos desplegados, dominios configurados, o valida su conexión.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), - "uuid": str("UUID de la aplicación"), - }, []string{"config_id", "uuid"})), - tool("coolify_deployments", "Lista los deployments recientes de una aplicación.", - obj(map[string]agentToolParam{ - "config_id": num("ID de la instancia Coolify"), - "uuid": str("UUID de la aplicación"), - }, []string{"config_id", "uuid"})), + "uuid": str("UUID del servidor"), + "info": agentToolParam{ + Type: "string", + Description: "Qué consultar", + Enum: []string{"recursos", "dominios", "validar"}, + }, + }, []string{"config_id", "uuid", "info"})), // ── Facturas ───────────────────────────────────────────────────────── tool("listar_facturas", "Lista facturas. Puede filtrar por estado: pendiente, pagada, vencida, cancelada.", @@ -420,55 +444,118 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{}, } return out, nil - case "coolify_apps", "coolify_servicios", "coolify_servidores", - "coolify_logs", "coolify_deployments": - configID := uint(getInt("config_id", 0)) - if configID == 0 { - return nil, fmt.Errorf("config_id requerido") - } - cfg, err := models.GetCoolifyConfigByID(configID) - if err != nil { - return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configID) - } - endpoint := map[string]string{ - "coolify_apps": "/applications", - "coolify_servicios": "/services", - "coolify_servidores": "/servers", - }[name] - if endpoint == "" { - uuid := getStr("uuid") - if uuid == "" { - return nil, fmt.Errorf("uuid requerido") - } - endpoint = map[string]string{ - "coolify_logs": "/applications/" + uuid + "/logs", - "coolify_deployments": "/applications/" + uuid + "/deployments", - }[name] - } - return coolifyCall("GET", endpoint, nil, cfg) - - case "coolify_deploy": - configID := uint(getInt("config_id", 0)) - uuid := getStr("uuid") - force := getBool("force") - if configID == 0 || uuid == "" { - return nil, fmt.Errorf("config_id y uuid requeridos") - } - cfg, err := models.GetCoolifyConfigByID(configID) + case "coolify_listar": + cfg, err := coolifyConfigDe(getInt("config_id", 0)) if err != nil { return nil, err } - ep := fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, force) - return coolifyCall("POST", ep, nil, cfg) + endpoint, ok := map[string]string{ + "apps": "/applications", + "servicios": "/services", + "bases_datos": "/databases", + "servidores": "/servers", + "proyectos": "/projects", + "deployments": "/deployments", + "equipo": "/teams/current/members", + }[getStr("tipo")] + if !ok { + return nil, fmt.Errorf("tipo inválido: usa apps, servicios, bases_datos, servidores, proyectos, deployments o equipo") + } + return coolifyCall("GET", endpoint, nil, cfg) - case "coolify_restart": - return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "restart") + case "coolify_detalle": + cfg, err := coolifyConfigDe(getInt("config_id", 0)) + if err != nil { + return nil, err + } + uuid := getStr("uuid") + if uuid == "" { + return nil, fmt.Errorf("uuid requerido") + } + base, ok := coolifyBasePorTipo(getStr("tipo")) + if !ok { + if getStr("tipo") == "deployment" { + return coolifyCall("GET", "/deployments/"+uuid, nil, cfg) + } + return nil, fmt.Errorf("tipo inválido: usa app, servicio, base_datos, servidor, proyecto o deployment") + } + return coolifyCall("GET", base+"/"+uuid, nil, cfg) - case "coolify_stop": - return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "stop") + case "coolify_accion": + cfg, err := coolifyConfigDe(getInt("config_id", 0)) + if err != nil { + return nil, err + } + uuid := getStr("uuid") + if uuid == "" { + return nil, fmt.Errorf("uuid requerido") + } + tipo := getStr("tipo") + accion := getStr("accion") - case "coolify_start": - return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "start") + if accion == "deploy" { + if tipo != "app" { + return nil, fmt.Errorf("'deploy' solo aplica a aplicaciones; para %s usa start, stop o restart", tipo) + } + return coolifyCall("POST", fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, getBool("force")), nil, cfg) + } + if accion != "start" && accion != "stop" && accion != "restart" { + return nil, fmt.Errorf("acción inválida: usa start, stop, restart o deploy") + } + base, ok := coolifyBasePorTipo(tipo) + if !ok || tipo == "servidor" || tipo == "proyecto" { + return nil, fmt.Errorf("tipo inválido para una acción: usa app, servicio o base_datos") + } + return coolifyCall("GET", fmt.Sprintf("%s/%s/%s", base, uuid, accion), nil, cfg) + + case "coolify_logs", "coolify_deployments": + cfg, err := coolifyConfigDe(getInt("config_id", 0)) + if err != nil { + return nil, err + } + uuid := getStr("uuid") + if uuid == "" { + return nil, fmt.Errorf("uuid requerido") + } + sufijo := "/logs" + if name == "coolify_deployments" { + sufijo = "/deployments" + } + return coolifyCall("GET", "/applications/"+uuid+sufijo, nil, cfg) + + case "coolify_envs": + cfg, err := coolifyConfigDe(getInt("config_id", 0)) + if err != nil { + return nil, err + } + uuid := getStr("uuid") + if uuid == "" { + return nil, fmt.Errorf("uuid requerido") + } + base, ok := coolifyBasePorTipo(getStr("tipo")) + if !ok || (getStr("tipo") != "app" && getStr("tipo") != "servicio") { + return nil, fmt.Errorf("tipo inválido: las variables de entorno solo se consultan de app o servicio") + } + return coolifyCall("GET", base+"/"+uuid+"/envs", nil, cfg) + + case "coolify_servidor_info": + cfg, err := coolifyConfigDe(getInt("config_id", 0)) + if err != nil { + return nil, err + } + uuid := getStr("uuid") + if uuid == "" { + return nil, fmt.Errorf("uuid requerido") + } + sufijo, ok := map[string]string{ + "recursos": "/resources", + "dominios": "/domains", + "validar": "/validate", + }[getStr("info")] + if !ok { + return nil, fmt.Errorf("info inválida: usa recursos, dominios o validar") + } + return coolifyCall("GET", "/servers/"+uuid+sufijo, nil, cfg) // ── Clientes ─────────────────────────────────────────────────────────── case "listar_facturas": @@ -1001,6 +1088,23 @@ func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + // Coolify devuelve el detalle del error en el body; sin revisar el status, + // un 401/404/500 se reportaría al usuario como si la acción hubiera salido bien. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + detalle := strings.TrimSpace(string(raw)) + if len(detalle) > 300 { + detalle = detalle[:300] + } + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return nil, fmt.Errorf("Coolify rechazó la petición (%d): el API token de la instancia es inválido o no tiene permisos", resp.StatusCode) + case http.StatusNotFound: + return nil, fmt.Errorf("Coolify no encontró el recurso (404) en %s — revisa el uuid", endpoint) + default: + return nil, fmt.Errorf("Coolify respondió %d: %s", resp.StatusCode, detalle) + } + } + var result interface{} if err := json.Unmarshal(raw, &result); err != nil { return map[string]string{"raw": string(raw)}, nil @@ -1008,15 +1112,46 @@ func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig return result, nil } -func coolifyAppAction(configIDInt int, uuid, action string) (interface{}, error) { - if configIDInt == 0 || uuid == "" { - return nil, fmt.Errorf("config_id y uuid requeridos") +// coolifyConfigDe resuelve la instancia de Coolify a usar. Si no se indicó +// config_id y solo hay una instancia activa configurada, la toma por defecto +// para que el usuario no tenga que saberse los IDs de memoria. +func coolifyConfigDe(configIDInt int) (*models.CoolifyConfig, error) { + if configIDInt > 0 { + cfg, err := models.GetCoolifyConfigByID(uint(configIDInt)) + if err != nil { + return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configIDInt) + } + return cfg, nil } - cfg, err := models.GetCoolifyConfigByID(uint(configIDInt)) + cfgs, err := models.GetAllCoolifyConfigs() if err != nil { return nil, err } - return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg) + var activas []models.CoolifyConfig + for _, c := range cfgs { + if c.Activo { + activas = append(activas, c) + } + } + if len(activas) == 1 { + return &activas[0], nil + } + if len(activas) == 0 { + return nil, fmt.Errorf("no hay instancias de Coolify activas configuradas") + } + return nil, fmt.Errorf("hay varias instancias de Coolify: indica cuál usar con config_id (consulta coolify_instancias)") +} + +// coolifyBasePorTipo mapea el tipo de recurso al prefijo de la API de Coolify. +func coolifyBasePorTipo(tipo string) (string, bool) { + base, ok := map[string]string{ + "app": "/applications", + "servicio": "/services", + "base_datos": "/databases", + "servidor": "/servers", + "proyecto": "/projects", + }[tipo] + return base, ok } // ─── Tipos Anthropic ───────────────────────────────────────────────────────── @@ -1341,7 +1476,7 @@ COMPORTAMIENTO: - Para listar datos, muestra los más relevantes en formato legible - Los valores monetarios son en COP (pesos colombianos) - Si una herramienta falla, explica el error y sugiere alternativas -- Para acciones en Coolify, primero usa coolify_instancias para saber qué IDs hay disponibles si el usuario no lo especifica +- En Coolify: si solo hay una instancia activa se usa automáticamente, no hace falta preguntar por config_id; si hay varias, consulta coolify_instancias y pregunta cuál. Para actuar sobre algo necesitas su UUID: búscalo con coolify_listar por nombre antes de llamar a coolify_accion. Reiniciar/desplegar es rutinario, hazlo directo; detener algo en producción sí confírmalo antes. Si un despliegue falla, revisa coolify_logs y coolify_deployments para explicar la causa concreta - Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Decide primero de qué se trata el archivo antes de guardarlo: · Si es una factura/cuenta de cobro → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura · Si es un documento de un proyecto (contrato, orden de servicio, entregable, etc.) → identifica a qué proyecto corresponde (usa listar_proyectos si hace falta) y llama a adjuntar_documento_proyecto diff --git a/pkg/services/telegram_service.go b/pkg/services/telegram_service.go index fa164e1..d735868 100755 --- a/pkg/services/telegram_service.go +++ b/pkg/services/telegram_service.go @@ -6,8 +6,13 @@ import ( "fmt" "net/http" "os" + "time" ) +// telegramHTTPClient evita que una llamada colgada a Telegram deje la +// goroutine bloqueada indefinidamente (http.Get/Post no tienen timeout). +var telegramHTTPClient = &http.Client{Timeout: 20 * time.Second} + type TelegramService struct { BotToken string // exportado para uso desde otros paquetes } @@ -44,7 +49,7 @@ func (ts *TelegramService) SendMessage(chatID interface{}, message string) error return fmt.Errorf("error marshalling request body: %v", err) } - resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) + resp, err := telegramHTTPClient.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("error sending request to Telegram API: %v", err) } @@ -62,7 +67,7 @@ func GetBotUsername(botToken string) string { if botToken == "" { return "" } - resp, err := http.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken)) + resp, err := telegramHTTPClient.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken)) if err != nil { return "" } @@ -100,7 +105,7 @@ func UpdatesRecientesDelBot(botToken string) ([]RemitenteReciente, error) { if botToken == "" { return nil, fmt.Errorf("bot token vacío") } - resp, err := http.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)) //nolint:noctx + resp, err := telegramHTTPClient.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)) //nolint:noctx if err != nil { return nil, fmt.Errorf("no se pudo consultar Telegram: %w", err) } diff --git a/resources/views/renovaciones/reglas.html b/resources/views/renovaciones/reglas.html index 36d54e4..5b1f15a 100644 --- a/resources/views/renovaciones/reglas.html +++ b/resources/views/renovaciones/reglas.html @@ -127,6 +127,7 @@

Genera automáticamente {{.EnlacePago}} en la plantilla

diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go index ca3f656..c3977aa 100644 --- a/rest/controllers/api/bold_controller.go +++ b/rest/controllers/api/bold_controller.go @@ -29,12 +29,16 @@ func BoldWebhook(c *fiber.Ctx) error { } // ─── 2. Verificar firma HMAC ───────────────────────────────────────────── + // La firma es obligatoria: sin ella cualquiera podría enviar un + // "SALE_APPROVED" falso y marcar un contrato como pagado sin haber pagado. signature := c.Get("x-bold-signature") - if signature != "" { - if !services.VerifyBoldSignature(rawBody, signature, cfg) { - log.Println("[BOLD] Webhook: firma inválida — descartado") - return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) - } + if signature == "" { + log.Printf("[BOLD] Webhook rechazado: sin firma (IP %s)", c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma requerida"}) + } + if !services.VerifyBoldSignature(rawBody, signature, cfg) { + log.Printf("[BOLD] Webhook rechazado: firma inválida (IP %s)", c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma inválida"}) } // ─── 3. Parsear evento ─────────────────────────────────────────────────── diff --git a/rest/controllers/api/dlocal_controller.go b/rest/controllers/api/dlocal_controller.go index f355e12..d67e1ec 100644 --- a/rest/controllers/api/dlocal_controller.go +++ b/rest/controllers/api/dlocal_controller.go @@ -236,13 +236,17 @@ func DlocalWebhook(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) } - // ─── 2. Verificar firma HMAC-SHA256 (opcional según config) ────────────── + // ─── 2. Verificar firma HMAC-SHA256 (obligatoria) ──────────────────────── + // Sin firma cualquiera podría notificar un pago falso y marcar el contrato + // como pagado, así que se rechaza la petición en vez de aceptarla. signature := c.Get("X-dLocal-Signature") - if signature != "" { - if !services.VerifyDlocalSignature(rawBody, signature, *cfg) { - log.Println("[DLOCAL] Webhook: firma inválida — descartado") - return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) - } + if signature == "" { + log.Printf("[DLOCAL] Webhook rechazado: sin firma (IP %s)", c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma requerida"}) + } + if !services.VerifyDlocalSignature(rawBody, signature, *cfg) { + log.Printf("[DLOCAL] Webhook rechazado: firma inválida (IP %s)", c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma inválida"}) } // ─── 3. Parsear notificación ───────────────────────────────────────────── diff --git a/rest/controllers/api/pago_controller.go b/rest/controllers/api/pago_controller.go index 8c2303f..2198231 100644 --- a/rest/controllers/api/pago_controller.go +++ b/rest/controllers/api/pago_controller.go @@ -61,7 +61,11 @@ func PagoExitosoPage(c *fiber.Ctx) error { }, "layouts/landing") } - // Bold confirmó el pago directamente en la URL de retorno + // Bold dice en la URL de retorno que el pago fue aprobado. + // OJO: esto viene del navegador del usuario y es manipulable, así que NO se + // usa para marcar el contrato como pagado. Solo se registra el retorno; la + // confirmación real la hace verificarPago() contra la API de Bold / el + // webhook firmado, que son las únicas fuentes de verdad. if boldTxStatus == "approved" { // IP real (detrás de nginx) ip := c.Get("X-Real-IP") @@ -71,38 +75,42 @@ func PagoExitosoPage(c *fiber.Ctx) error { if ip == "" { ip = c.IP() } + + estado, fechaPago := verificarPago(ref) + + estadoLog := "pendiente" + if estado { + estadoLog = "pagado" + } _ = models.SaveBoldCallbackLog(models.BoldCallbackLog{ Referencia: ref, PaymentLink: paymentLink, Params: paramsJSON, - Estado: "pagado", IP: ip, UserAgent: string(c.Request().Header.UserAgent()), + Estado: estadoLog, IP: ip, UserAgent: string(c.Request().Header.UserAgent()), }) - // Marcar contrato si aún no está confirmado - var contratoID uint - if _, err := fmt.Sscanf(ref, "contrato-%d", &contratoID); err == nil && contratoID > 0 { - if ok, _ := models.MarcarContratoPagado(contratoID); ok { - go services.EnviarCorreoConfirmacionPago(contratoID, "bold") - } - // Enriquecer callback log: primero webhook log, luego Bold API directa - go func(linkID string) { - // 1. Si el webhook ya llegó, usar esos datos - if wLogs, err := models.GetBoldWebhookLogsByRef(ref); err == nil { - for _, wl := range wLogs { - if wl.PayerEmail != "" || wl.Monto > 0 { - models.EnrichBoldCallbackLog(ref, wl.PayerEmail, wl.Monto) - return - } - } - } - // 2. Fallback: consultar Bold API directamente para obtener el monto - if linkID != "" { - if boldCfg, err := models.GetBoldConfig(); err == nil { - if _, _, monto, err := services.CheckBoldLinkStatus(boldCfg, linkID); err == nil && monto > 0 { - models.EnrichBoldCallbackLog(ref, "", monto) - } - } - } - }(paymentLink) + if !estado { + log.Printf("[PAGO] Retorno con bold-tx-status=approved para %s pero la verificación contra Bold no lo confirma todavía", ref) } - estado, fechaPago := verificarPago(ref) + + // Enriquecer callback log: primero webhook log, luego Bold API directa + go func(linkID string) { + // 1. Si el webhook ya llegó, usar esos datos + if wLogs, err := models.GetBoldWebhookLogsByRef(ref); err == nil { + for _, wl := range wLogs { + if wl.PayerEmail != "" || wl.Monto > 0 { + models.EnrichBoldCallbackLog(ref, wl.PayerEmail, wl.Monto) + return + } + } + } + // 2. Fallback: consultar Bold API directamente para obtener el monto + if linkID != "" { + if boldCfg, err := models.GetBoldConfig(); err == nil { + if _, _, monto, err := services.CheckBoldLinkStatus(boldCfg, linkID); err == nil && monto > 0 { + models.EnrichBoldCallbackLog(ref, "", monto) + } + } + } + }(paymentLink) + return c.Render("pago_exitoso", fiber.Map{ "Ref": ref, "Estado": estado, "FechaPago": fechaPago, }, "layouts/landing") diff --git a/rest/controllers/api/paypal_controller.go b/rest/controllers/api/paypal_controller.go new file mode 100644 index 0000000..c0e6063 --- /dev/null +++ b/rest/controllers/api/paypal_controller.go @@ -0,0 +1,102 @@ +package controllers + +import ( + "fmt" + "log" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +// PaypalWebhook recibe las notificaciones de PayPal y confirma el pago del +// contrato asociado. +// Ruta: POST /webhooks/paypal +// +// La firma se verifica siempre contra la API de PayPal: sin eso cualquiera +// podría enviar un "pago completado" falso y renovar un contrato sin pagar. +func PaypalWebhook(c *fiber.Ctx) error { + rawBody := c.Body() + + cfg, err := models.GetPaypalConfig() + if err != nil { + log.Println("[PAYPAL] Webhook: sin configuración activa") + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + headers := map[string]string{ + "paypal-auth-algo": c.Get("Paypal-Auth-Algo"), + "paypal-cert-url": c.Get("Paypal-Cert-Url"), + "paypal-transmission-id": c.Get("Paypal-Transmission-Id"), + "paypal-transmission-sig": c.Get("Paypal-Transmission-Sig"), + "paypal-transmission-time": c.Get("Paypal-Transmission-Time"), + } + if headers["paypal-transmission-id"] == "" || headers["paypal-transmission-sig"] == "" { + log.Printf("[PAYPAL] Webhook rechazado: sin cabeceras de firma (IP %s)", c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma requerida"}) + } + + valido, err := services.PaypalVerificarWebhook(cfg, headers, rawBody) + if err != nil { + log.Printf("[PAYPAL] Webhook: no se pudo verificar la firma: %v", err) + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"ok": false}) + } + if !valido { + log.Printf("[PAYPAL] Webhook rechazado: firma inválida (IP %s)", c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "firma inválida"}) + } + + evento, err := services.ParsePaypalWebhook(rawBody) + if err != nil { + log.Printf("[PAYPAL] Webhook: %v", err) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + log.Printf("[PAYPAL] Webhook %s — evento=%s ref=%s recurso=%s", + evento.ID, evento.EventType, evento.Referencia, evento.ResourceID) + + switch evento.EventType { + case "CHECKOUT.ORDER.APPROVED": + // El cliente aprobó el pago pero todavía no se cobró: hay que capturarlo. + if evento.ResourceID != "" { + if ok, err := services.PaypalCapturarOrden(cfg, evento.ResourceID); err != nil { + log.Printf("[PAYPAL] Error capturando la orden %s: %v", evento.ResourceID, err) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } else if !ok { + log.Printf("[PAYPAL] La orden %s no quedó capturada todavía", evento.ResourceID) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + } + case "PAYMENT.CAPTURE.COMPLETED": + // El cobro ya se hizo efectivo: no hay nada que capturar. + default: + // Otros eventos (reembolsos, disputas, etc.) solo se registran. + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + marcarContratoPagadoPorReferencia(evento.Referencia, "paypal") + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) +} + +// marcarContratoPagadoPorReferencia confirma el pago del contrato codificado en +// una referencia con formato "contrato-{id}". +func marcarContratoPagadoPorReferencia(referencia, pasarela string) { + if referencia == "" { + log.Printf("[%s] Pago confirmado pero sin referencia de contrato: requiere conciliación manual", pasarela) + return + } + var contratoID uint + if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err != nil || contratoID == 0 { + log.Printf("[%s] Referencia '%s' no corresponde a un contrato", pasarela, referencia) + return + } + ok, err := models.MarcarContratoPagado(contratoID) + if err != nil { + log.Printf("[%s] Error marcando contrato %d como pagado: %v", pasarela, contratoID, err) + return + } + log.Printf("[%s] Contrato %d marcado como pagado (nuevo=%v)", pasarela, contratoID, ok) + if ok { + go services.EnviarCorreoConfirmacionPago(contratoID, pasarela) + } +} diff --git a/rest/middlewares/solo_admin.go b/rest/middlewares/solo_admin.go new file mode 100644 index 0000000..9c1e0e2 --- /dev/null +++ b/rest/middlewares/solo_admin.go @@ -0,0 +1,37 @@ +package middlewares + +import ( + "log" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" +) + +// SoloAdmin restringe una ruta a usuarios administradores. +// +// Hasta ahora todo /app solo verificaba que hubiera sesión iniciada: cualquier +// usuario del panel, sin importar su rol, podía ejecutar SQL arbitrario en el +// Query Runner, crear usuarios, cambiar la contraseña de otros o reasignar +// permisos. MenuMiddleware no autoriza nada, solo arma el menú lateral. +// +// Se aplica únicamente a las operaciones sensibles (gestión de usuarios, roles, +// módulos, Query Runner y seeds) en vez de a todo el panel, para no dejar sin +// acceso a nadie que hoy esté usando las pantallas operativas normales. +func SoloAdmin(c *fiber.Ctx) error { + user, err := auth.User(c) + if err != nil || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": true, + "message": "Usuario no autenticado", + }) + } + if !user.IsAdmin { + log.Printf("[AUTORIZACION] Usuario %d (%s) intentó acceder a %s sin ser administrador", + user.ID, user.Email, c.Path()) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": true, + "message": "Necesitas permisos de administrador para esta acción", + }) + } + return c.Next() +} diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index 04b5544..fc7b114 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -20,6 +20,9 @@ func RutasPublicas(web fiber.Router) { web.Post("/webhooks/bold", apiControllers.BoldWebhook) // dLocal requiere respuesta HTTP 200 inmediata. web.Post("/webhooks/dlocal", apiControllers.DlocalWebhook) + // PayPal: configurar en el panel de PayPal → Webhooks, con los eventos + // CHECKOUT.ORDER.APPROVED y PAYMENT.CAPTURE.COMPLETED. + web.Post("/webhooks/paypal", apiControllers.PaypalWebhook) // Webhook entrante por integración SaaS (token único por config) web.Post("/webhooks/saas-in/:token", controllers.SaasWebhookInHandler) web.Get("/webhooks/saas-in/:token", controllers.SaasWebhookInHandler) // algunos SaaS verifican con GET diff --git a/rest/routes/user.go b/rest/routes/user.go index 1c4e3f0..bf4d209 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -29,35 +29,35 @@ func UserRoutes(app fiber.Router) { app.Get("/web", func(c *fiber.Ctx) error { return c.Redirect("/", http.StatusSeeOther) }) protected.Get("/", controllers.App) protected.Get("/dashboard", middlewares.MenuMiddleware, controllers.Dashboard) - protected.Get("/modules", middlewares.MenuMiddleware, controllers.Modules) // Renderizar la vista - protected.Get("/loadmodules", controllers.GetModules) // Obtener todos los módulos - protected.Post("/modules", controllers.CreateModule) // Crear un nuevo módulo - protected.Put("/modules/:id", controllers.UpdateModule) // Actualizar un módulo existente - protected.Delete("/modules/:id", controllers.DeleteModule) // Eliminar un módulo + protected.Get("/modules", middlewares.MenuMiddleware, controllers.Modules) // Renderizar la vista + protected.Get("/loadmodules", controllers.GetModules) // Obtener todos los módulos + protected.Post("/modules", middlewares.SoloAdmin, controllers.CreateModule) // Crear un nuevo módulo + protected.Put("/modules/:id", middlewares.SoloAdmin, controllers.UpdateModule) // Actualizar un módulo existente + protected.Delete("/modules/:id", middlewares.SoloAdmin, controllers.DeleteModule) // Eliminar un módulo // Rutas de roles - protected.Get("/roles", middlewares.MenuMiddleware, controllers.Roles) // Renderizar la vista - protected.Get("/loadroles", controllers.GetRoles) // Obtener todos - protected.Post("/roles", controllers.CreateRole) // Crear - protected.Put("/roles/:id", controllers.UpdateRole) // Actualizar - protected.Delete("/roles/:id", controllers.DeleteRole) // Eliminar + protected.Get("/roles", middlewares.MenuMiddleware, controllers.Roles) // Renderizar la vista + protected.Get("/loadroles", controllers.GetRoles) // Obtener todos + protected.Post("/roles", middlewares.SoloAdmin, controllers.CreateRole) // Crear + protected.Put("/roles/:id", middlewares.SoloAdmin, controllers.UpdateRole) // Actualizar + protected.Delete("/roles/:id", middlewares.SoloAdmin, controllers.DeleteRole) // Eliminar // Rutas de módulos - protected.Get("/submodules", middlewares.MenuMiddleware, controllers.Submodules) // Renderizar la vista - protected.Get("/loadsubmodules", controllers.GetSubmodules) // Obtener todos los módulos - protected.Post("/submodules", controllers.CreateSubmodule) // Crear un nuevo módulo - protected.Put("/submodules/:id", controllers.UpdateSubmodule) // Actualizar un módulo existente - protected.Delete("/submodules/:id", controllers.DeleteSubmodule) // Eliminar un módulo + protected.Get("/submodules", middlewares.MenuMiddleware, controllers.Submodules) // Renderizar la vista + protected.Get("/loadsubmodules", controllers.GetSubmodules) // Obtener todos los módulos + protected.Post("/submodules", middlewares.SoloAdmin, controllers.CreateSubmodule) // Crear un nuevo módulo + protected.Put("/submodules/:id", middlewares.SoloAdmin, controllers.UpdateSubmodule) // Actualizar un módulo existente + protected.Delete("/submodules/:id", middlewares.SoloAdmin, controllers.DeleteSubmodule) // Eliminar un módulo // Rutas de usuarios - protected.Get("/users", middlewares.MenuMiddleware, controllers.Users) // Renderizar la vista - protected.Get("/loadusers", controllers.GetUsers) // Obtener - protected.Post("/users", controllers.CreateUser) // Crear - protected.Put("/users/:id", controllers.UpdateUser) // Actualizar - protected.Put("/password/:id", controllers.UpdatePassword) // Actualizar - protected.Delete("/users/:id", controllers.DeleteUser) // Eliminar - protected.Get("/user/:id", controllers.GetUser) // Buscar un usuario - protected.Post("/users/:id/credenciales", controllers.EnviarCredencialesUsuario) // Enviar credenciales + protected.Get("/users", middlewares.MenuMiddleware, controllers.Users) // Renderizar la vista + protected.Get("/loadusers", controllers.GetUsers) // Obtener + protected.Post("/users", middlewares.SoloAdmin, controllers.CreateUser) // Crear + protected.Put("/users/:id", middlewares.SoloAdmin, controllers.UpdateUser) // Actualizar + protected.Put("/password/:id", middlewares.SoloAdmin, controllers.UpdatePassword) // Actualizar + protected.Delete("/users/:id", middlewares.SoloAdmin, controllers.DeleteUser) // Eliminar + protected.Get("/user/:id", controllers.GetUser) // Buscar un usuario + protected.Post("/users/:id/credenciales", controllers.EnviarCredencialesUsuario) // Enviar credenciales // Rutas de conexiones ssh protected.Get("/conexion_ssh", middlewares.MenuMiddleware, controllers.ConxSsh) // Renderizar la vista @@ -134,15 +134,15 @@ func UserRoutes(app fiber.Router) { RenovacionesRoutes(protected) // ─── Seed manual (admin) ──────────────────────────────────────────── - protected.Get("/run-seed", controllers.RunSeed) + protected.Get("/run-seed", middlewares.SoloAdmin, controllers.RunSeed) // ─── Query Runner (editor SQL) ──────────────────────────────────────────── protected.Get("/query-runner", middlewares.MenuMiddleware, controllers.QueryRunnerPage) protected.Get("/query-runner/connections", controllers.GetConxDbList) protected.Get("/query-runner/databases", controllers.GetDatabases) protected.Get("/query-runner/tables", controllers.GetTables) protected.Get("/query-runner/test", controllers.TestConnection) - protected.Post("/query-runner/run", controllers.RunQuery) - protected.Post("/query-runner/run-batch", controllers.RunBatchQuery) + protected.Post("/query-runner/run", middlewares.SoloAdmin, controllers.RunQuery) + protected.Post("/query-runner/run-batch", middlewares.SoloAdmin, controllers.RunBatchQuery) protected.Get("/query-runner/history", controllers.GetHistory) protected.Delete("/query-runner/history", controllers.ClearHistory) protected.Post("/query-runner/export/csv", controllers.ExportCSV) diff --git a/session.db b/session.db deleted file mode 100755 index cf0cb5ad9a53dda1fcd5a91e4cce597174cb5bce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 zcmeI(KX2MV9Dw075E`|@P1S)3>69r}P@}42rKm3QKuSzP%)n&3;aXV=hTx+@>PDu1 zxPF?h9eOUfjWaMdDtb>?{`vm_Q zd+rWK@`E>$?l9nf`Qxh0~sySB>XesP4IjCq2Ik6dS>qTG9BmbUcK3O zERm#Rbz