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 }