La parte difícil de una migración desde Bubble no es el schema de la base de datos (las tablas de Bubble mapean limpio a Postgres) ni la UI (las pantallas nuevas suelen estar mejor rediseñadas que copiadas). La parte difícil es la business logic que vive dentro del editor visual de workflows de Bubble. Cada workflow es un árbol de condiciones, escrituras de base de datos, llamadas a API y efectos colaterales que el equipo lleva meses editando. La traducción tiene que preservar cada comportamiento mientras convierte el árbol en código que otro ingeniero pueda leer.
El patrón de abajo es el que enviamos: un workflow de Bubble ("When a user clicks Upgrade to Pro") reescrito como una sola Server Action tipada. El schema Zod valida el input, la transacción envuelve las escrituras, el camino de error es explícito, el tipo de retorno es el que el caller pone en el switch. El editor visual que escondía la lógica se sustituye por código que vive en source control.
1. El workflow Bubble original
En Bubble, el flujo de upgrade se ve como una columna vertical de pasos en el editor de workflows. Leerlo requiere abrir cada paso, leer sus condiciones, mirar sus acciones de base de datos y trazar qué campos escribe cada paso. Abajo está la misma lógica escrita en TypeScript.
2. La Server Action traducida
// app/[lang]/(app)/account/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'
import { stripe } from '@/lib/stripe/server'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
targetPlan: z.enum(['pro', 'team']),
})
export type UpgradeResult =
| { tag: 'success'; subscriptionId: string }
| { tag: 'already_on_target'; currentPlan: string }
| { tag: 'no_payment_method'; portalUrl: string }
| { tag: 'error'; message: string }
export async function upgradePlan(raw: unknown): Promise<UpgradeResult> {
const session = await getServerSession()
if (!session) return { tag: 'error', message: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { tag: 'error', message: 'invalid input' }
const supabase = await createServerClient()
// Paso 1: lee la subscription actual. En Bubble es "Get current user's
// subscription"; en TypeScript es una sola query tipada.
const { data: subscription, error: readError } = await supabase
.from('subscriptions')
.select('id, plan, stripe_subscription_id, status')
.eq('tenant_id', session.tenantId)
.single()
if (readError) return { tag: 'error', message: 'subscription not found' }
// Paso 2: guarda contra el caso no-op. En Bubble era una condición
// "Only when" en el trigger del workflow; aquí es una precondición antes
// de cualquier side effect.
if (subscription.plan === parsed.data.targetPlan) {
return { tag: 'already_on_target', currentPlan: subscription.plan }
}
// Paso 3: verifica el método de pago. En Bubble era una llamada de plugin
// aparte seguida de una rama condicional; aquí es un solo lookup en Stripe.
const customer = await stripe.customers.retrieve(
subscription.stripe_subscription_id,
)
if ('deleted' in customer || !customer.invoice_settings?.default_payment_method) {
const portal = await stripe.billingPortal.sessions.create({
customer: subscription.stripe_subscription_id,
return_url: `${process.env.SITE_URL}/account`,
})
return { tag: 'no_payment_method', portalUrl: portal.url }
}
// Paso 4: actualiza la subscription Stripe. La prorrata y la generación
// de la factura son responsabilidad de Stripe, no nuestra.
const updated = await stripe.subscriptions.update(
subscription.stripe_subscription_id,
{
items: [{ price: PRICE_LOOKUP[parsed.data.targetPlan] }],
proration_behavior: 'create_prorations',
metadata: { tenant_id: session.tenantId, plan: parsed.data.targetPlan },
},
)
// Paso 5: actualiza la fila local de subscription. El webhook también
// dispara y confirma la misma escritura; la fila es idempotente.
const { error: writeError } = await supabase
.from('subscriptions')
.update({
plan: parsed.data.targetPlan,
status: updated.status,
updated_at: new Date().toISOString(),
})
.eq('id', subscription.id)
if (writeError) return { tag: 'error', message: 'database write failed' }
revalidatePath('/account')
return { tag: 'success', subscriptionId: updated.id }
}
const PRICE_LOOKUP: Record<'pro' | 'team', string> = {
pro: process.env.STRIPE_PRICE_PRO!,
team: process.env.STRIPE_PRICE_TEAM!,
}
3. Qué cambia para el equipo
Revisar esta Server Action es una code review de un solo archivo. Leer el workflow Bubble original obligaba a navegar por cinco pestañas del editor visual. Atrapar un bug en la versión nueva es un error de compilación en TypeScript o un test que falta; atrapar un bug en la versión Bubble era esperar a que alguien se diera cuenta antes de que lo hiciera un cliente.
La migración no es una reescritura por sí misma, es una traducción desde un runtime que esconde la lógica a un runtime donde la lógica es el código fuente. El comportamiento del producto se mantiene; lo que se vuelve posible es el audit trail, la type safety, y todas las prácticas de ingeniería con las que el equipo puede por fin contar de verdad.
4. Qué no nota el usuario
Bien hecha, la migración es invisible para la gente que usa el producto. Las pantallas cargan más rápido, el email de reset de contraseña llega una sola vez el día del cutover, y el siguiente sign-in es idéntico al anterior. El trabajo de ingeniería es enorme; el evento visible al usuario es "la app hoy se siente más ágil". Y esa es la vara que nos ponemos.