NutriFlow × PayPal — Guía de Integración

Flask + React · Checkout · Suscripciones · Reembolsos · IPN · EUR
PayPal JS SDK Python 3.11 Flask React 18 Sandbox ✓
1

Flujo de pago completo

Express Checkout
Paso 1
Cliente hace clic
React renderiza el botón PayPal Smart Buttons con el plan seleccionado
Paso 2
createOrder
Frontend llama a /api/paypal/create-order → Flask crea orden en PayPal v2
Paso 3
Autorización
PayPal popup: usuario aprueba el pago con su cuenta o tarjeta
Paso 4
captureOrder
Frontend llama a /api/paypal/capture-order → Flask captura y activa licencia
Paso 5
Confirmación
Email automático + activación cuenta + log en DB transactions
2

Backend Flask — paypal_client.py

Python
nutriflow/payments/paypal_client.py Python
# NutriFlow — PayPal REST API client (Python 3.11)
# Soporta: checkout único, suscripciones, reembolsos

import os, requests
from dataclasses import dataclass
from typing import Optional

PAYPAL_PLANS = {
    "licencia_anual":  { "amount": "149.00", "currency": "EUR", "type": "ONE_TIME" },
    "clinica_pro_mes": { "amount":  "29.00", "currency": "EUR", "type": "SUBSCRIPTION" },
}

class PayPalClient:
    def __init__(self, mode="sandbox"):
        self.client_id     = os.environ["PAYPAL_CLIENT_ID"]
        self.client_secret  = os.environ["PAYPAL_CLIENT_SECRET"]
        self.base_url = (
            "https://api-m.sandbox.paypal.com" if mode == "sandbox"
            else "https://api-m.paypal.com"
        )
        self.token = self._get_token()

    def _get_token(self) -> str:
        r = requests.post(
            f"{self.base_url}/v1/oauth2/token",
            data={"grant_type": "client_credentials"},
            auth=(self.client_id, self.client_secret),
        )
        r.raise_for_status()
        return r.json()["access_token"]

    def create_order(self, plan_key: str) -> dict:
        """Crea orden de pago único (licencia_anual)."""
        plan = PAYPAL_PLANS[plan_key]
        r = requests.post(
            f"{self.base_url}/v2/checkout/orders",
            headers={"Authorization": f"Bearer {self.token}"},
            json={
                "intent": "CAPTURE",
                "purchase_units": [{
                    "reference_id": plan_key,
                    "description":  "NutriFlow Licencia Anual Pro",
                    "amount": {
                        "currency_code": plan["currency"],
                        "value": plan["amount"],
                    }
                }],
                "application_context": {
                    "return_url": "https://app.nutriflow.es/pago/exito",
                    "cancel_url": "https://app.nutriflow.es/pago/cancelado",
                }
            }
        )
        r.raise_for_status()
        return r.json()

    def capture_order(self, order_id: str) -> dict:
        """Captura el pago autorizado por el usuario."""
        r = requests.post(
            f"{self.base_url}/v2/checkout/orders/{order_id}/capture",
            headers={
                "Authorization": f"Bearer {self.token}",
                "Content-Type": "application/json",
            },
        )
        r.raise_for_status()
        return r.json()

    def create_refund(self, capture_id: str,
                       amount: Optional[str]=None,
                       note: Optional[str]=None) -> dict:
        """Reembolso parcial o total dentro de los 14 días (política EU)."""
        payload = {}
        if amount:
            payload["amount"] = {"value": amount, "currency_code": "EUR"}
        if note:
            payload["note_to_payer"] = note
        r = requests.post(
            f"{self.base_url}/v2/payments/captures/{capture_id}/refund",
            headers={"Authorization": f"Bearer {self.token}"},
            json=payload,
        )
        r.raise_for_status()
        return r.json()
3

Rutas Flask — routes/paypal.py

Python · Flask
nutriflow/routes/paypal.py Python
from flask import Blueprint, request, jsonify
from nutriflow.payments.paypal_client import PayPalClient
from nutriflow.models import db, Transaction, User
from nutriflow.notifications import send_confirmation_email
import os

bp = Blueprint("paypal", __name__, url_prefix="/api/paypal")
MODE = os.getenv("PAYPAL_MODE", "sandbox")

@bp.route("/create-order", methods=["POST"])
def create_order():
    data     = request.get_json()
    plan_key = data.get("plan", "licencia_anual")
    client   = PayPalClient(mode=MODE)
    order    = client.create_order(plan_key)
    return jsonify({"orderID": order["id"]})

@bp.route("/capture-order", methods=["POST"])
def capture_order():
    data     = request.get_json()
    order_id = data["orderID"]
    user_id  = data["userID"]
    client   = PayPalClient(mode=MODE)
    result   = client.capture_order(order_id)

    if result["status"] == "COMPLETED":
        capture = result["purchase_units"][0]["payments"]["captures"][0]
        # Guardar transacción
        tx = Transaction(
            user_id   = user_id,
            paypal_id = capture["id"],
            amount    = float(capture["amount"]["value"]),
            currency  = capture["amount"]["currency_code"],
            status    = "COMPLETED",
        )
        db.session.add(tx)
        # Activar licencia del usuario
        User.query.get(user_id).plan = "pro"
        db.session.commit()
        send_confirmation_email(user_id, capture)
        return jsonify({"status": "ok", "transaction_id": capture["id"]})

    return jsonify({"status": "error"}), 400

@bp.route("/refund", methods=["POST"])
def refund():
    data       = request.get_json()
    capture_id = data["capture_id"]
    client     = PayPalClient(mode=MODE)
    result     = client.create_refund(
        capture_id,
        note="Reembolso política 14 días — NutriFlow"
    )
    return jsonify(result)
4

Frontend React — PayPalCheckout.tsx

React 18 · TypeScript
src/components/PayPalCheckout.tsx TSX
// NutriFlow — Smart Buttons con PayPal JS SDK
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";

interface Props { plan: "licencia_anual" | "clinica_pro_mes"; userID: string; }

const PAYPAL_OPTIONS = {
  "client-id": import.meta.env.VITE_PAYPAL_CLIENT_ID,
  currency: "EUR",
  intent: "capture",
};

export function PayPalCheckout({ plan, userID }: Props) {
  return (
    <PayPalScriptProvider options={PAYPAL_OPTIONS}>
      <PayPalButtons
        style={{ layout: "vertical", color: "gold", shape: "rect" }}
        createOrder={async () => {
          const res = await fetch("/api/paypal/create-order", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ plan }),
          });
          const { orderID } = await res.json();
          return orderID;
        }}
        onApprove={async (data) => {
          const res = await fetch("/api/paypal/capture-order", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ orderID: data.orderID, userID }),
          });
          const result = await res.json();
          if (result.status === "ok") {
            window.location.href = "/dashboard?pago=exito";
          }
        }}
        onError={(err) => console.error("PayPal error:", err)}
      />
    </PayPalScriptProvider>
  );
}
5

IPN Handler — Webhooks asíncronos

Flask · IPN
nutriflow/routes/ipn.py Python
@bp.route("/api/paypal/ipn", methods=["POST"])
def handle_ipn():
    ipn_data = request.form.to_dict()
    # 1. Verificar autenticidad con PayPal
    verify  = ipn_data.copy()
    verify["cmd"] = "_notify-validate"
    pp_url  = (
        "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"
        if MODE == "sandbox"
        else "https://ipnpb.paypal.com/cgi-bin/webscr"
    )
    resp  = requests.post(pp_url, data=verify)
    if resp.text != "VERIFIED":
        return "INVALID", 400

    # 2. Enrutar por tipo de evento
    status   = ipn_data.get("payment_status")
    txn_type = ipn_data.get("txn_type")

    if   status   == "Completed":           _activate_account(ipn_data)
    elif status   == "Refunded":            _handle_refund(ipn_data)
    elif status   == "Reversed":           _handle_chargeback(ipn_data)
    elif txn_type == "subscr_failed":      _suspend_account(ipn_data)
    elif txn_type == "subscr_cancel":     _cancel_subscription(ipn_data)

    return "OK", 200
6

Variables de entorno requeridas

.env
PAYPAL_CLIENT_ID AaBbCc...tu_sandbox_client_id REQUIRED
PAYPAL_CLIENT_SECRET EFgHiJ...tu_sandbox_secret REQUIRED
PAYPAL_MODE sandbox | live default: sandbox
VITE_PAYPAL_CLIENT_ID (mismo valor, expuesto al frontend React) REQUIRED
PAYPAL_IPN_VERIFY_URL https://ipnpb.sandbox.paypal.com/cgi-bin/webscr overridable
7

Planes de pago — NutriFlow

Precios EUR
Configuración PAYPAL_PLANS
Clave Descripción Importe Tipo Endpoint
licencia_anual Licencia Pro anual — pago único €149.00 ONE_TIME /api/paypal/create-order
clinica_pro_mes Plan Clínica Pro — mensual recurrente €29.00 SUBSCRIPTION /v1/billing/subscriptions
8

Checklist de lanzamiento

QA & Go-live

COMPLETED — Activar cuenta PENDING — En revisión PayPal REVERSED — Chargeback → suspender REFUNDED — Reembolso procesado subscr_failed — Cobro fallido → reintento subscr_cancel — Cancelación → degradar plan