Caso de uso · Portal de cliente

Un portal self-service que jubila los cinco tickets más frecuentes en la primera semana

Un solo portal Next.js en `app.tudominio.com/account`, detrás de la misma auth del producto, con las cinco secciones que cualquier cliente de SaaS pide de verdad: cuenta, equipo, billing, usage, facturas. Stripe Customer Portal lleva las tarjetas. El usage lee del origen de verdad del metering. La gestión de equipo hace cambios de rol sin ticket. Las preguntas que se repiten dejan de llegar a la bandeja.

El problema

Las mismas cinco preguntas son el 60% del volumen de soporte

Coge un mes de tickets y etiquétalos. La forma es la misma en todos los SaaS: 'cambiar la tarjeta', 'actualizar el nombre de la empresa en la factura', 'añadir un compañero', 'por qué me habéis cobrado tanto', 'descargar las facturas del último trimestre'. Cinco preguntas, 50-60% de la bandeja, todas workflows que Stripe y el producto ya soportan pero a los que el cliente no llega sin un humano. El engineer de soporte se pasa el lunes con los reembolsos, el martes con los cambios de seats, el miércoles escribiendo el mismo email siete veces. Construimos el portal que se ocupa de esos cinco workflows. El cliente se sirve solo. La bandeja de soporte atiende los casos verdaderamente humanos. El equipo que había crecido para no quedarse atrás con los tickets recupera su tiempo.

Cómo lo abordamos

Seis pasos de \"cinco preguntas al día\" a un portal que los clientes usan

Audit de tickets antes del alcance. Alcance antes de auth. Auth antes de pantallas. Pantallas antes del cableado de Stripe. Cableado de Stripe antes de las vistas de usage. Vistas de usage antes de las notificaciones. Cada paso depende del anterior; saltarse uno produce un portal que parece completo pero no responde a ninguna de las preguntas.

  1. 01

    Auditar la bandeja de soporte

    Sacamos los últimos 90 días de tickets, los etiquetamos, y producimos una lista ordenada de los 15 motivos por los que los clientes contactan a soporte. La salida es un CSV que el founder lee de una sentada; los cinco primeros suelen cubrir el 50-60% del volumen, los quince primeros el 80%. Cada tipo de ticket recibe una columna para tiempo de resolución actual, coste humano actual y si una pantalla de portal puede absorberlo. El audit es el brief de todo lo que sigue.

  2. 02

    Definir el alcance del portal

    Del audit, elegimos los workflows que el portal va a llevarse en el primer cutover (normalmente: cuenta, equipo, billing, usage, facturas) y los workflows que se quedan explícitamente con soporte (normalmente: reembolsos pasados 30 días, contratos custom, ayuda con integraciones). La lista vive en un documento que el founder firma; no construimos pantallas para workflows que no están en la lista.

  3. 03

    Cablear el portal al auth del producto

    El portal es la misma app Next.js o una app hermana que comparte la sesión auth. Sin segundo login. El cliente hace clic en 'Cuenta' en el producto y aterriza dentro del portal. No construimos un flujo de auth paralelo porque cada flujo paralelo se trae sus propios bugs.

  4. 04

    Construir las cinco pantallas

    Cuenta (nombre, email, reset de contraseña, dos factores). Equipo (invitar, quitar, cambiar rol, con UI optimista). Billing (tarjeta vía Stripe Customer Portal, email de facturación, datos de empresa, NIF). Usage (la métrica por la que el cliente paga, con gráficos a 30 días y 12 meses, sacada del mismo metering que lee finance). Facturas (lista, descarga PDF, marcar como pagada para pagos offline). Cada pantalla sale detrás de un feature flag para que el rollout sea incremental.

  5. 05

    Integración de Stripe Customer Portal

    Embebemos Stripe Customer Portal para la gestión de tarjeta y la lista de facturas (Stripe los mantiene mejor de lo que lo haríamos nosotros). La sesión del portal se crea desde el backend del producto con una return URL que devuelve al cliente dentro del producto, no a una página de Stripe. El flujo parece nativo; el cliente no se da cuenta de que es Stripe el que renderiza el formulario.

  6. 06

    Notificación y audit

    Cada acción del portal que cambia algo que le importaría al equipo de soporte (cambio de rol, cambio de email de facturación, cambio de plan) dispara un evento al que el equipo se puede suscribir. Slack recibe la notificación, el audit log captura actor y timestamp, y el cliente recibe un email para que un cambio hecho por un compañero no sea una sorpresa. El portal deja de ser una caja negra.

Qué entregamos

Audit de la bandeja de soporte

Un CSV etiquetado de 90 días de tickets, ordenado por frecuencia, con una columna que marca cuáles puede absorber una pantalla de portal. El artefacto que el founder usa para decidir el alcance. Se vuelve a hacer cada trimestre para mantener el portal honesto.

Documento de alcance del portal

Un documento de una página que lista cada pantalla en alcance y cada workflow que se queda con soporte. Firmado antes de que arranque el código; clavado en el repo para que el scope creep futuro tenga que pelearse con una decisión existente.

Pantalla de cuenta

Nombre, email, reset de contraseña, alta de 2FA, lista de sesiones con revocación. Sustituye los cuatro tickets relacionados con cuenta más frecuentes. La sección de cuenta que cada usuario SaaS espera encontrar al hacer clic en su avatar.

Gestión de equipo

Invitar por email, quitar miembro, cambiar rol, transferir ownership. Las definiciones de roles viven en la misma config de permisos que usa el producto. El email de invitación enlaza a un flujo tipado que deja al nuevo miembro dentro del producto el primer día.

Sección de billing

Stripe Customer Portal embebido para tarjetas, email de facturación y razón social editables en el producto, NIF e identificación fiscal con validación específica por país, historial de facturas con PDFs descargables. La página que el cliente abre cuando finance le pide una factura.

Vista de usage

Un gráfico de la métrica por la que el cliente paga (llamadas API, seats, GB, ejecuciones) en los últimos 30 días y los últimos 12 meses. Sacada de la pipeline de metering en la que confía finance. Incluye un CSV descargable para el finance del cliente y una previsión del mes que viene basada en el run rate actual.

Página de API keys

Una página donde los clientes crean, nombran, recortan y revocan API keys sin un ticket. Las claves se muestran solo una vez en la creación; la rotación es un clic. Sustituye al ticket más común que les hace perder tiempo a los engineers en los SaaS técnicos.

Configuración de Stripe Customer Portal

Una configuración de Stripe Customer Portal afinada al producto (qué features aparecen, qué páginas son alcanzables, qué email de facturación es editable). Con la marca del producto. Devuelve al cliente a la página de billing dentro del producto, no a una página de Stripe.

Webhooks y handlers de eventos

Handlers para cada evento Stripe disparado por el portal (`customer.updated`, `payment_method.attached`, `customer_subscription.updated`) que mantienen la copia de estado del cliente en el producto al día. Idempotentes; replay-safe.

Sistema de notificaciones

Notificaciones de Slack, email e in-app para las acciones de portal que le importan al equipo (cambio de rol, cambio de billing, cambio de plan, API key creada). Suscribibles por canal; los defaults vienen afinados a lo que necesita un equipo de soporte en fase temprana.

Audit log del portal

Cada escritura del portal aterriza en una tabla `portal_audit` con actor, entidad, antes/después. Aparece en la misma vista de audit log que usa el staff para sus propias acciones; el equipo de soporte ve la línea de tiempo completa de actividad del cliente cuando abre un ticket.

Ayuda embebida y feedback

Un drawer contextual de ayuda con tres a cinco artículos cortos por sección y un botón de feedback que abre un formulario tipado. El drawer lee de un CMS; los artículos nuevos salen sin código. El feedback va a una board de Linear que el equipo de producto revisa cada semana.

Cinco archivos que componen un portal de cliente que jubila tickets de soporte

Los cinco archivos de abajo componen el portal: el layout que comparte auth y mantiene al cliente en una sola sesión, el creador de sesión de Stripe Customer Portal, el endpoint de gestión de equipo con role guards, el gráfico de usage que lee del metering, y la página de API keys con tokens recortados.

Un build de portal de cliente es un problema de deflection antes que un problema de UI. La bandeja de soporte ya nos dice qué workflows está dispuesto a hacer el cliente por su cuenta; el portal es el sitio donde se lo permitimos. Cada pantalla tiene un trabajo de una línea: saca este tipo de ticket de la bandeja. La métrica de éxito no es el diseño de la pantalla, es el porcentaje de tickets que dejan de llegar.

Los cinco archivos de abajo son la armadura que deja el encargo. El layout que comparte auth, el creador de sesión de Stripe Customer Portal, el endpoint de gestión de equipo con role guards, el gráfico de usage, y la página de API keys.

1. El layout que comparte auth

El portal vive en /account/* dentro de la app del producto. El layout lee la sesión existente; una visita sin autenticar redirige al sign-in del producto. No hay una superficie auth separada que mantener.

// app/account/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth/session'
import { AccountNav } from '@/components/account/AccountNav'

export default async function AccountLayout({ children }: { children: React.ReactNode }) {
  const session = await getServerSession()
  if (!session) {
    redirect('/auth/sign-in?next=/account')
  }

  return (
    <div className="ds-account-shell">
      <AccountNav user={session.user} />
      <main className="ds-account-content">{children}</main>
    </div>
  )
}

2. El creador de sesión de Stripe Customer Portal

El endpoint crea una sesión de portal y devuelve una URL que el cliente abre. La return URL deja al cliente dentro del producto. El configuration ID del portal está fijado por entorno, así un cambio en staging no se cuela en producción.

// app/api/billing/portal-session/route.ts
import Stripe from 'stripe'
import { getServerSession } from '@/lib/auth/session'
import { getCustomer } from '@/lib/billing/customer'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(): Promise<Response> {
  const session = await getServerSession()
  if (!session) {
    return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
  }

  const customer = await getCustomer(session.user.id)
  if (!customer?.stripeCustomerId) {
    return Response.json({ success: false, error: 'no_customer' }, { status: 400 })
  }

  const portal = await stripe.billingPortal.sessions.create({
    customer: customer.stripeCustomerId,
    return_url: `${process.env.PUBLIC_APP_URL}/account/billing`,
    configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
  })

  return Response.json({ success: true, data: { url: portal.url } })
}

3. El endpoint de gestión de equipo con role guards

Las escrituras de equipo pasan por un único endpoint que lee el rol del actor, valida la acción contra una matriz de permisos y audita el cambio. La matriz vive en un archivo de config que el equipo puede leer; el endpoint es pequeño.

// app/api/team/members/route.ts
import { getServerSession } from '@/lib/auth/session'
import { adminDb } from '@/lib/admin/db'
import { canPerform } from '@/lib/permissions/can'

interface ChangeRolePayload {
  memberId: string
  newRole: 'admin' | 'member' | 'viewer'
}

export async function PATCH(req: Request): Promise<Response> {
  const session = await getServerSession()
  if (!session) {
    return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
  }

  const body = (await req.json()) as ChangeRolePayload
  const actor = await loadMember(session.user.id)
  if (!canPerform(actor, 'team.change_role', { targetMemberId: body.memberId })) {
    return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
  }

  const db = await adminDb()
  const before = await db.from('team_members').select('*').eq('id', body.memberId).single()
  await db.from('team_members').update({ role: body.newRole }).eq('id', body.memberId)
  const after = await db.from('team_members').select('*').eq('id', body.memberId).single()

  await db.from('portal_audit').insert({
    actor_user_id: session.user.id,
    entity_type: 'team_member',
    entity_id: body.memberId,
    action: 'change_role',
    before: before.data,
    after: after.data,
  })

  await notifyTeam({ event: 'role_changed', memberId: body.memberId, newRole: body.newRole })
  return Response.json({ success: true, data: after.data })
}

async function loadMember(userId: string) { /* ... */ return { id: userId, role: 'admin' as const } }
async function notifyTeam(_input: { event: string; memberId: string; newRole: string }) { /* ... */ }

4. El gráfico de usage

El componente del gráfico lee del mismo origen de metering que lee finance. El número del cliente es el número de finance; el portal no tiene una copia propia que pueda derivar. Dos rangos (últimos 30 días, últimos 12 meses) y un export CSV.

// app/account/usage/UsageChart.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from '@/components/charts/LineChart'

interface UsageRange {
  date: string
  count: number
}

interface UsageResponse {
  success: boolean
  data: { last30: UsageRange[]; last12Months: UsageRange[]; forecastNextMonth: number }
}

export function UsageChart() {
  const { data, isLoading } = useQuery<UsageResponse>({
    queryKey: ['account', 'usage'],
    queryFn: async () => (await fetch('/api/account/usage')).json() as Promise<UsageResponse>,
    staleTime: 5 * 60_000,
  })

  if (isLoading || !data?.success) return <p>Cargando el usage…</p>

  return (
    <section className="ds-stack ds-stack--md">
      <h2 className="ds-heading-ui">Últimos 30 días</h2>
      <LineChart series={data.data.last30} xKey="date" yKey="count" />

      <h2 className="ds-heading-ui">Últimos 12 meses</h2>
      <LineChart series={data.data.last12Months} xKey="date" yKey="count" />

      <p className="ds-text-sm ds-text-secondary">
        Previsión del mes que viene: {data.data.forecastNextMonth.toLocaleString('es-ES')} unidades.
      </p>
      <a href="/api/account/usage/export.csv" className="ds-btn ds-btn--ghost">Descargar CSV</a>
    </section>
  )
}

5. La página de API keys

El cliente crea, nombra, recorta y revoca claves sin que engineering intervenga. Las claves se muestran solo una vez en la creación; guardamos un hash. La rotación es un clic, que crea una clave nueva y le mete a la vieja un periodo de gracia antes de expirar.

// app/account/api-keys/page.tsx
import { adminDb } from '@/lib/admin/db'
import { getServerSession } from '@/lib/auth/session'
import { CreateKeyForm } from './CreateKeyForm'
import { KeyList } from './KeyList'

export default async function ApiKeysPage() {
  const session = await getServerSession()
  if (!session) return null

  const db = await adminDb()
  const { data: keys } = await db
    .from('api_keys')
    .select('id, name, scope, last_used_at, created_at, revoked_at')
    .eq('owner_user_id', session.user.id)
    .order('created_at', { ascending: false })

  return (
    <section className="ds-stack ds-stack--lg">
      <header className="ds-stack ds-stack--sm">
        <h1 className="ds-editorial-title">API keys</h1>
        <p className="ds-editorial-lede">Crea claves recortadas para las integraciones que usan esta cuenta.</p>
      </header>
      <CreateKeyForm />
      <KeyList keys={keys ?? []} />
    </section>
  )
}

6. Lo que esto compone

El layout compartido mantiene al cliente en una sola sesión. El creador de sesión de portal le pasa el billing a Stripe sin sacar al cliente del producto. El endpoint de equipo mueve los cambios de rol de la bandeja de soporte a un botón. El gráfico de usage le enseña al cliente el mismo número que ve finance. La página de API keys cierra el ticket que más tiempo le hacía perder al engineer.

La bandeja de soporte vuelve a respirar. La cola del lunes por la mañana son los casos verdaderamente humanos en los que el equipo quería trabajar. Las preguntas que se repiten dejan de llegar porque la respuesta está a un clic. El equipo que creció para gestionar el volumen de tickets recupera el tiempo para entregar el producto del que ahora el portal forma parte.

Stacks relacionados

Preguntas frecuentes

¿Se puede hacer sin integrar Stripe Customer Portal?

Sí, y a veces lo hacemos. El Customer Portal es el camino más rápido a una superficie de billing que Stripe mantiene al día gratis; para la mayoría de SaaS en fase temprana es la respuesta correcta. Los equipos con lógica de billing custom (usage-based con compromisos anuales, contratos negociados por cliente) suelen construir las pantallas de billing directamente contra la API de Stripe. Hacemos el camino pesado cuando el audit lo justifica.

¿Cómo evitáis que el portal se convierta en otra superficie de producto que pida tiempo de engineering para siempre?

Dos patrones. Primero, el portal usa el mismo design system del producto; las pantallas nuevas caen en los componentes existentes en vez de inventar layout. Segundo, el documento de alcance es el spec; no añadimos pantallas sin un alcance actualizado, y el audit trimestral de soporte conduce lo que entra y lo que sale. La mayoría de los portales se estabilizan después de los primeros seis meses.

¿Y los clientes con contrato custom que no tienen una subscription de Stripe?

Reciben una sección de billing solo lectura con los términos del contrato, la fecha de renovación y el historial de facturas. Los botones de cambio que normalmente permiten cambiar de plan están escondidos. Modelamos este caso explícitamente porque cada SaaS enterprise lo tiene; el portal gestiona los dos mundos sin confundir al cliente.

¿Cómo dialoga el portal con nuestra página de settings existente en la app?

A menudo la sustituye; a veces convive con ella. La llamada de scoping decide. El patrón que vemos más a menudo: la página de settings legacy pasa a ser un redirect al portal cuando salen los flujos nuevos; la URL `app.x/settings` sigue funcionando para que los enlaces de emails existentes no acaben en 404.

¿Cuánto tarda?

De seis a ocho semanas desde el kickoff. El audit de la bandeja lleva una semana. Alcance y auth llevan una semana. Las cinco pantallas llevan de tres a cuatro semanas en total. El embedding de Stripe Customer Portal lleva media semana. Notificaciones, audit log y ayuda embebida llevan la última semana. Colchón para una ronda de feedback del cliente antes del cutover.

¿Los clientes usan el portal de verdad o siguen escribiendo a soporte?

Los cinco workflows que el portal lleva bajan entre un 60 y un 80% en la bandeja de soporte en el primer mes desde el cutover. El 20-40% que queda suele ser de clientes que escribieron antes de descubrir que el portal existe; un empujón en el producto y un email puntual cierran la mayor parte de ese hueco. Medimos el ratio de deflection cada trimestre y lo sacamos al dashboard.

¿Y los clientes no anglófonos?

El portal soporta los mismos idiomas que el producto. Si el producto está solo en inglés, construimos el portal solo en inglés y marcamos los idiomas que el audit sugiere añadir después. No construimos un portal en tres idiomas cuando el producto está en uno; el trabajo es desperdicio hasta que el producto se ponga al día.

¿Reemplazáis a nuestro engineer de soporte?

No. El portal hace que el trabajo del engineer de soporte escale. En vez de siete copias del mismo email por semana, el engineer atiende los tickets verdaderamente complejos que el portal no cubre. Los engineers de soporte suelen sentirse liberados: el trabajo rutinario se comprime; lo que queda es el trabajo que el engineer quería hacer desde el principio.

Define el alcance de tu portal de cliente

Una llamada de scoping, un audit de la bandeja de soporte en la primera semana, un alcance fijo y un número que mantenemos. De seis a ocho semanas desde el kickoff a un portal que los clientes usan y a una bandeja de soporte que vuelve a respirar.