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.
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.
