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>
354 lines
11 KiB
Go
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]
|
|
}
|