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
-27
View File
@@ -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
+4 -2
View File
@@ -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
*.db
session.db
+81
View File
@@ -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.
+3
View File
@@ -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,
})
+64
View File
@@ -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
}
+11 -8
View File
@@ -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)
+25
View File
@@ -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")
}
+19 -1
View File
@@ -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()
+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.")
+29 -1
View File
@@ -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 {
+16 -2
View File
@@ -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"`
+12 -8
View File
@@ -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
+5 -2
View File
@@ -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)
}
+353
View File
@@ -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]
}
+51 -6
View File
@@ -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)
+208 -73
View File
@@ -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
+8 -3
View File
@@ -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)
}
+1
View File
@@ -127,6 +127,7 @@
<select x-model="form.pasarela_enlace" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="bold">Bold (Colombia)</option>
<option value="dlocal">dLocal</option>
<option value="paypal">PayPal</option>
<option value="ninguna">Ninguna (sin enlace de pago)</option>
</select>
<p class="text-xs text-gray-400 mt-1">Genera automáticamente <code>&#123;&#123;.EnlacePago&#125;&#125;</code> en la plantilla</p>
+9 -5
View File
@@ -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 ───────────────────────────────────────────────────
+10 -6
View File
@@ -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 ─────────────────────────────────────────────
+37 -29
View File
@@ -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")
+102
View File
@@ -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)
}
}
+37
View File
@@ -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()
}
+3
View File
@@ -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
+26 -26
View File
@@ -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)
BIN
View File
Binary file not shown.