Saltar al contenido

Un checkout con Stripe que no se fía del navegador

El cliente confirma el pago y el cliente nunca debe crear el pedido. Dónde va cada pieza de un checkout en una aplicación con App Router, y cuáles de ellas se pueden manipular.

6 min de lectura

Un checkout tiene tres participantes: el navegador del cliente, su aplicación y el proveedor de pagos. Hacerlo bien es casi por completo una cuestión de cuál de ellos puede afirmar qué, y el navegador puede afirmar muy poco.

En una aplicación con App Router esa división aterriza en ficheros concretos, y conviene escribirla antes de que exista ninguno de ellos.

Qué decide el servidor

El navegador del cliente envía identificadores de producto y cantidades. Ese es todo el alcance de lo que puede aportar.

El precio, el impuesto, el envío, el descuento y el importe final se calculan en el servidor a partir de sus propios datos, cada vez, incluida la petición que crea el pago. No leídos de un campo oculto, no tomados del total del carrito en el cliente, no dados por buenos porque la petición anterior calculó bien.

// app/checkout/actions.ts
'use server'
 
export async function startCheckout(items: CartItem[]) {
  const session = await auth()
  const priced = await priceBasket(items)        // precios y reglas del servidor
  const order = await db.order.create({
    data: { userId: session.user.id, status: 'pending', total: priced.total },
  })
 
  const intent = await stripe.paymentIntents.create({
    amount: priced.total,                        // unidad menor, desde el servidor
    currency: priced.currency,
    metadata: { orderId: order.id },
    automatic_payment_methods: { enabled: true },
  })
 
  return { clientSecret: intent.client_secret, orderId: order.id }
}

Tres cosas ahí son deliberadas. El pedido existe antes que el pago, en estado pendiente, para que siempre haya algo a lo que enganchar el resultado. Su identificador viaja en los metadatos del pago, que es lo que permite a un webhook que llega cuarenta minutos después encontrarlo sin tabla de búsqueda. Y lo único que vuelve al navegador es un client secret, que autoriza confirmar ese pago concreto y nada más.

Qué hace el cliente, y qué no

El navegador recoge la tarjeta y confirma el pago directamente con Stripe. La tarjeta no toca nunca su servidor, y eso es lo que le mantiene fuera del régimen de cumplimiento en el que le metería manejar datos de tarjeta.

Esa confirmación es un componente de cliente - uno de los pocos sitios de una tienda donde un componente de cliente es realmente necesario y no solo cómodo, y merece la excepción a mantener la frontera ajustada. Necesita el JavaScript de Stripe, necesita montar un iframe y necesita reaccionar al banco del cliente.

Lo que no hace es decirle a su servidor que el pago funcionó. La llamada de confirmación se resuelve en el navegador, y una promesa resuelta en un navegador no es un hecho sobre dinero. Es una buena razón para enseñar una pantalla de éxito; no es una razón para preparar un pedido.

El resultado llega por otro sitio

Un Route Handler recibe el webhook, verifica la firma contra el cuerpo en bruto y entrega el evento a algo duradero.

// app/api/stripe/webhook/route.ts
export async function POST(request: Request) {
  const raw = await request.text()                       // bytes en bruto, no json()
  let event
  try {
    event = stripe.webhooks.constructEvent(
      raw, request.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!,
    )
  } catch {
    return new Response(null, { status: 400 })
  }
 
  await recordAndEnqueue(event.id, raw)                  // fuera de este proceso
  return new Response(null, { status: 200 })
}

request.json() rompería la firma, porque el hash va sobre los bytes que se enviaron y volver a codificar un objeto no los reproduce. Y el manejador se queda corto a propósito: la preparación del pedido, el correo, el descuento de stock y la contabilidad ocurren después de la respuesta, sobre algo que pueda reintentar.

No en after, que ejecuta la función una vez enviada la respuesta pero sigue dentro de la misma invocación y la misma duración máxima de la ruta: no se puede reintentar y no sobrevive a que desaparezca la instancia. Es la misma frontera con la que choca cualquier integración externa, y los pagos son el caso en el que cruzarla cuesta dinero de verdad.

La página de en medio

Entre la confirmación y el webhook hay un hueco, normalmente segundos y a veces minutos, y el cliente está sentado en una página durante ese rato.

Renderice desde su propia fila de pedido, no desde nada que el cliente haya informado. El pedido está pendiente, así que la página dice que el pago se está confirmando y ofrece la referencia. Cuando el webhook llega y la fila pasa a pagado, la página muestra el recibo.

El error barato es renderizar éxito de forma optimista porque la promesa del cliente se resolvió. Parece correcto en desarrollo, donde el webhook llega en unos cientos de milisegundos, y produce una pantalla de pedido confirmado para un pago que fue rechazado en el último paso. Muestre lo que sabe su base de datos; es el único participante aquí que usted controla.

Ese estado intermedio es una pantalla real con texto real, y cómo construirla importa más en un pago que en ningún otro sitio, porque es el momento en que un cliente decide si lo intenta otra vez y paga dos veces.

Dónde deja esto la arquitectura

La aplicación Next.js posee la pantalla de checkout, el cálculo de precios, el pedido pendiente y el receptor de webhooks. No posee los reintentos, la conciliación, ni nada que tenga que pasar según un horario. Eso es de un worker o de un servicio de backend, por la razón de siempre: una petición termina, y la historia de un pago no.

Nuestro trabajo de comercio empieza en esta frontera y no en el diseño, porque un checkout bonito que se fía del navegador es un checkout que será explotado al mes de salir.

Preguntas relacionadas

¿Puede venir el importe desde el cliente?
Puede enviarse y no debe creerse. Todo lo que llega a una Server Action o a un Route Handler vino de un navegador que controla el cliente, así que el precio hay que recalcularlo en el servidor a partir de los identificadores de producto y las cantidades. Es el error más antiguo del comercio en línea y sigue saliendo a producción, porque en desarrollo el valor de la petición siempre es el que usted puso.
¿Necesitamos Stripe Elements o basta con la página alojada?
La página alojada es menos trabajo, le mantiene más lejos de los datos de tarjeta y resuelve muchos casos límite que de otro modo conocería uno a uno. Elements compensa cuando el checkout forma parte de la experiencia de producto y no es un paso al final. Ambas mantienen la tarjeta fuera de su servidor, que es la propiedad que de verdad importa.
¿Dónde se crea el pedido?
En el servidor, antes del pago, en estado pendiente, con un identificador propio que viaja en los metadatos del pago. Crearlo cuando el cliente informa de éxito significa que no existe pedido para nadie cuyo navegador se cerró. Crearlo solo desde el webhook significa que no tiene nada que mostrar al cliente en la página que está mirando ahora mismo.
¿Es una Server Action el sitio adecuado para esto?
Para crear el intent sí: se ejecuta en el servidor, puede leer la sesión y devuelve un client secret que es seguro entregar al navegador. Para cualquier cosa que deba ocurrir después de la respuesta, no. Una Server Action es una petición; termina cuando sale la respuesta, y un pago no.

Volver a todos los artículos