Stripe Integration Production Ready

FlowMetrics — Integración Stripe

Referencia completa de código TypeScript / Next.js 14 App Router para suscripciones, pagos, webhooks idempotentes, portal de cliente y facturación por uso.

💳
Checkout
Trial 14 días
🔄
Upgrade/Down
Prorateado
📊
Metered
0,005€ / 1k ev.
🔗
Webhooks
Idempotentes
🛡️
Feature Gate
Grace period
Planes FlowMetrics
Plan Mensual Anual Eventos/mes Proyectos Trial Stripe Price ID (env)
Starter 29 € 290 € (-17%) 100.000 3 ✓ 14 días STRIPE_STARTER_*_PRICE_ID
Pro 79 € 790 € (-17%) 1.000.000 Ilimitados ✓ 14 días STRIPE_PRO_*_PRICE_ID
Enterprise Factura a medida 10M+ Ilimitados STRIPE_ENT_PRICE_ID
Ciclo de vida de suscripción
Estado de suscripción → DB: subscriptionStatus
TRIALING
paid
ACTIVE
cancel
CANCEL_PENDING
period_end
CANCELED
TRIALING
trial_end sin pago
PAST_DUE
3x fallido
CANCELED
pago ok
ACTIVE
Configuración cliente y Checkout Session
📄lib/stripe.ts
📄api/billing/checkout/route.ts
📄api/billing/portal/route.ts
// lib/stripe.ts — FlowMetrics Stripe client
import Stripe from "stripe"

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-04-10",
  typescript: true,
  appInfo: { name: "flowmetrics", version: "1.0.0" },
})

export const PLANS = {
  starter: {
    monthly:  process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
    yearly:   process.env.STRIPE_STARTER_YEARLY_PRICE_ID!,
    eventsLimit: 100_000,
    projects:    3,
    label: "Starter",
  },
  pro: {
    monthly:  process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
    yearly:   process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
    eventsLimit: 1_000_000,
    projects:    Infinity,
    label: "Pro",
  },
} as const

// Metered price for events over quota
export const METERED_EVENTS_PRICE_ID = process.env.STRIPE_METERED_EVENTS_PRICE_ID!

// ─────────────────────────────────────────────
// app/api/billing/checkout/route.ts
// ─────────────────────────────────────────────
import { NextResponse } from "next/server"
import { stripe, PLANS } from "@/lib/stripe"
import { getAuthUser } from "@/lib/auth"
import { db } from "@/lib/db"

export async function POST(req: Request) {
  const user = await getAuthUser()
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })

  const { planKey, interval } = await req.json()  // interval: "monthly" | "yearly"
  const plan = PLANS[planKey as keyof typeof PLANS]
  if (!plan) return NextResponse.json({ error: "Invalid plan" }, { status: 400 })

  // Get or create Stripe customer
  let customerId = user.stripeCustomerId
  if (!customerId) {
    const customer = await stripe.customers.create({
      email:    user.email,
      name:     user.name,
      metadata: { userId: user.id },
    })
    customerId = customer.id
    await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customerId } })
  }

  const session = await stripe.checkout.sessions.create({
    customer:             customerId,
    mode:                 "subscription",
    payment_method_types: ["card"],
    line_items:           [{ price: plan[interval], quantity: 1 }],
    allow_promotion_codes: true,
    subscription_data: {
      // Solo primer trial por workspace
      trial_period_days: user.hasHadTrial ? undefined : 14,
      metadata: { userId: user.id, planKey: planKey },
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session={CHECKOUT_SESSION_ID}`,
    cancel_url:  `${process.env.NEXT_PUBLIC_APP_URL}/pricing?cancelled=1`,
    metadata:    { userId: user.id },
  })

  return NextResponse.json({ url: session.url })
}
Upgrade, Downgrade y Preview de Prorateamiento
📄lib/billing.ts
// lib/billing.ts — Upgrade/Downgrade + Prorateamiento FlowMetrics
import { stripe } from "@/lib/stripe"

/**
 * Cambia el plan de suscripción.
 * immediate=true → upgrade inmediato con prorateamiento (genera factura)
 * immediate=false → downgrade al final del período, sin cargo adicional
 */
export async function changeSubscriptionPlan(
  subscriptionId: string,
  newPriceId: string,
  immediate = false,
) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId)
  const item = sub.items.data[0]

  return stripe.subscriptions.update(subscriptionId, {
    items: [{ id: item.id, price: newPriceId }],
    proration_behavior:   immediate ? "always_invoice" : "none",
    billing_cycle_anchor: "unchanged",
  })
}

/** Preview del cargo de prorateamiento antes de confirmar upgrade */
export async function previewProration(subscriptionId: string, newPriceId: string) {
  const sub             = await stripe.subscriptions.retrieve(subscriptionId)
  const prorationDate   = Math.floor(Date.now() / 1000)

  const preview = await stripe.invoices.retrieveUpcoming({
    customer:                     sub.customer as string,
    subscription:                 subscriptionId,
    subscription_items:           [{ id: sub.items.data[0].id, price: newPriceId }],
    subscription_proration_date:  prorationDate,
  })

  return {
    amountDueCents: preview.amount_due,
    amountDueEur:   (preview.amount_due / 100).toFixed(2),
    prorationDate,
    lines: preview.lines.data.map((l) => ({
      description: l.description,
      amount:      (l.amount / 100).toFixed(2) + " €",
    })),
  }
}

/** Reportar eventos para facturación metered (over-quota) */
export async function reportEvents(subscriptionItemId: string, quantity: number) {
  await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
    quantity,
    timestamp: Math.floor(Date.now() / 1000),
    action: "increment",
  })
}

/** Feature gate — acceso activo si active|trialing o grace period past_due */
export function isSubscriptionActive(user: {
  subscriptionStatus:   string | null
  stripeCurrentPeriodEnd: Date | null
}): boolean {
  if (!user.subscriptionStatus) return false
  if (["active", "trialing"].includes(user.subscriptionStatus)) return true
  // Grace period: past_due pero dentro del período actual
  if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
    return user.stripeCurrentPeriodEnd > new Date()
  }
  return false
}
Eventos Webhook manejados
Evento Stripe Acción en FlowMetrics DB Prioridad
checkout.session.completed Vincula stripeSubscriptionId al user, marca hasHadTrial=true, sincroniza plan y period_end CRÍTICO
customer.subscription.updated Actualiza priceId, status, period_end y cancelAtPeriodEnd CRÍTICO
customer.subscription.deleted Anula subscriptionId/priceId/periodEnd; status → "canceled" CRÍTICO
invoice.payment_succeeded Restaura status → "active", actualiza period_end para el nuevo ciclo IMPORTANTE
invoice.payment_failed Status → "past_due". Si attempt_count ≥ 3: envío email dunning final IMPORTANTE
customer.subscription.trial_will_end Envía email recordatorio 3 días antes del fin de trial con CTA de pago INFO
Stripe CLI — Tests locales
Terminal
# Instalar y autenticar
$ brew install stripe/stripe-cli/stripe
$ stripe login

# Forward webhooks a Next.js local
$ stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Simular eventos
$ stripe trigger checkout.session.completed
$ stripe trigger invoice.payment_failed
$ stripe trigger customer.subscription.updated

# Tarjetas de prueba
# ✓ Éxito: 4242 4242 4242 4242
# ✓ 3DS: 4000 0025 0000 3155
# ✗ Decline: 4000 0000 0000 9995
Variables de entorno (.env.local)
Variable Descripción
STRIPE_SECRET_KEY API key privada (sk_live_…) REQ
STRIPE_WEBHOOK_SECRET Secreto de endpoint webhook (whsec_…) REQ
STRIPE_STARTER_MONTHLY_PRICE_ID Price Starter mensual 29€ REQ
STRIPE_STARTER_YEARLY_PRICE_ID Price Starter anual 290€ REQ
STRIPE_PRO_MONTHLY_PRICE_ID Price Pro mensual 79€ REQ
STRIPE_PRO_YEARLY_PRICE_ID Price Pro anual 790€ REQ
STRIPE_METERED_EVENTS_PRICE_ID Precio metered 0,005€/1k ev. REQ
NEXT_PUBLIC_APP_URL URL base (success/cancel redirect) REQ
Errores comunes a evitar
⚠ Orden de webhooks no garantizado
Stripe puede entregar eventos fuera de orden. Nunca confíes en los datos del evento directamente para actualizar la BD.
→ stripe.subscriptions.retrieve(sub.id) para re-fetch
⚠ Webhooks procesados dos veces
Stripe reintenta en 500. Sin tabla de idempotencia se ejecutan acciones duplicadas.
→ tabla StripeEvent { id, type, processedAt }
⚠ Abuso del trial
Un usuario puede crear múltiples cuentas para obtener trials repetidos.
→ hasHadTrial: true tras primer checkout
⚠ Sorpresa de prorateamiento
Upgrade inmediato genera factura sin previo aviso al usuario.
→ previewProration() antes de confirmar upgrade
⚠ Portal no configurado en Dashboard
El portal de cliente no funciona hasta activar features en Stripe Dashboard.
→ Billing → Customer portal → Enable features
⚠ Sin userId en metadata
Sin metadata.userId en checkout session no se puede vincular la suscripción al usuario en el webhook.
→ metadata: { userId: user.id } siempre