La maggior parte degli articoli su "integrazione Stripe" si ferma al pulsante Checkout. Il billing in produzione è l'entry point qui sotto: una config PLANS tipizzata che guida ogni interfaccia, una Server Action di Checkout che sa di valute e trial, un helper di Customer Portal che torna al posto giusto e un handler webhook che scrive la riga subscription risultante quando Checkout completa.
1. La config di pricing
I piani vivono nel codice. Lo stesso file è la fonte di verità per Checkout, Customer Portal, dashboard admin e pagina pricing del marketing. Aggiornare un prezzo è una modifica tipizzata, non un click sulla dashboard di Stripe.
// src/lib/billing/plans.ts
import { z } from 'zod'
export const PLAN_IDS = ['starter', 'growth', 'enterprise'] as const
export type PlanId = (typeof PLAN_IDS)[number]
export const CURRENCY_IDS = ['eur', 'usd', 'gbp'] as const
export type CurrencyId = (typeof CURRENCY_IDS)[number]
export interface PlanPrice {
stripePriceId: string
monthlyCents: number
yearlyDiscount: number
}
interface PlanDefinition {
trialDays: number
features: string[]
prices: Record<CurrencyId, PlanPrice>
}
export const PLANS: Record<PlanId, PlanDefinition> = {
starter: {
trialDays: 14,
features: ['5 seats', 'core integrations', 'email support'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_EUR!,
monthlyCents: 2900,
yearlyDiscount: 0.15,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_USD!,
monthlyCents: 3200,
yearlyDiscount: 0.15,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_GBP!,
monthlyCents: 2500,
yearlyDiscount: 0.15,
},
},
},
growth: {
trialDays: 14,
features: ['20 seats', 'all integrations', 'priority support', 'SSO'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_EUR!,
monthlyCents: 9900,
yearlyDiscount: 0.20,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_USD!,
monthlyCents: 10900,
yearlyDiscount: 0.20,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_GBP!,
monthlyCents: 8500,
yearlyDiscount: 0.20,
},
},
},
enterprise: {
trialDays: 0,
features: ['unlimited seats', 'custom integrations', 'dedicated CSM', 'SLA'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_EUR!,
monthlyCents: 49900,
yearlyDiscount: 0.25,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_USD!,
monthlyCents: 54900,
yearlyDiscount: 0.25,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_GBP!,
monthlyCents: 42900,
yearlyDiscount: 0.25,
},
},
},
}
export const PlanIdSchema = z.enum(PLAN_IDS)
export const CurrencyIdSchema = z.enum(CURRENCY_IDS)
2. La Server Action di Checkout
Una Server Action tipizzata crea una Checkout Session. L'action legge la config PLANS, sceglie il prezzo per piano e valuta, applica il trial e ritorna l'URL su cui Stripe si aspetta che il browser navighi.
// app/[lang]/(public)/pricing/actions.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { PLANS, PlanIdSchema, CurrencyIdSchema } from '@/lib/billing/plans'
import { createDraftTenant } from '@/lib/tenants/server'
const Input = z.object({
email: z.string().email(),
planId: PlanIdSchema,
currency: CurrencyIdSchema,
interval: z.enum(['monthly', 'yearly']),
})
export async function startCheckout(raw: unknown): Promise<void> {
const parsed = Input.parse(raw)
const plan = PLANS[parsed.planId]
const price = plan.prices[parsed.currency]
const tenant = await createDraftTenant(parsed.email)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: parsed.email,
client_reference_id: tenant.id,
line_items: [
{
price: price.stripePriceId,
quantity: 1,
},
],
subscription_data:
plan.trialDays > 0
? { trial_period_days: plan.trialDays }
: undefined,
automatic_tax: { enabled: true },
billing_address_collection: 'required',
success_url: `${process.env.SITE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/pricing`,
metadata: {
tenant_id: tenant.id,
plan_id: parsed.planId,
currency: parsed.currency,
interval: parsed.interval,
},
})
redirect(session.url!)
}
3. L'handler webhook post-Checkout
Quando Checkout completa, Stripe spara checkout.session.completed. L'handler webhook è quello che scrive la riga subscription canonica, non la pagina di success. Il browser può non arrivare mai a /welcome (errore di rete, navigazione altrove); il webhook ci arriva sempre.
// src/lib/stripe/handlers/checkout-completed.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'
export async function handleCheckoutCompleted(
session: Stripe.Checkout.Session,
): Promise<void> {
if (session.mode !== 'subscription') return
if (!session.subscription || !session.client_reference_id) {
throw new Error('checkout session missing required fields')
}
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
const customerId =
typeof session.customer === 'string'
? session.customer
: session.customer?.id
if (!customerId) throw new Error('checkout session missing customer')
// Promuove il tenant draft: linka l'utente, conserva gli ID Stripe.
const { error: tenantError } = await adminClient
.from('tenants')
.update({
stripe_customer_id: customerId,
status: 'active',
})
.eq('id', session.client_reference_id)
if (tenantError) throw tenantError
// Scrive la riga subscription canonica. Lo stato completo della
// subscription arriva in customer.subscription.updated; questo crea il seed.
const { error: subError } = await adminClient.from('subscriptions').insert({
id: subscriptionId,
tenant_id: session.client_reference_id,
stripe_customer_id: customerId,
plan_id: session.metadata?.plan_id ?? 'unknown',
status: 'trialing',
created_at: new Date().toISOString(),
})
if (subError) throw subError
}
4. L'helper del Customer Portal
Quando il cliente vuole gestire la sua subscription, l'applicazione apre un portale hosted da Stripe. Non ricreiamo mai la UI di Stripe; apriamo una sessione firmata e facciamo redirect.
// app/[lang]/(app)/account/billing/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession } from '@/lib/auth/server'
export async function openBillingPortal(): Promise<void> {
const session = await getServerSession()
if (!session) throw new Error('unauthorised')
const supabase = await createServerClient()
const { data: tenant, error } = await supabase
.from('tenants')
.select('stripe_customer_id')
.eq('id', session.tenantId)
.single()
if (error || !tenant?.stripe_customer_id) {
throw new Error('no billing customer found')
}
const portal = await stripe.billingPortal.sessions.create({
customer: tenant.stripe_customer_id,
return_url: `${process.env.SITE_URL}/account`,
})
redirect(portal.url)
}
5. Come si tiene insieme tutto questo
La config PLANS guida ogni interfaccia di billing. Checkout la legge e crea una sessione. Il webhook scrive la riga subscription risultante. Il Customer Portal apre per il self-service. Ogni pezzo è un file con uno scopo solo, e il codice applicativo non ha bisogno di sapere niente dell'API di Stripe oltre a questi quattro entry point.
Il billing in produzione non è un pulsante Checkout. È la composizione di questi quattro file, più il webhook router descritto nella pagina dello stack Stripe, più il job di riconciliazione notturno. Tremila righe di lavoro, due-quattro settimane se lasci che l'SDK faccia il suo lavoro, e un casino non risolvibile se tratti Stripe come qualcosa da incapsulare.