diff --git a/main.go b/main.go index 98662ee..d974888 100755 --- a/main.go +++ b/main.go @@ -129,6 +129,9 @@ func main() { // Tablero de tareas &models.Tarea{}, &models.TareaComentario{}, + // Pagos externos: API de cobros para aplicaciones de terceros + &models.ServicioPagoExterno{}, + &models.SolicitudPagoExterna{}, // Soporte: webhook de correo entrante &models.SoporteWebhookConfig{}, // Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA @@ -169,6 +172,7 @@ func main() { migrations.SeedWebSms() migrations.SeedUrlMonitor() migrations.SeedTareas() + migrations.SeedPagosExternos() migrations.SeedAutomatizacionIA() if n, err := models.RepararEstadosTareaInvalidos(); err != nil { log.Printf("[FIX] Error reparando estados de tareas: %v", err) diff --git a/migrations/migrate.go b/migrations/migrate.go index 2834af9..1dbb52c 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -104,6 +104,9 @@ func Migrate() { // Tablero de tareas &models.Tarea{}, &models.TareaComentario{}, + // Pagos externos: API de cobros para aplicaciones de terceros + &models.ServicioPagoExterno{}, + &models.SolicitudPagoExterna{}, // Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA &models.PlantillaDocumento{}, &models.Tarifa{}, @@ -1221,3 +1224,40 @@ func SeedTareas() { } log.Println("[SEED] Seed de Tareas completado.") } + +// SeedPagosExternos registra el submódulo del panel de administración de +// servicios de pago externos (emisión de tokens de API para terceros). +func SeedPagosExternos() { + db := app.Http.Database.DB + var modulo models.Modules + if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil { + log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedPagosExternos") + return + } + url := "/app/pagos-externos" + var sub models.Submodules + if err := db.Where("url = ?", url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: "Pagos externos", + Description: "Tokens de API para que apps de terceros pidan cobros por Bold/dLocal/PayPal", + Url: url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo 'Pagos externos': %v", err) + return + } + log.Printf("[SEED] Submódulo 'Pagos externos' creado") + } else if sub.ModuleId != modulo.ID { + db.Model(&sub).Update("module_id", modulo.ID) + } + var rol models.Roles + if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil { + log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) + } else { + asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub}) + log.Printf("[SEED] Submódulo 'Pagos externos' asignado al rol 'Administrador'") + } + log.Println("[SEED] Seed de Pagos externos completado.") +} diff --git a/pkg/models/pago_externo.go b/pkg/models/pago_externo.go new file mode 100644 index 0000000..e0356b7 --- /dev/null +++ b/pkg/models/pago_externo.go @@ -0,0 +1,298 @@ +package models + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "strings" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// ServicioPagoExterno es una credencial que le entregamos a una aplicación +// externa para que pueda pedir cobros a través de nuestras pasarelas +// (Bold/dLocal/PayPal) sin darle acceso a nada más del sistema. El token solo +// se muestra en texto plano una vez, al crearlo o regenerarlo: en la base +// solo se guarda su hash (igual que una API key de Stripe/GitHub). +type ServicioPagoExterno struct { + gorm.Model + Nombre string `json:"nombre" gorm:"column:nombre;size:150"` + TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"` + TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"` + IPsPermitidas string `json:"ips_permitidas" gorm:"column:ips_permitidas;type:text"` + PasarelasHabilitadas string `json:"pasarelas_habilitadas" gorm:"column:pasarelas_habilitadas;size:100"` + NotificarWebhook bool `json:"notificar_webhook" gorm:"column:notificar_webhook;default:false"` + CallbackURL string `json:"callback_url" gorm:"column:callback_url;type:text"` + CallbackSecret string `json:"-" gorm:"column:callback_secret;size:100"` + NotificarTelegram bool `json:"notificar_telegram" gorm:"column:notificar_telegram;default:false"` + TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id;size:50"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` + CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"` +} + +func (ServicioPagoExterno) TableName() string { return "servicios_pago_externos" } + +// SolicitudPagoExterna es cada cobro individual pedido por una app externa a +// través de un ServicioPagoExterno. +type SolicitudPagoExterna struct { + gorm.Model + ServicioID uint `json:"servicio_id" gorm:"column:servicio_id;index"` + Servicio *ServicioPagoExterno `json:"servicio,omitempty" gorm:"foreignKey:ServicioID"` + ReferenciaExterna string `json:"referencia_externa" gorm:"column:referencia_externa;size:150;index"` + ReferenciaInterna string `json:"referencia_interna" gorm:"column:referencia_interna;uniqueIndex;size:60"` + ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id"` + Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"` + Pasarela string `json:"pasarela" gorm:"column:pasarela;size:20"` + Monto float64 `json:"monto" gorm:"column:monto"` + Moneda string `json:"moneda" gorm:"column:moneda;size:10"` + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + // Estado: pendiente | pagado | fallido | expirado + Estado string `json:"estado" gorm:"column:estado;default:'pendiente';index"` + EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"` + PasarelaLinkID string `json:"-" gorm:"column:pasarela_link_id;size:150"` + PasarelaTxID string `json:"pasarela_tx_id" gorm:"column:pasarela_tx_id;size:150"` + FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"` + CallbackEntregado bool `json:"callback_entregado" gorm:"column:callback_entregado;default:false"` + CallbackIntentos int `json:"callback_intentos" gorm:"column:callback_intentos;default:0"` + TelegramEntregado bool `json:"telegram_entregado" gorm:"column:telegram_entregado;default:false"` +} + +func (SolicitudPagoExterna) TableName() string { return "solicitudes_pago_externas" } + +// ─── Tokens ─────────────────────────────────────────────────────────────────── + +// GenerarTokenServicioPago crea un token aleatorio de 32 bytes (64 hex chars) y +// su hash SHA-256. El token crudo se devuelve una sola vez: solo el hash se +// guarda en base de datos, así una filtración de la BD no expone credenciales +// utilizables directamente. +func GenerarTokenServicioPago() (raw string, hash string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", fmt.Errorf("no se pudo generar el token: %w", err) + } + raw = "spx_" + hex.EncodeToString(b) + hash = HashTokenServicioPago(raw) + return raw, hash, nil +} + +func HashTokenServicioPago(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// tokenPreview devuelve los últimos caracteres del token para poder +// identificarlo en el panel sin volver a mostrarlo completo. +func tokenPreview(raw string) string { + if len(raw) <= 8 { + return raw + } + return "..." + raw[len(raw)-6:] +} + +// ─── CRUD ServicioPagoExterno ─────────────────────────────────────────────── + +// CreateServicioPagoExterno genera el token, lo hashea y crea el registro. +// Devuelve el token en texto plano: es la única vez que estará disponible. +func CreateServicioPagoExterno(s *ServicioPagoExterno) (tokenPlano string, err error) { + raw, hash, err := GenerarTokenServicioPago() + if err != nil { + return "", err + } + s.TokenHash = hash + s.TokenPreview = tokenPreview(raw) + if err := app.Http.Database.DB.Create(s).Error; err != nil { + return "", err + } + return raw, nil +} + +// RegenerarTokenServicioPago invalida el token anterior y genera uno nuevo. +func RegenerarTokenServicioPago(id uint) (tokenPlano string, err error) { + raw, hash, err := GenerarTokenServicioPago() + if err != nil { + return "", err + } + result := app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id). + Updates(map[string]interface{}{"token_hash": hash, "token_preview": tokenPreview(raw)}) + if result.Error != nil { + return "", result.Error + } + if result.RowsAffected == 0 { + return "", fmt.Errorf("servicio de pago no encontrado") + } + return raw, nil +} + +func GetAllServiciosPagoExterno(limit, offset int) ([]ServicioPagoExterno, int64, error) { + var items []ServicioPagoExterno + var total int64 + db := app.Http.Database.DB.Model(&ServicioPagoExterno{}) + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func GetServicioPagoExternoByID(id uint) (*ServicioPagoExterno, error) { + var s ServicioPagoExterno + if err := app.Http.Database.DB.First(&s, id).Error; err != nil { + return nil, err + } + return &s, nil +} + +// FindServicioPagoExternoActivoByToken resuelve el servicio a partir del token +// crudo recibido en el header Authorization. Solo hace match si está activo. +func FindServicioPagoExternoActivoByToken(rawToken string) (*ServicioPagoExterno, error) { + hash := HashTokenServicioPago(rawToken) + var s ServicioPagoExterno + if err := app.Http.Database.DB.Where("token_hash = ? AND activo = ?", hash, true).First(&s).Error; err != nil { + return nil, err + } + return &s, nil +} + +func UpdateServicioPagoExterno(id uint, updates map[string]interface{}) error { + return app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id).Updates(updates).Error +} + +func DeleteServicioPagoExterno(id uint) error { + return app.Http.Database.DB.Delete(&ServicioPagoExterno{}, id).Error +} + +// IPPermitida valida la IP del caller contra la lista configurada en el +// servicio (coma-separada, admite IP exacta o CIDR). Lista vacía = sin +// restricción de IP. +func (s *ServicioPagoExterno) IPPermitida(ip string) bool { + lista := strings.TrimSpace(s.IPsPermitidas) + if lista == "" { + return true + } + callerIP := net.ParseIP(ip) + if callerIP == nil { + return false + } + for _, entrada := range strings.Split(lista, ",") { + entrada = strings.TrimSpace(entrada) + if entrada == "" { + continue + } + if strings.Contains(entrada, "/") { + _, red, err := net.ParseCIDR(entrada) + if err == nil && red.Contains(callerIP) { + return true + } + continue + } + if net.ParseIP(entrada) != nil && net.ParseIP(entrada).Equal(callerIP) { + return true + } + } + return false +} + +// PasarelasList devuelve las pasarelas habilitadas para este servicio. +func (s *ServicioPagoExterno) PasarelasList() []string { + return SplitModulos(s.PasarelasHabilitadas) +} + +// PasarelaHabilitada indica si el servicio puede usar esa pasarela. +func (s *ServicioPagoExterno) PasarelaHabilitada(pasarela string) bool { + for _, p := range s.PasarelasList() { + if p == pasarela { + return true + } + } + return false +} + +// ─── SolicitudPagoExterna ─────────────────────────────────────────────────── + +// GenerarReferenciaInterna crea una referencia única con el prefijo "extpay-" +// que los webhooks de las pasarelas usan para distinguir estas solicitudes de +// las de un Contrato (que usan "contrato-{id}"). +func GenerarReferenciaInterna() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("no se pudo generar la referencia: %w", err) + } + return "extpay-" + hex.EncodeToString(b), nil +} + +func CreateSolicitudPagoExterna(s *SolicitudPagoExterna) error { + return app.Http.Database.DB.Create(s).Error +} + +func GetSolicitudPagoExternaByReferenciaInterna(ref string) (*SolicitudPagoExterna, error) { + var s SolicitudPagoExterna + if err := app.Http.Database.DB.Preload("Servicio").Preload("Cliente"). + Where("referencia_interna = ?", ref).First(&s).Error; err != nil { + return nil, err + } + return &s, nil +} + +func GetAllSolicitudesPagoExterna(limit, offset int, servicioID uint) ([]SolicitudPagoExterna, int64, error) { + var items []SolicitudPagoExterna + var total int64 + db := app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Preload("Servicio").Preload("Cliente") + if servicioID > 0 { + db = db.Where("servicio_id = ?", servicioID) + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +// MarcarSolicitudPagoExternaPagada transiciona pendiente→pagado de forma +// idempotente (igual patrón que MarcarContratoPagado): si ya estaba pagada, +// no vuelve a disparar la notificación. Devuelve ok=true solo en la +// transición real. +func MarcarSolicitudPagoExternaPagada(referenciaInterna, pasarelaTxID string) (ok bool, solicitud *SolicitudPagoExterna, err error) { + now := time.Now() + result := app.Http.Database.DB.Model(&SolicitudPagoExterna{}). + Where("referencia_interna = ? AND estado = ?", referenciaInterna, "pendiente"). + Updates(map[string]interface{}{ + "estado": "pagado", + "fecha_pago": now, + "pasarela_tx_id": pasarelaTxID, + }) + if result.Error != nil { + return false, nil, result.Error + } + s, getErr := GetSolicitudPagoExternaByReferenciaInterna(referenciaInterna) + if getErr != nil { + return result.RowsAffected > 0, nil, getErr + } + return result.RowsAffected > 0, s, nil +} + +// MarcarCallbackEntregado registra que la notificación saliente (webhook y/o +// Telegram) ya se intentó entregar, para no reintentar indefinidamente sin +// visibilidad. +func MarcarCallbackEntregado(id uint, webhookOk, telegramOk bool) error { + updates := map[string]interface{}{} + if webhookOk { + updates["callback_entregado"] = true + } + if telegramOk { + updates["telegram_entregado"] = true + } + if len(updates) == 0 { + return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id). + Update("callback_intentos", gorm.Expr("callback_intentos + 1")).Error + } + updates["callback_intentos"] = gorm.Expr("callback_intentos + 1") + return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id).Updates(updates).Error +} diff --git a/pkg/services/pago_externo_service.go b/pkg/services/pago_externo_service.go new file mode 100644 index 0000000..5b20bd1 --- /dev/null +++ b/pkg/services/pago_externo_service.go @@ -0,0 +1,263 @@ +package services + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "math" + "net/http" + "strings" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +var pagoExternoHTTPClient = &http.Client{Timeout: 15 * time.Second} + +// ConfirmarPagoExternoPorReferencia resuelve una referencia de webhook con +// prefijo "extpay-" (las de Contrato usan "contrato-{id}") y, si corresponde, +// marca la solicitud como pagada y dispara la notificación al dueño del +// servicio. Devuelve manejada=true cuando la referencia era de este tipo, para +// que el webhook que llama sepa que no debe intentar resolverla como contrato. +func ConfirmarPagoExternoPorReferencia(referencia, pasarelaTxID, pasarela string) (manejada bool) { + if !strings.HasPrefix(referencia, "extpay-") { + return false + } + ok, solicitud, err := models.MarcarSolicitudPagoExternaPagada(referencia, pasarelaTxID) + if err != nil { + log.Printf("[PAGO_EXTERNO] error marcando %s como pagada: %v", referencia, err) + return true + } + if !ok { + log.Printf("[PAGO_EXTERNO] %s ya estaba confirmada, se ignora notificación duplicada", referencia) + return true + } + log.Printf("[PAGO_EXTERNO] %s confirmada vía %s", referencia, pasarela) + go EntregarNotificacionPagoExterno(solicitud) + return true +} + +// SolicitarPagoExterno crea un cobro para un ServicioPagoExterno ya +// autenticado (token+IP verificados por el middleware) usando una de sus +// pasarelas habilitadas. +func SolicitarPagoExterno(servicio *models.ServicioPagoExterno, referenciaExterna, pasarela, descripcion, moneda string, monto float64, clienteID *uint) (*models.SolicitudPagoExterna, error) { + if monto <= 0 { + return nil, fmt.Errorf("monto debe ser mayor a 0") + } + habilitadas := servicio.PasarelasList() + if len(habilitadas) == 0 { + return nil, fmt.Errorf("este servicio no tiene ninguna pasarela habilitada, contacta al administrador") + } + if pasarela == "" { + if len(habilitadas) == 1 { + pasarela = habilitadas[0] + } else { + return nil, fmt.Errorf("especifica 'pasarela': este servicio tiene varias habilitadas (%v)", habilitadas) + } + } else if !servicio.PasarelaHabilitada(pasarela) { + return nil, fmt.Errorf("pasarela '%s' no está habilitada para este servicio (habilitadas: %v)", pasarela, habilitadas) + } + if clienteID != nil { + if _, err := models.GetClienteByID(*clienteID); err != nil { + return nil, fmt.Errorf("cliente_id inválido: %w", err) + } + } + + referenciaInterna, err := models.GenerarReferenciaInterna() + if err != nil { + return nil, err + } + if descripcion == "" { + descripcion = fmt.Sprintf("Cobro %s", referenciaInterna) + } + if moneda == "" { + moneda = "COP" + } + + var enlace, linkID string + switch pasarela { + case "bold": + enlace, linkID, err = crearCobroBoldExterno(referenciaInterna, descripcion, moneda, monto) + case "dlocal": + enlace, linkID, err = crearCobroDlocalExterno(referenciaInterna, descripcion, moneda, monto) + case "paypal": + enlace, linkID, err = crearCobroPaypalExterno(referenciaInterna, descripcion, moneda, monto) + default: + err = fmt.Errorf("pasarela '%s' no soportada", pasarela) + } + if err != nil { + return nil, err + } + + solicitud := &models.SolicitudPagoExterna{ + ServicioID: servicio.ID, + ReferenciaExterna: referenciaExterna, + ReferenciaInterna: referenciaInterna, + ClienteID: clienteID, + Pasarela: pasarela, + Monto: monto, + Moneda: moneda, + Descripcion: descripcion, + Estado: "pendiente", + EnlacePago: enlace, + PasarelaLinkID: linkID, + } + if err := models.CreateSolicitudPagoExterna(solicitud); err != nil { + return nil, fmt.Errorf("no se pudo guardar la solicitud: %w", err) + } + return solicitud, nil +} + +func crearCobroBoldExterno(referencia, descripcion, moneda string, monto float64) (enlace, linkID string, err error) { + cfg, err := models.GetBoldConfig() + if err != nil { + return "", "", fmt.Errorf("Bold no está configurado") + } + req := BoldPaymentLinkRequest{ + AmountType: "CLOSE", + Amount: BoldAmountField{ + Currency: moneda, + TotalAmount: int64(math.Round(monto)), + }, + Description: descripcion, + Reference: referencia, + CallbackURL: cfg.CallbackUrl + "?ref=" + referencia, + } + result, err := CreateBoldPaymentLink(cfg, req) + if err != nil { + return "", "", err + } + return result.Payload.URL, result.Payload.PaymentLink, nil +} + +func crearCobroDlocalExterno(referencia, descripcion, moneda string, monto float64) (enlace, linkID string, err error) { + cfg, err := models.GetLastActiveDlocalApi() + if err != nil { + return "", "", fmt.Errorf("dLocal no está configurado") + } + pagoReq := PagoRequest{ + Currency: moneda, + Amount: monto, + OrderID: referencia, + Description: descripcion, + SuccessURL: absAppURL("/pago-exitoso?ref=" + referencia), + BackURL: absAppURL("/pago-cancelado?ref=" + referencia), + NotificationURL: absAppURL("/webhooks/dlocal"), + } + body, err := CreatePago(*cfg, pagoReq) + if err != nil { + return "", "", err + } + var resp struct { + RedirectURL string `json:"redirect_url"` + ID string `json:"id"` + } + if err := json.Unmarshal(body, &resp); err != nil || resp.RedirectURL == "" { + return "", "", fmt.Errorf("dLocal no devolvió redirect_url: %s", string(body)) + } + return resp.RedirectURL, resp.ID, nil +} + +func crearCobroPaypalExterno(referencia, descripcion, moneda string, monto float64) (enlace, linkID string, err error) { + cfg, err := models.GetPaypalConfig() + if err != nil { + return "", "", fmt.Errorf("PayPal no está configurado") + } + orderID, urlAprobacion, err := PaypalCrearOrden(cfg, referencia, descripcion, moneda, monto) + if err != nil { + return "", "", err + } + return urlAprobacion, orderID, nil +} + +// EntregarNotificacionPagoExterno avisa al dueño del ServicioPagoExterno que +// un cobro se confirmó, por los canales que tenga habilitados (webhook +// firmado y/o Telegram). Llamar solo una vez, justo después de que +// MarcarSolicitudPagoExternaPagada confirme la transición real (ok=true) — +// si no hay transición real (ya estaba pagada), no se vuelve a notificar. +func EntregarNotificacionPagoExterno(solicitud *models.SolicitudPagoExterna) { + if solicitud == nil { + return + } + if solicitud.Servicio == nil { + full, err := models.GetSolicitudPagoExternaByReferenciaInterna(solicitud.ReferenciaInterna) + if err != nil { + log.Printf("[PAGO_EXTERNO] no se pudo recargar la solicitud %s para notificar: %v", solicitud.ReferenciaInterna, err) + return + } + solicitud = full + } + servicio := solicitud.Servicio + if servicio == nil { + log.Printf("[PAGO_EXTERNO] solicitud %s sin servicio asociado, no se puede notificar", solicitud.ReferenciaInterna) + return + } + + payload := map[string]interface{}{ + "referencia_externa": solicitud.ReferenciaExterna, + "referencia_interna": solicitud.ReferenciaInterna, + "estado": solicitud.Estado, + "pasarela": solicitud.Pasarela, + "monto": solicitud.Monto, + "moneda": solicitud.Moneda, + "pasarela_tx_id": solicitud.PasarelaTxID, + "fecha_pago": solicitud.FechaPago, + } + body, _ := json.Marshal(payload) + + webhookOk := true + if servicio.NotificarWebhook && servicio.CallbackURL != "" { + webhookOk = enviarCallbackFirmado(servicio.CallbackURL, servicio.CallbackSecret, body) + if !webhookOk { + log.Printf("[PAGO_EXTERNO] fallo entregando callback de %s a %s", solicitud.ReferenciaInterna, servicio.CallbackURL) + } + } + + telegramOk := true + if servicio.NotificarTelegram && servicio.TelegramChatID != "" { + telegramOk = enviarTelegramPagoExterno(servicio.TelegramChatID, solicitud) + } + + if err := models.MarcarCallbackEntregado(solicitud.ID, webhookOk, telegramOk); err != nil { + log.Printf("[PAGO_EXTERNO] error registrando intento de entrega para %s: %v", solicitud.ReferenciaInterna, err) + } +} + +func enviarCallbackFirmado(url, secret string, body []byte) bool { + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/json") + if secret != "" { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) + } + resp, err := pagoExternoHTTPClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode >= 200 && resp.StatusCode < 300 +} + +func enviarTelegramPagoExterno(chatID string, s *models.SolicitudPagoExterna) bool { + _, tgCfg, err := models.GetAgenteBotConfig() + if err != nil || tgCfg == nil || tgCfg.BotToken == "" { + log.Printf("[PAGO_EXTERNO] no hay bot de Telegram del agente configurado para notificar %s", s.ReferenciaInterna) + return false + } + msg := fmt.Sprintf("💰 Pago confirmado\nRef: %s\nMonto: %.2f %s\nPasarela: %s", + s.ReferenciaExterna, s.Monto, s.Moneda, s.Pasarela) + svc := &TelegramService{BotToken: tgCfg.BotToken} + if err := svc.SendMessage(chatID, msg); err != nil { + log.Printf("[PAGO_EXTERNO] error enviando Telegram para %s: %v", s.ReferenciaInterna, err) + return false + } + return true +} diff --git a/resources/views/pagos_externos.html b/resources/views/pagos_externos.html new file mode 100644 index 0000000..21ca85b --- /dev/null +++ b/resources/views/pagos_externos.html @@ -0,0 +1,404 @@ + +
+ +
+ Cargando... +
+ +
+ + +
+
+

Pagos externos

+

Tokens de API para que aplicaciones de terceros pidan cobros a través de Bold, dLocal o PayPal.

+
+ +
+ + +
+ + +
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + +
NombreTokenPasarelasNotificaciónEstadoAcciones
+
+
+ + +
+
+ + + + + + + + + + + + + + + + +
Referencia externaServicioPasarelaMontoEstadoNotificadoFecha
+
+
+
+ + +
+
+

+
+
+ +
+ + +
+ +
+ +
+ +
+

Si el que llama no indica cuál usar, se toma la única habilitada (o falla si hay varias).

+
+ +
+ + +
+ +
+ +
+ +

Se envía un POST firmado (header X-Signature, HMAC-SHA256 con el callback_secret) al confirmarse el pago.

+
+ + +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+
+ + +
+
+

⚠️ Guarda estas credenciales ahora

+

No se volverán a mostrar completas. Si las pierdes, tendrás que regenerarlas.

+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+
+ + +
+
+

¿Regenerar token?

+

El token actual dejará de funcionar de inmediato. Cualquier app que lo use quedará desconectada hasta que actualices el nuevo.

+
+ + +
+
+
+ + +
+
+

¿Eliminar servicio?

+

El token dejará de funcionar de inmediato. Esta acción no se puede deshacer.

+
+ + +
+
+
+
+ + diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go index c3977aa..3bc0006 100644 --- a/rest/controllers/api/bold_controller.go +++ b/rest/controllers/api/bold_controller.go @@ -117,7 +117,13 @@ func BoldWebhook(c *fiber.Ctx) error { log.Printf("[BOLD] Webhook: SALE_APPROVED — payment_id=%s referencia=%s email=%s monto=%d", paymentID, referencia, payerEmail, monto) - // ─── 7. Marcar contrato como pagado y limpiar enlace ──────────────────── + // ─── 7. Intentar vincular con una solicitud de pago externo ───────────── + if services.ConfirmarPagoExternoPorReferencia(referencia, paymentID, "bold") { + models.MarkBoldWebhookProcessed(notificationID) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + // ─── 8. Marcar contrato como pagado y limpiar enlace ──────────────────── // La referencia tiene formato "contrato-{id}" if referencia != "" { var contratoID uint diff --git a/rest/controllers/api/dlocal_controller.go b/rest/controllers/api/dlocal_controller.go index d67e1ec..00d2200 100644 --- a/rest/controllers/api/dlocal_controller.go +++ b/rest/controllers/api/dlocal_controller.go @@ -320,6 +320,12 @@ func DlocalWebhook(c *fiber.Ctx) error { log.Printf("[DLOCAL] Webhook: PAID — payment_id=%s order=%s email=%s monto=%.2f %s", paymentID, orderID, payerEmail, monto, moneda) + // Intentar vincular con una solicitud de pago externo (referencia "extpay-…") + if services.ConfirmarPagoExternoPorReferencia(orderID, paymentID, "dlocal") { + models.MarkDlocalPaymentProcessed(notificationID) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + // Intentar vincular con contrato si el order_id tiene formato "contrato-{id}" if orderID != "" { var contratoID uint diff --git a/rest/controllers/api/pago_externo_controller.go b/rest/controllers/api/pago_externo_controller.go new file mode 100644 index 0000000..29840ac --- /dev/null +++ b/rest/controllers/api/pago_externo_controller.go @@ -0,0 +1,101 @@ +package controllers + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +func servicioPagoDeContexto(c *fiber.Ctx) (*models.ServicioPagoExterno, error) { + servicio, ok := c.Locals("servicio_pago").(*models.ServicioPagoExterno) + if !ok || servicio == nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado") + } + return servicio, nil +} + +// PasarelasDisponiblesHandler indica qué pasarelas puede usar el token actual. +// Ruta: GET /api/v1/pagos-externos/pasarelas +func PasarelasDisponiblesHandler(c *fiber.Ctx) error { + servicio, err := servicioPagoDeContexto(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) + } + return c.JSON(fiber.Map{"pasarelas": servicio.PasarelasList()}) +} + +// SolicitarPagoExternoHandler crea un cobro nuevo con el token autenticado. +// Ruta: POST /api/v1/pagos-externos/solicitar +func SolicitarPagoExternoHandler(c *fiber.Ctx) error { + servicio, err := servicioPagoDeContexto(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) + } + + var req struct { + ReferenciaExterna string `json:"referencia_externa"` + Pasarela string `json:"pasarela"` + Monto float64 `json:"monto"` + Moneda string `json:"moneda"` + Descripcion string `json:"descripcion"` + ClienteID *uint `json:"cliente_id"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "body inválido"}) + } + if strings.TrimSpace(req.ReferenciaExterna) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "referencia_externa es requerida"}) + } + + solicitud, err := services.SolicitarPagoExterno( + servicio, + strings.TrimSpace(req.ReferenciaExterna), + strings.ToLower(strings.TrimSpace(req.Pasarela)), + strings.TrimSpace(req.Descripcion), + strings.ToUpper(strings.TrimSpace(req.Moneda)), + req.Monto, + req.ClienteID, + ) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "referencia_externa": solicitud.ReferenciaExterna, + "referencia_interna": solicitud.ReferenciaInterna, + "enlace_pago": solicitud.EnlacePago, + "pasarela": solicitud.Pasarela, + "estado": solicitud.Estado, + "monto": solicitud.Monto, + "moneda": solicitud.Moneda, + }) +} + +// EstadoPagoExternoHandler consulta el estado de un cobro. Solo devuelve datos +// si la solicitud pertenece al mismo servicio autenticado, para que un token +// no pueda enumerar/consultar solicitudes de otro cliente. +// Ruta: GET /api/v1/pagos-externos/:referencia/estado +func EstadoPagoExternoHandler(c *fiber.Ctx) error { + servicio, err := servicioPagoDeContexto(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) + } + referencia := c.Params("referencia") + + solicitud, err := models.GetSolicitudPagoExternaByReferenciaInterna(referencia) + if err != nil || solicitud.ServicioID != servicio.ID { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "solicitud no encontrada"}) + } + + return c.JSON(fiber.Map{ + "referencia_externa": solicitud.ReferenciaExterna, + "referencia_interna": solicitud.ReferenciaInterna, + "estado": solicitud.Estado, + "pasarela": solicitud.Pasarela, + "monto": solicitud.Monto, + "moneda": solicitud.Moneda, + "fecha_pago": solicitud.FechaPago, + }) +} diff --git a/rest/controllers/api/paypal_controller.go b/rest/controllers/api/paypal_controller.go index c0e6063..2c21af8 100644 --- a/rest/controllers/api/paypal_controller.go +++ b/rest/controllers/api/paypal_controller.go @@ -74,17 +74,21 @@ func PaypalWebhook(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) } - marcarContratoPagadoPorReferencia(evento.Referencia, "paypal") + marcarContratoPagadoPorReferencia(evento.Referencia, evento.ResourceID, "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) { +// una referencia con formato "contrato-{id}", o de una solicitud de pago +// externo con formato "extpay-…". +func marcarContratoPagadoPorReferencia(referencia, transaccionID, pasarela string) { if referencia == "" { log.Printf("[%s] Pago confirmado pero sin referencia de contrato: requiere conciliación manual", pasarela) return } + if services.ConfirmarPagoExternoPorReferencia(referencia, transaccionID, 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) diff --git a/rest/controllers/pago_externo_admin_controller.go b/rest/controllers/pago_externo_admin_controller.go new file mode 100644 index 0000000..f146177 --- /dev/null +++ b/rest/controllers/pago_externo_admin_controller.go @@ -0,0 +1,231 @@ +package controllers + +import ( + "crypto/rand" + "encoding/hex" + "math" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +var pasarelasValidas = map[string]bool{"bold": true, "dlocal": true, "paypal": true} + +// PagosExternosIndex renderiza el panel de administración de servicios de pago externos. +func PagosExternosIndex(c *fiber.Ctx) error { + return c.Render("pagos_externos", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +// GetServiciosPagoExterno devuelve la lista paginada. El token nunca se +// devuelve completo, solo el preview guardado al crearlo/regenerarlo. +func GetServiciosPagoExterno(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + + items, total, err := models.GetAllServiciosPagoExterno(limit, offset) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +type servicioPagoReq struct { + Nombre string `json:"nombre"` + IPsPermitidas []string `json:"ips_permitidas"` + PasarelasHabilitadas []string `json:"pasarelas_habilitadas"` + NotificarWebhook bool `json:"notificar_webhook"` + CallbackURL string `json:"callback_url"` + NotificarTelegram bool `json:"notificar_telegram"` + TelegramChatID string `json:"telegram_chat_id"` + Activo bool `json:"activo"` +} + +func (r servicioPagoReq) validar() (pasarelas []string, ips []string, err error) { + if strings.TrimSpace(r.Nombre) == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "nombre es requerido") + } + for _, p := range r.PasarelasHabilitadas { + p = strings.ToLower(strings.TrimSpace(p)) + if p == "" { + continue + } + if !pasarelasValidas[p] { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "pasarela inválida: "+p) + } + pasarelas = append(pasarelas, p) + } + if len(pasarelas) == 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "selecciona al menos una pasarela habilitada") + } + for _, ip := range r.IPsPermitidas { + ip = strings.TrimSpace(ip) + if ip != "" { + ips = append(ips, ip) + } + } + if r.NotificarWebhook && strings.TrimSpace(r.CallbackURL) == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "callback_url es requerido si el webhook de notificación está activado") + } + if r.NotificarTelegram && strings.TrimSpace(r.TelegramChatID) == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "telegram_chat_id es requerido si la notificación por Telegram está activada") + } + return pasarelas, ips, nil +} + +func generarSecretoHex(nbytes int) (string, error) { + b := make([]byte, nbytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// CreateServicioPagoExternoHandler crea un nuevo servicio y devuelve el token +// en texto plano — es la única respuesta donde vendrá completo. +func CreateServicioPagoExternoHandler(c *fiber.Ctx) error { + var req servicioPagoReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + pasarelas, ips, err := req.validar() + if err != nil { + return c.Status(err.(*fiber.Error).Code).JSON(fiber.Map{"error": err.Error()}) + } + + item := &models.ServicioPagoExterno{ + Nombre: strings.TrimSpace(req.Nombre), + IPsPermitidas: strings.Join(ips, ","), + PasarelasHabilitadas: models.JoinModulos(pasarelas), + NotificarWebhook: req.NotificarWebhook, + CallbackURL: strings.TrimSpace(req.CallbackURL), + NotificarTelegram: req.NotificarTelegram, + TelegramChatID: strings.TrimSpace(req.TelegramChatID), + Activo: true, + CreadoPorID: extraerUserID(c), + } + if req.NotificarWebhook { + secret, err := generarSecretoHex(32) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo generar el secreto de firma"}) + } + item.CallbackSecret = secret + } + + tokenPlano, err := models.CreateServicioPagoExterno(item) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "ok": true, + "id": item.ID, + "token": tokenPlano, + "callback_secret": item.CallbackSecret, + "aviso": "Guarda el token y el callback_secret ahora: no se volverán a mostrar completos.", + }) +} + +// UpdateServicioPagoExternoHandler actualiza un servicio existente (no toca el token). +func UpdateServicioPagoExternoHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) + } + var req servicioPagoReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + pasarelas, ips, verr := req.validar() + if verr != nil { + return c.Status(verr.(*fiber.Error).Code).JSON(fiber.Map{"error": verr.Error()}) + } + + existente, err := models.GetServicioPagoExternoByID(uint(id)) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "servicio no encontrado"}) + } + + updates := map[string]interface{}{ + "nombre": strings.TrimSpace(req.Nombre), + "ips_permitidas": strings.Join(ips, ","), + "pasarelas_habilitadas": models.JoinModulos(pasarelas), + "notificar_webhook": req.NotificarWebhook, + "callback_url": strings.TrimSpace(req.CallbackURL), + "notificar_telegram": req.NotificarTelegram, + "telegram_chat_id": strings.TrimSpace(req.TelegramChatID), + "activo": req.Activo, + } + // Si se activa el webhook y todavía no tenía secreto (se creó sin él), generarlo ahora. + if req.NotificarWebhook && existente.CallbackSecret == "" { + secret, err := generarSecretoHex(32) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo generar el secreto de firma"}) + } + updates["callback_secret"] = secret + } + + if err := models.UpdateServicioPagoExterno(uint(id), updates); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// RegenerarTokenServicioPagoHandler invalida el token actual y devuelve uno nuevo. +func RegenerarTokenServicioPagoHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) + } + tokenPlano, err := models.RegenerarTokenServicioPago(uint(id)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true, "token": tokenPlano, "aviso": "El token anterior dejó de funcionar. Guarda este ahora, no se volverá a mostrar."}) +} + +func DeleteServicioPagoExternoHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) + } + if err := models.DeleteServicioPagoExterno(uint(id)); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// GetSolicitudesPagoExternoHandler devuelve el historial de cobros, opcionalmente +// filtrado por servicio, para trazabilidad/soporte. +func GetSolicitudesPagoExternoHandler(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + servicioID, _ := strconv.ParseUint(c.Query("servicio_id", "0"), 10, 64) + + items, total, err := models.GetAllSolicitudesPagoExterna(limit, offset, uint(servicioID)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} diff --git a/rest/middlewares/auth.go b/rest/middlewares/auth.go index 500eae9..434103b 100755 --- a/rest/middlewares/auth.go +++ b/rest/middlewares/auth.go @@ -263,7 +263,8 @@ func AuthAdmin(c *fiber.Ctx) error { func AuthApi() func(*fiber.Ctx) error { return func(c *fiber.Ctx) error { // Excluir rutas públicas y /api/v2 (tiene su propio middleware) - if c.Path() == "/api/v1/oauth/token" || c.Path() == "/api/sms/send" || strings.HasPrefix(c.Path(), "/api/v2") { + if c.Path() == "/api/v1/oauth/token" || c.Path() == "/api/sms/send" || strings.HasPrefix(c.Path(), "/api/v2") || + strings.HasPrefix(c.Path(), "/api/v1/pagos-externos") { return c.Next() } diff --git a/rest/middlewares/auth_servicio_pago.go b/rest/middlewares/auth_servicio_pago.go new file mode 100644 index 0000000..e97ffa5 --- /dev/null +++ b/rest/middlewares/auth_servicio_pago.go @@ -0,0 +1,33 @@ +package middlewares + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// AuthServicioPago protege los endpoints públicos de la API de pagos externos +// (/api/v1/pagos-externos/*). Es independiente de la sesión/JWT interna: el +// caller es una aplicación de un tercero que solo tiene un token largo +// (Bearer) y opcionalmente está restringido a una o varias IPs/CIDRs. Si pasa, +// deja el *models.ServicioPagoExterno resuelto en Locals("servicio_pago"). +func AuthServicioPago(c *fiber.Ctx) error { + auth := c.Get("Authorization") + token := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + if token == "" || token == auth { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Falta el header Authorization: Bearer "}) + } + + servicio, err := models.FindServicioPagoExternoActivoByToken(token) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Token inválido o servicio inactivo"}) + } + + if !servicio.IPPermitida(c.IP()) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "IP no autorizada para este servicio"}) + } + + c.Locals("servicio_pago", servicio) + return c.Next() +} diff --git a/rest/routes/api.go b/rest/routes/api.go index 892185f..f2a4214 100755 --- a/rest/routes/api.go +++ b/rest/routes/api.go @@ -3,6 +3,7 @@ package routes import ( "github.com/gofiber/fiber/v2" apiControllers "github.com/sujit-baniya/fiber-boilerplate/rest/controllers/api" + "github.com/sujit-baniya/fiber-boilerplate/rest/middlewares" ) func ApiRoutes(api fiber.Router) { @@ -24,6 +25,14 @@ func v1AuthRoutes(api fiber.Router) { api.Post("/rapyd/wallet/create", apiControllers.MakeWallet) api.Post("/generate-url-qr", apiControllers.CreateUrlQr) api.Post("/ia/ollama-generate", apiControllers.GeneraTextoStream) + + // ─── Pagos externos: API pública para apps de terceros ──────────────────── + // Autenticación propia (token Bearer + IP), independiente de la sesión/JWT + // interna — por eso está excluida en AuthApi() (rest/middlewares/auth.go). + pagosExt := api.Group("/pagos-externos", middlewares.AuthServicioPago) + pagosExt.Get("/pasarelas", apiControllers.PasarelasDisponiblesHandler) + pagosExt.Post("/solicitar", apiControllers.SolicitarPagoExternoHandler) + pagosExt.Get("/:referencia/estado", apiControllers.EstadoPagoExternoHandler) } func v1Routes(api fiber.Router) { diff --git a/rest/routes/hermes.go b/rest/routes/hermes.go index 6641807..ede1997 100644 --- a/rest/routes/hermes.go +++ b/rest/routes/hermes.go @@ -262,6 +262,14 @@ func AdminApiRoutes(api fiber.Router) { h.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler) h.Get("/ai-config/:id/test", controllers.TestAiConfigHandler) + // ─── Pagos externos (API para que apps de terceros pidan cobros) ─────────── + h.Get("/pagos-externos/servicios", controllers.GetServiciosPagoExterno) + h.Post("/pagos-externos/servicios", controllers.CreateServicioPagoExternoHandler) + h.Put("/pagos-externos/servicios/:id", controllers.UpdateServicioPagoExternoHandler) + h.Delete("/pagos-externos/servicios/:id", controllers.DeleteServicioPagoExternoHandler) + h.Post("/pagos-externos/servicios/:id/regenerar-token", controllers.RegenerarTokenServicioPagoHandler) + h.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler) + // ─── OSS API (almacenamiento) ──────────────────────────────────────────── h.Get("/oss-api", controllers.GetOssApiConfigs) h.Get("/oss-api/active", controllers.GetActiveOssApiList) diff --git a/rest/routes/user.go b/rest/routes/user.go index 04c05c7..a27d93e 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -329,6 +329,17 @@ func UserRoutes(app fiber.Router) { protected.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler) protected.Get("/ai-config/:id/test", controllers.TestAiConfigHandler) + // ─── Pagos externos (API para que apps de terceros pidan cobros) ─────────── + // Sensible: emitir/regenerar un token da acceso a crear cobros reales por + // Bold/dLocal/PayPal, así que solo un administrador puede administrarlo. + protected.Get("/pagos-externos", middlewares.MenuMiddleware, controllers.PagosExternosIndex) + protected.Get("/pagos-externos/servicios", controllers.GetServiciosPagoExterno) + protected.Post("/pagos-externos/servicios", middlewares.SoloAdmin, controllers.CreateServicioPagoExternoHandler) + protected.Put("/pagos-externos/servicios/:id", middlewares.SoloAdmin, controllers.UpdateServicioPagoExternoHandler) + protected.Delete("/pagos-externos/servicios/:id", middlewares.SoloAdmin, controllers.DeleteServicioPagoExternoHandler) + protected.Post("/pagos-externos/servicios/:id/regenerar-token", middlewares.SoloAdmin, controllers.RegenerarTokenServicioPagoHandler) + protected.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler) + // ─── OSS API (Alibaba Cloud + S3/MinIO) ──────────────────────────────────── protected.Get("/oss-api", middlewares.MenuMiddleware, controllers.OssApiIndex) protected.Get("/loadossapi", controllers.GetOssApiConfigs)