EmpiezaTuSaaS
Funcionalidades

Pagos

Stripe Checkout, webhooks y customer portal integrados

Pagos

EmpiezaTuSaaS incluye integración completa con Stripe para pagos únicos (one-time payments). Checkout hosted, webhooks, customer portal, cupones, Tax ID y multiples monedas -- todo listo para usar.

Setup de Stripe

Crear cuenta y obtener API keys

  1. Crea una cuenta en stripe.com
  2. Ve a Developers > API keys y copia las claves de test mode
  3. Configura tus variables de entorno:
.env.local
STRIPE_SECRET_KEY="sk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."

Crear productos en Stripe

  1. Ve a Products en el dashboard
  2. Crea un producto por plan (Starter, Pro, Lifetime)
  3. Para cada uno, crea un precio de pago unico (one-time)
  4. Copia los Price IDs:
.env.local
STRIPE_PRICE_ID_STARTER="price_..."
STRIPE_PRICE_ID_PRO="price_..."
STRIPE_PRICE_ID_LIFETIME="price_..."

Instalar SDK

npm install stripe @stripe/stripe-js

Cliente de Stripe (servidor)

El boilerplate usa inicializacion lazy con Proxy para evitar errores en build:

lib/stripe.ts
import Stripe from "stripe";

let _stripe: Stripe | null = null;

export function getStripeServer(): Stripe {
  if (!process.env.STRIPE_SECRET_KEY) {
    throw new Error("STRIPE_SECRET_KEY is not set");
  }
  if (!_stripe) {
    _stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  }
  return _stripe;
}

export const stripe = new Proxy({} as Stripe, {
  get(_target, prop) {
    return getStripeServer()[prop as keyof Stripe];
  },
});

Cliente de Stripe (navegador)

lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js";

let stripePromise: ReturnType<typeof loadStripe> | null = null;

export function getStripe() {
  if (!stripePromise) {
    stripePromise = loadStripe(
      process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
    );
  }
  return stripePromise;
}

Configuración de planes

Los planes se definen en config/stripe.ts. Todos usan mode: "payment" (pago unico):

config/stripe.ts
export interface PricingPlan {
  id: string;
  name: string;
  description: string;
  price: number;
  currency: string;
  interval: "month" | "year" | "one_time";
  priceId: string;
  features: string[];
  isPopular?: boolean;
  isFeatured?: boolean;
  cta: string;
  mode: "subscription" | "payment";
}

export const pricingPlans: PricingPlan[] = [
  {
    id: "starter",
    name: "Starter",
    price: 49,
    currency: "EUR",
    interval: "one_time",
    priceId: process.env.STRIPE_PRICE_ID_STARTER || "",
    mode: "payment",
    // ...features y descripción
  },
  // Pro (99 EUR), Lifetime (199 EUR) -- misma estructura
];

export const stripeConfig = {
  plans: pricingPlans,
  successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
  cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/#pricing`,
  portalReturnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
};

Usa claves de test (sk_test_, pk_test_) en desarrollo. Las claves live empiezan con sk_live_ y pk_live_.

Paywall (acceso por suscripción)

Por defecto, el boilerplate requiere que el usuario tenga una suscripción activa para acceder al dashboard. Si no tiene plan, ve la página de pricing en lugar del dashboard.

Cómo funciona

El flag requirePayment en config/site.ts controla este comportamiento:

config/site.ts
export const siteConfig = {
  // ...
  requirePayment: true, // true = paywall activado (default)
};

Flujo con requirePayment: true:

Usuario hace login
    |
Dashboard Layout
    |
¿Tiene suscripción activa?
    |
+-- NO  → Muestra página de pricing (con header y user menu)
|         → Usuario elige plan → Stripe Checkout → Paga
|         → Webhook activa suscripción → Refresca → Ve el dashboard
|
+-- SÍ  → Muestra el dashboard normal (sidebar + contenido)

Excepciones:

  • Los admins se saltan el paywall siempre — acceden al dashboard sin necesidad de plan
  • La verificación se hace en app/[locale]/(dashboard)/layout.tsx consultando la tabla subscriptions

Cambiar a modelo freemium

Si prefieres que los usuarios accedan al dashboard sin pagar (freemium), simplemente cambia:

config/site.ts
requirePayment: false, // Dashboard accesible sin plan activo

Con requirePayment: false, el dashboard se muestra siempre y la página de billing muestra "Sin suscripción activa" con un enlace a los planes.

Implementación en el layout

app/[locale]/(dashboard)/layout.tsx
// Paywall: check subscription if requirePayment is enabled
if (siteConfig.requirePayment && session?.user) {
  const userRole = (session.user as Record<string, unknown>)?.role as string;

  // Admins skip the paywall
  if (!isAdminRole(userRole)) {
    const subscription = await db.subscription.findUnique({
      where: { userId: session.user.id },
      select: { status: true },
    });

    if (subscription?.status !== "active") {
      return (
        // Header con logo + user menu
        // + componente <Pricing /> a pantalla completa
      );
    }
  }
}
// Si tiene suscripción → render normal del dashboard con sidebar

Checkout

Stripe Checkout proporciona páginas de pago hosted, seguras y optimizadas para conversión.

Ruta API de checkout

La ruta detecta automáticamente si el precio es de pago único o recurrente:

app/api/stripe/checkout/route.ts
export async function POST(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session?.user) {
    return NextResponse.json(
      { message: "No autorizado" }, { status: 401 }
    );
  }

  const { priceId, planName, couponId } = await request.json();

  // Buscar o crear cliente de Stripe
  let stripeCustomerId: string | undefined;
  const subscription = await db.subscription.findUnique({
    where: { userId: user.id },
  });

  if (subscription?.stripeCustomerId) {
    stripeCustomerId = subscription.stripeCustomerId;
  } else {
    const customer = await stripe.customers.create({
      email: user.email,
      name: user.name,
      metadata: { userId: user.id },
    });
    stripeCustomerId = customer.id;
    // Guardar customer ID en DB...
  }

  // Detectar modo automáticamente
  const price = await stripe.prices.retrieve(priceId);
  const mode = price.type === "recurring" ? "subscription" : "payment";

  const checkoutSession = await stripe.checkout.sessions.create({
    customer: stripeCustomerId,
    mode,
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: stripeConfig.successUrl,
    cancel_url: stripeConfig.cancelUrl,
    metadata: { userId: user.id, planName: planName || "" },
    tax_id_collection: { enabled: true },
    customer_update: { name: "auto", address: "auto" },
    ...(couponId
      ? { discounts: [{ coupon: couponId }] }
      : { allow_promotion_codes: true }),
  });

  return NextResponse.json({ url: checkoutSession.url });
}

Boton de checkout

components/checkout-button.tsx
"use client";

import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";

interface CheckoutButtonProps {
  priceId: string;
  planName: string;
  children: React.ReactNode;
  className?: string;
  variant?: "default" | "outline" | "secondary" | "ghost" | "link" | "destructive";
}

export function CheckoutButton({
  priceId, planName, children, className, variant = "default",
}: CheckoutButtonProps) {
  const [isLoading, setIsLoading] = useState(false);

  const handleCheckout = async () => {
    setIsLoading(true);
    try {
      const response = await fetch("/api/stripe/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ priceId, planName }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || "Error al crear el checkout");
      }

      const { url } = await response.json();
      if (url) window.location.href = url;
    } catch (error) {
      toast.error(
        error instanceof Error ? error.message : "Error al procesar el pago."
      );
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Button variant={variant} className={className} onClick={handleCheckout} disabled={isLoading}>
      {isLoading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Procesando...</> : children}
    </Button>
  );
}

Ejemplo de uso

import { CheckoutButton } from "@/components/checkout-button";
import { pricingPlans } from "@/config/stripe";

{pricingPlans.map((plan) => (
  <CheckoutButton priceId={plan.priceId} planName={plan.name}>
    {plan.cta}
  </CheckoutButton>
))}

Cupones vs códigos promocionales

  • allow_promotion_codes: true -- El usuario introduce un código en el checkout
  • discounts: [{ coupon: couponId }] -- Aplicas un cupon programaticamente (no se pueden combinar ambos)

Webhooks

Los webhooks sincronizan eventos de Stripe con tu base de datos.

Eventos que maneja el boilerplate

EventoDescripción
checkout.session.completedPago único completado
customer.subscription.createdSuscripcion creada
customer.subscription.updatedSuscripcion actualizada
customer.subscription.deletedSuscripcion cancelada
invoice.paidFactura pagada
invoice.payment_failedPago fallido

Handler de webhooks

app/api/stripe/webhooks/route.ts
export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json(
      { message: "No se encontro la firma" }, { status: 400 }
    );
  }

  // Verificar firma -- NUNCA proceses sin verificar
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (error) {
    return NextResponse.json(
      { message: "Firma invalida" }, { status: 400 }
    );
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutCompleted(session);
      break;
    }
    case "customer.subscription.created":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionUpdate(subscription);
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionDeleted(subscription);
      break;
    }
    // invoice.paid, invoice.payment_failed...
  }

  return NextResponse.json({ received: true });
}

Handler de checkout completado (pago unico)

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  const planName = session.metadata?.planName;

  if (!userId) return;

  if (session.mode === "payment") {
    await db.subscription.upsert({
      where: { userId },
      update: {
        plan: planName?.toLowerCase() || "starter",
        status: "active",
        stripePriceId: session.line_items?.data[0]?.price?.id,
      },
      create: {
        userId,
        stripeCustomerId: session.customer as string,
        plan: planName?.toLowerCase() || "starter",
        status: "active",
        stripePriceId: session.line_items?.data[0]?.price?.id,
      },
    });
  }
}

Para pagos únicos, el status se pone como "active" permanentemente (sin fecha de expiracion).

Modelo de datos

prisma/schema.prisma
model Subscription {
  id                   String    @id @default(cuid())
  userId               String    @unique
  user                 User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  stripeCustomerId     String?   @unique
  stripeSubscriptionId String?   @unique
  stripePriceId        String?
  stripeCurrentPeriodEnd DateTime?
  plan                 String?   // "starter" | "pro" | "lifetime"
  status               String    @default("inactive")
  createdAt            DateTime  @default(now())
  updatedAt            DateTime  @updatedAt

  @@map("subscriptions")
}

Verificar acceso del usuario

const subscription = await db.subscription.findUnique({
  where: { userId: session.user.id },
});

const hasAccess = subscription?.status === "active";
const userPlan = subscription?.plan; // "starter" | "pro" | "lifetime"

Verificar plan minimo

const planHierarchy = ["starter", "pro", "lifetime"];

function hasMinimumPlan(userPlan: string | null | undefined, requiredPlan: string): boolean {
  if (!userPlan) return false;
  return planHierarchy.indexOf(userPlan) >= planHierarchy.indexOf(requiredPlan);
}

Siempre verifica la firma del webhook con constructEvent() antes de procesar. Nunca confies en datos no verificados.

Customer Portal

El Customer Portal de Stripe permite a los usuarios gestiónar facturación sin que construyas la UI.

Configurar en Stripe

  1. Ve a Settings > Billing > Customer portal
  2. Configura logo, colores, permisos de método de pago e historial de facturas

Ruta API del portal

app/api/stripe/portal/route.ts
export async function POST(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session?.user) {
    return NextResponse.json({ message: "No autorizado" }, { status: 401 });
  }

  const subscription = await db.subscription.findUnique({
    where: { userId: session.user.id },
  });

  if (!subscription?.stripeCustomerId) {
    return NextResponse.json(
      { message: "No se encontro suscripcion activa" }, { status: 400 }
    );
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: subscription.stripeCustomerId,
    return_url: stripeConfig.portalReturnUrl,
  });

  return NextResponse.json({ url: portalSession.url });
}

Boton para abrir el portal

components/manage-subscription-button.tsx
"use client";

import { useState } from "react";
import { Loader2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";

export function ManageSubscriptionButton() {
  const [isLoading, setIsLoading] = useState(false);

  const handleManage = async () => {
    setIsLoading(true);
    try {
      const response = await fetch("/api/stripe/portal", { method: "POST" });
      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message);
      }
      const { url } = await response.json();
      if (url) window.location.href = url;
    } catch (error) {
      toast.error(
        error instanceof Error ? error.message : "Error al abrir el portal."
      );
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Button variant="outline" onClick={handleManage} disabled={isLoading}>
      {isLoading ? (
        <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Cargando...</>
      ) : (
        <><ExternalLink className="mr-2 h-4 w-4" />Gestiónar facturación</>
      )}
    </Button>
  );
}

Testing local

Para probar webhooks en desarrollo usa Stripe CLI:

# Instalar
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Escuchar y reenviar a tu servidor local
stripe listen --forward-to localhost:3000/api/stripe/webhooks

El CLI te dara un whsec_... temporal. Copialo en .env.local como STRIPE_WEBHOOK_SECRET.

Disparar eventos de prueba

stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

Tarjetas de prueba

TarjetaResultado
4242 4242 4242 4242Pago exitoso
4000 0000 0000 9995Pago rechazado
4000 0025 0000 3155Requiere autenticación

Producción

Checklist antes de ir a producción:

  • Desactivar test mode en Stripe y usar claves sk_live_ / pk_live_
  • Crear productos y precios en modo live (los Price IDs cambian)
  • Actualizar STRIPE_PRICE_ID_* en las variables de entorno de producción
  • Crear webhook endpoint en Stripe apuntando a https://tuapp.com/api/stripe/webhooks
  • Seleccionar los 6 eventos del boilerplate en el endpoint
  • Copiar el signing secret de producción como STRIPE_WEBHOOK_SECRET
  • Configurar el Customer Portal en Settings > Billing > Customer portal
  • Verificar que NEXT_PUBLIC_APP_URL apunta a tu dominio de producción
  • Completar la verificación de negocio en Stripe para recibir pagos reales
  • Hacer un pago de prueba con una tarjeta real (puedes reembolsarlo)

On this page