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