La parte difficile di una migrazione Bubble non è lo schema del database (le tabelle Bubble si mappano pulite su Postgres) o la UI (le schermate nuove di solito stanno meglio riprogettate che copiate). La parte difficile è la business logic che vive dentro l'editor di workflow visuale di Bubble. Ogni workflow è un albero di condizioni, scritture sul database, chiamate API ed effetti collaterali che il team sta modificando da mesi. La traduzione deve preservare ogni comportamento mentre trasforma l'albero in codice che un altro ingegnere può leggere.
Il pattern qui sotto è quello che rilasciamo: un workflow Bubble ("Quando un utente clicca Upgrade a Pro") riscritto come una singola Server Action tipizzata. Lo schema Zod valida l'input, la transazione racchiude le scritture, la strada di errore è esplicita, il tipo di ritorno è quello su cui chi chiama fa switch. L'editor visuale che nascondeva la logica viene sostituito da codice che vive in source control.
1. Il workflow Bubble originale
In Bubble, il flusso di upgrade assomiglia a una colonna verticale di passi nell'editor di workflow. Leggerlo richiede di aprire ogni passo, leggere le sue condizioni, guardare le sue azioni di database e tracciare quali campi ogni passo scrive. Qui sotto la stessa logica scritta in TypeScript.
2. La Server Action tradotta
// 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()
// Step 1: legge la subscription corrente. In Bubble è "Get current user's
// subscription"; in TypeScript è una singola query tipizzata.
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' }
// Step 2: guard sul caso no-op. In Bubble era una condizione "Only when"
// sul trigger del workflow; qui è una precondizione prima di qualunque
// side effect.
if (subscription.plan === parsed.data.targetPlan) {
return { tag: 'already_on_target', currentPlan: subscription.plan }
}
// Step 3: verifica il metodo di pagamento. In Bubble era una chiamata
// separata di plugin seguita da un ramo condizionale; qui è una singola
// lookup 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 }
}
// Step 4: aggiorna la subscription Stripe. La proration e la generazione
// della fattura sono responsabilità di Stripe, non nostra.
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 },
},
)
// Step 5: aggiorna la riga subscription locale. Il webhook scatta anche
// lui e conferma la stessa scrittura; la riga è 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. Cosa cambia per il team
Fare la review di questa Server Action significa una code review di un file solo. Leggere il workflow Bubble originale richiedeva di cliccare attraverso cinque tab dell'editor visuale. Trovare un bug nella versione nuova è un errore di compilazione TypeScript o un test mancante; trovare un bug nella versione Bubble era sperare che qualcuno se ne accorgesse prima di un cliente.
La migrazione non è una riscrittura fine a sé stessa, è una traduzione da un runtime che nasconde la logica a un runtime in cui la logica è il codice sorgente. Il comportamento del prodotto resta identico; quello che diventa possibile è l'audit trail, la type safety, e tutto quel lavoro di ingegneria su cui il team può finalmente fare affidamento.
4. Cosa l'utente non nota
Fatta bene, la migrazione è invisibile per chi usa il prodotto. Le schermate caricano più veloci, l'email di reset password arriva una sola volta nel giorno del cutover, e il sign-in successivo è identico all'ultimo. Il lavoro di ingegneria è enorme; l'evento visibile all'utente è "l'app oggi sembra più reattiva". E quella è l'asticella che ci diamo.