Files
soft_usite/pkg/services/paypal_service.go
T
Lizandro GDandClaude Opus 5 c8e5afceb2 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>
2026-08-03 03:20:00 +00:00

354 lines
11 KiB
Go

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]
}