Permite emitir credenciales (token hasheado + IP/CIDR opcional) desde
/app/pagos-externos para que aplicaciones de terceros pidan cobros a
través de Bold/dLocal/PayPal sin acceso a nada más del sistema:
- POST /api/v1/pagos-externos/solicitar genera el link de cobro real
usando solo las pasarelas habilitadas para ese servicio.
- Los webhooks existentes de Bold/dLocal/PayPal (firma obligatoria,
idempotentes) ahora también resuelven referencias "extpay-…" sin
tocar el flujo de contratos ("contrato-{id}").
- Al confirmarse el pago se notifica por webhook firmado (HMAC) y/o
Telegram, configurable por servicio.
- CRUD de servicios protegido con SoloAdmin; token y callback_secret
solo se muestran una vez, en DB se guardan hasheados.
264 lines
8.9 KiB
Go
264 lines
8.9 KiB
Go
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
|
|
}
|