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
- Crea una cuenta en stripe.com
- Ve a Developers > API keys y copia las claves de test mode
- Configura tus variables de entorno:
STRIPE_SECRET_KEY="sk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."Crear productos en Stripe
- Ve a Products en el dashboard
- Crea un producto por plan (Starter, Pro, Lifetime)
- Para cada uno, crea un precio de pago unico (one-time)
- Copia los Price IDs:
STRIPE_PRICE_ID_STARTER="price_..."
STRIPE_PRICE_ID_PRO="price_..."
STRIPE_PRICE_ID_LIFETIME="price_..."Instalar SDK
npm install stripe @stripe/stripe-jsCliente de Stripe (servidor)
El boilerplate usa inicializacion lazy con Proxy para evitar errores en build:
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)
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):
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:
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.tsxconsultando la tablasubscriptions
Cambiar a modelo freemium
Si prefieres que los usuarios accedan al dashboard sin pagar (freemium), simplemente cambia:
requirePayment: false, // Dashboard accesible sin plan activoCon 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
// 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 sidebarCheckout
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:
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
"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 checkoutdiscounts: [{ 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
| Evento | Descripción |
|---|---|
checkout.session.completed | Pago único completado |
customer.subscription.created | Suscripcion creada |
customer.subscription.updated | Suscripcion actualizada |
customer.subscription.deleted | Suscripcion cancelada |
invoice.paid | Factura pagada |
invoice.payment_failed | Pago fallido |
Handler de webhooks
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
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
- Ve a Settings > Billing > Customer portal
- Configura logo, colores, permisos de método de pago e historial de facturas
Ruta API del portal
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
"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/webhooksEl 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_failedTarjetas de prueba
| Tarjeta | Resultado |
|---|---|
4242 4242 4242 4242 | Pago exitoso |
4000 0000 0000 9995 | Pago rechazado |
4000 0025 0000 3155 | Requiere 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_URLapunta 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)