De un mockup de Lovable o Bolt a un backend real: el checklist antes de cobrar
La distancia entre un mockup de Lovable o Bolt y un backend real: billing idempotente, sesiones revocables, tenants aislados y secretos en el servidor.
Antes de cobrarle con tarjeta a un usuario real desde un mockup de Lovable o Bolt, cuatro cosas tienen que ser verdad en el backend: el billing es idempotente, las sesiones son revocables, los tenants están aislados por construcción y los secretos viven en el servidor y se validan al arrancar.
El mockup es la parte fácil. Lovable o Bolt te lleva en una tarde a una pantalla que parece un producto: el layout, los datos de mentira, el botón de pago que muestra un toast de éxito. Después querés cobrar con tarjetas reales y se abre la brecha entre prototipo y producción. La demo que armaste es, más o menos, el 20% del código que necesita un backend real; el otro 80% no se ve en una captura de pantalla y es justo lo que el generador se salteó: el webhook que se dispara cuando se vence una suscripción, el reintento cuando falla un pago, la sesión que tiene que morir cuando se resetea una contraseña, la query que no puede devolver filas de otro cliente.
Ese precipicio del 80/20 es la queja que más se repite entre quienes cruzan este puente. La IA te lleva rápido a una app que parece andar, y el último tramo (auth, seguridad, casos borde, billing real, deploy) es donde se va el tiempo. No es un problema de habilidad. Es que la demo y el sistema casi no comparten código.
Este post es el checklist: qué tiene que ser verdad en el backend antes de ponerle una pantalla de cobro delante a un desconocido. Está escrito a partir del código real del boilerplate, no es un pitch. Y es un destino de graduación, no un competidor de Lovable: si tu app nunca necesita lógica de backend propia, quedate en no-code. Esto es para el momento en que decidiste que sí la necesita.
"Cobrar con tarjeta" es un prompt. El billing es un sistema.
Cobrar con tarjeta en el camino feliz es un solo prompt, y en la demo funciona. Un sistema de billing de producción es otra cosa: el webhook que llega cuando se renueva una suscripción, la lógica de reintento cuando una tarjeta es rechazada, el portal de cliente, el estado de la base de datos que tiene que reconciliarse después de que llega el webhook, y el email de confirmación. El encuadre que sobrevive al contacto con producción: el camino feliz es un prompt, el sistema no.
Hay un bug puntual que cobra doble sin que nadie se entere: handlers de webhooks que no son idempotentes.
Como lo dice un artículo sobre Claude Code y boilerplates, "If Stripe sends the same message twice (which it
does), your app charges or credits the user twice" (buildthisnow.com).
Todos los proveedores reenvían eventos. Si tu handler hace un INSERT en cada entrega, un invoice.paid
reenviado le cobra otra vez al cliente.
El camino de webhooks del boilerplate es verificar-y-después-traducir. El adapter del proveedor valida la firma y
normaliza el body crudo en un BillingEvent; el caso de uso lo republica como evento de dominio y responde ok:
nunca toca la base de datos directamente:
async execute(req: WebhookRequest): Promise<Result<HandleWebhookOutput, DomainError>> {
const parsed = await this.provider.parseWebhook(req); // verifies signature + parses
if (!parsed.ok) return parsed;
if (!parsed.value) return ok({ accepted: true }); // valid, but a type we don't model yet
const event = parsed.value;
await this.events.publish([
{ name: `billing.${event.type}`, aggregateId, occurredAt: new Date(), payload: event },
]);
return ok({ accepted: true, eventType: event.type });
}La idempotencia vive una capa más abajo, en los listeners que reducen esos eventos al modelo de lectura. El reenvío es esperable, así que las escrituras son upserts con la clave del propio proveedor, no inserts:
bus.subscribe('billing.subscription.created', async (event) => {
const p = event.payload as Record<string, unknown>;
await subs.upsert({
customerInternalId: asString(p, 'customerInternalId'),
provider: asString(p, 'provider'),
externalId: asString(p, 'subscriptionId'), // the idempotency key
priceId: asString(p, 'priceId'),
status: 'active',
currentPeriodEnd: asDate(p, 'currentPeriodEnd'),
metadata: (p.metadata as Record<string, unknown> | undefined) ?? null,
});
});Las compras únicas siguen la misma regla. RecordPurchaseUseCase está documentado como "Idempotent on
externalOrderId — webhook deliveries are at-least-once across every provider", y el repositorio lo respalda
con un upsert atómico:
const row = await this.prisma.purchase.upsert({
where: { externalOrderId: input.externalOrderId }, // unique per provider invoice
update: { /* ...refresh the row */ },
create: { externalOrderId: input.externalOrderId, /* ...persist */ },
});El efecto neto: el mismo webhook entregado cinco veces produce una fila de suscripción y una fila de compra. Esa
es la diferencia entre un checkout de demo y un sistema de billing, y tiene la misma forma ya sea que la plata
haya entrado por MercadoPago o por Polar (Stripe es un scaffold), porque todos los proveedores se normalizan en un
único BillingEvent.
Una sesión tiene que poder revocarse
El defecto más citado del "auth vibe-codeado" parece chico pero no lo es: sesiones que no se cierran al cambiar la contraseña. Reseteás la contraseña después de una filtración y el token de login viejo del atacante sigue andando. Desde un repo en blanco, un agente promedia años de código público, y "the average includes the bugs" (buildthisnow.com). La invalidación de sesiones es una de las tres que ese artículo nombra.
Revocar una sesión es trivial si la primitiva existe. En el boilerplate es un port de primera clase con una implementación obvia: borrar las filas de sesión de BetterAuth de un usuario:
async revokeAllForUser(userId: string): Promise<number> {
const result = await this.prisma.session.deleteMany({ where: { userId } });
return result.count;
}Lo que lo hace de calidad de producción no es el delete, sino que está conectado a las operaciones de trust and safety y auditado. Force-logout, force-password-reset, suspend y soft-delete lo llaman, y cada llamada emite una entrada de auditoría:
const revoked = await this.sessions.revokeAllForUser(input.targetUserId);
await this.audit.execute({
action: 'platform.user.force_logout',
actorUserId: input.actorUserId,
resourceType: 'user',
resourceId: input.targetUserId,
payload: { revoked },
});Una aclaración sobre la política: los dos flujos de credenciales cierran todas las demás sesiones por defecto.
El cambio de contraseña pasa revokeOtherSessions: true y el reseteo configura revokeSessionsOnPasswordReset: true
(better-auth.adapter.ts:400,117). Relajar cualquiera de los dos es un flag, no una feature que tengas que
construir. La gracia de una base es que la perilla existe y la política la decidís vos, no descubrir después de un incidente que
la perilla faltaba.
Un cliente no puede leer las filas de otro
Si tu app tiene organizaciones o workspaces, la falla aburrida y catastrófica es un findMany que se olvidó su
where: { organizationId }. Compila, pasa el review, sale a producción, y de repente la org A ve los datos de la
org B. Esto no es hipotético en el mundo vibe coding: el escaneo de Matt Palmer y su equipo sobre 1.645
proyectos de Lovable, hecho el 21 de marzo de 2025, encontró 170 con configuraciones de row-level security
inadecuadas, con 303 endpoints de Supabase identificados (statement), y el patrón quedó catalogado
como CVE-2025-48757. El builder no técnico no sabe que existe
RLS para activar.
El boilerplate cierra esa categoría con un guard de Prisma que tira un error cuando una lectura con alcance de tenant se olvidó el filtro, en vez de devolver en silencio las filas de todos los tenants. Tiene su propio deep-dive (la mecánica, la allowlist y lo que deliberadamente no cubre están en Multi-tenancy en Prisma sin RLS, en inglés), pero la versión de una línea es: un filtro de tenant olvidado se convierte en un stack trace en tu corrida de tests, no en una filtración de datos en un ticket de soporte.
Los secretos viven en el servidor, validados al arrancar
La historia de terror canónica del vibe coding arranca cuando un atacante consigue una secret key de Stripe. Una versión, de un hilo de Hacker News sobre un SaaS vibe-codeado: "the hacker got a hold of his Stripe key and issued every customer a refund" (Hacker News). Un informe de vibe-eval.com, que recoge una auditoría manual de 50 apps (Cursor, Lovable, Bolt, v0) publicada en Reddit, dice que el 90% comparte las mismas cinco vulnerabilidades, entre ellas API keys hardcodeadas (vibe-eval.com).
La solución estructural no es la vigilancia, es la arquitectura: los secretos son variables de entorno solo del servidor, validadas al arrancar por un schema de Zod, y el servidor nunca se las entrega al cliente. Si falta una requerida, el proceso se niega a arrancar en vez de levantarse a medio configurar:
BETTER_AUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
MERCADO_PAGO_WEBHOOK_SECRET: z.string().optional(),Los secretos de webhook son los que usan los adapters de proveedores para verificar las firmas entrantes, la primera línea del flujo de billing de arriba. Se leen en el servidor, nunca se serializan en una página, y no hay ningún camino de código que los mande al navegador porque el frontend nunca los ve.
El checklist, antes de cobrar
Nada de esto es exótico. Es el conjunto específico de cosas que son invisibles en un mockup y no negociables en un producto:
- El billing es idempotente. Los webhooks se verifican, y el reenvío del mismo evento hace upsert de una fila en vez de cobrar dos veces.
- Las sesiones son revocables y auditadas. Una cuenta comprometida se puede desloguear a la fuerza; un reseteo de contraseña puede invalidar tokens viejos; cada acción así deja rastro.
- Los tenants están aislados por construcción. Un filtro
organizationIdolvidado falla fuerte, no filtra. - Los secretos están del lado del servidor y validados al arrancar. Ninguna key llega al bundle del navegador; un secreto requerido que falta frena el arranque.
La objeción que vale la pena contestar de frente: los escépticos dicen que la mayoría de los starter kits terminan en una reescritura desde la base. Es una crítica justa a un kit espagueti. Y es la razón por la que esta base se apoya en límites forzados por el compilador (capas DDD forzadas con lint, contratos OpenAPI tipados, tests): las partes que normalmente tirarías son las que la arquitectura te impide enredar desde el principio. Está pensada para ser la base que escalás, no el andamio que descartás.
Si estás justo en este punto (el mockup anda y sentís que el último 20% se viene antes de poder cobrar), la guía de mockup a producción cuenta el cruce completo, y por qué existe este boilerplate explica la apuesta de fondo. Cuando quieras correr el código del que salieron estos snippets, la documentación de primeros pasos te lleva del clone a un backend que arranca y está protegido. Si tu app salió de Lovable en particular, el plan para apps de Lovable recorre la migración. Las partes aburridas y peligrosas ya están construidas y bloqueadas, que es justo el punto de no tener que enviarlas vos.
Si todavía estás en la etapa de decidir qué es esto del vibe coding y dónde conviene usarlo, empezá por qué es el vibe coding.