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:
co-authored by
Claude Opus 5
parent
5607836261
commit
c8e5afceb2
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user