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 — 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
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
// 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
@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
| 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
- ✓Crear app en developer.paypal.com y obtener credenciales sandbox
- ✓Implementar
PayPalClient con OAuth 2.0 y manejo de errores
- ✓Rutas Flask:
/create-order, /capture-order, /refund, /ipn
- ✓Componente React
<PayPalCheckout> con Smart Buttons EUR
- ✓Verificación IPN con respuesta doble a PayPal
- !Crear cuentas de prueba sandbox en developer.paypal.com (buyer + seller)
- !Probar flujo completo en sandbox: pago → captura → activación
- !Probar reembolso dentro del periodo de 14 días (política EU)
- !Configurar ngrok/webhook para recibir IPN en local durante desarrollo
- !Cambiar
PAYPAL_MODE=live y credenciales de producción antes del deploy
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