Il prodotto subscription intero, non un pulsante Checkout
Piani definiti in codice, Customer Portal embed, webhook router che sopravvive ai retry, email di dunning collegate al provider di email transazionali, fiscalità gestita da Stripe Tax, riconciliazione notturna contro il database. Il sistema di billing che serve davvero alla tua SaaS, definito e rilasciato in due-quattro settimane.
Il problema
Quasi tutte le "integrazioni Stripe" si fermano al pulsante Checkout
Il Checkout hosted funziona in un'ora. Poi il founder scopre che il sistema di billing vero ha bisogno di subscription con prorating, di un Customer Portal per le cancellazioni, di un handler webhook che non perde eventi quando Stripe ritrasmette, di email di dunning quando una carta fallisce, di configurazione fiscale per regione, di template fattura che portano il brand e di un job di riconciliazione che intercetti il giorno in cui Stripe e il database non concordano. Sei mesi dopo il team ha uno stack di billing fatto a metà che un ingegnere odia in segreto. Noi consegniamo tutto in due-quattro settimane, definito, con il runbook per tenerlo in salute.
Come lo affrontiamo
I sei passi di un'integrazione billing Stripe
Ogni passo chiude una fonte di rischio. L'integrazione esce quando il team riesce a fare un signup nuovo, un cambio piano, un update carta, una cancellazione e un rimborso senza aprire la dashboard di Stripe.
- 01
Definizione del modello di pricing in codice
Piani, valute, intervalli di billing, comportamento dei trial e add-on vengono definiti in una config PLANS tipizzata sotto source control. Le stesse definizioni alimentano Checkout, Customer Portal, dashboard admin e pagina pricing del marketing. Una sola fonte di verità, niente sync manuale.
- 02
Configurazione dell'account Stripe
Impostazioni fiscali, soglie di billing, configurazione del Customer Portal, template di fattura brandizzati, policy di dunning e Smart Retries vengono configurati su Stripe e committati nel runbook. Niente di importante vive solo nella dashboard Stripe.
- 03
Modello dello stato subscription su Postgres
Una tabella subscriptions rispecchia la subscription Stripe in modo canonico (status, current_period_end, cancel_at_period_end, price_id, quantity). L'applicazione legge da Postgres; il webhook tiene Postgres allineato a Stripe.
- 04
Collegamento di Checkout e Customer Portal
Una Server Action tipizzata crea una Checkout Session con il prezzo, la valuta e il trial giusti. Il Customer Portal apre via URL di sessione firmato con un return path. Entrambi i flussi vivono sul dominio applicativo.
- 05
Costruzione del webhook event router
Una sola route in runtime Node riceve ogni evento Stripe, verifica la firma, deduplica per event ID contro una tabella di idempotenza, fa routing per tipo di evento via discriminated union e scrive in Postgres in transazione. I retry sono sicuri per costruzione.
- 06
Aggiunta di riconciliazione e dunning
Un job notturno confronta gli eventi Stripe col database e fa emergere il drift. Le email di dunning si collegano al provider di transazionale (Resend di default) con una sequenza che rispecchia gli Smart Retries di Stripe. L'MRR recuperato compare nella dashboard admin.
Cosa consegniamo
Config PLANS tipizzata
Un singolo file TypeScript che definisce ogni piano, valuta, intervallo e feature flag. Usato da Checkout, Portal, admin e pagina pricing.
Integrazione Stripe Checkout
Checkout hosted per il lancio veloce, o Elements embedded quando il buyer vuole un checkout completamente brandizzato. Scegliamo sull'evidenza di conversione.
Customer Portal embed
Gestione piano self-service, cancellazioni, metodi di pagamento e storico fatture. Configurato per andare in linea con brand e prodotto.
Webhook event router
Handler idempotente con verifica firma, tabella di dedupe per event ID, routing type-safe per tipo di evento, scritture DB transazionali.
Mirror Postgres dello stato Stripe
Una tabella subscriptions che tiene la vista locale canonica. Il codice applicativo legge da Postgres; il webhook lo tiene allineato.
Gestione dei trial
Periodi di trial configurati per piano, handler trial_will_end collegati alla notifica, scadenza trial che si chiude in modo pulito quando manca un metodo di pagamento.
Upgrade e downgrade con prorating
Cambi piano via `stripe.subscriptions.update` con `proration_behavior: create_prorations`. Opzioni mid-cycle, end-of-cycle e immediate, esposte secondo le esigenze UX.
Dunning e Smart Retries
Recupero del pagamento fallito, finestre di smart retry, sequenza di email di dunning collegata al provider di transazionale, banner in-app quando il metodo di pagamento richiede attenzione.
Stripe Tax collegato
Calcolo automatico di IVA, GST e sales tax per regione. Tax ID raccolti al checkout, formato fattura conforme alla giurisdizione del buyer.
Template fattura brandizzati
PDF di fattura e ricevuta personalizzati con brand, copy del footer, termini di pagamento e dati di contatto. Formattazione multi-valuta gestita correttamente.
Job di riconciliazione
Un job notturno confronta gli eventi Stripe col database, fa emergere il drift, manda alert su incongruenze prima che lo faccia il team finanza.
Migrazione da billing esistente
Subscription esistenti migrate da PayPal, Paddle, Lemon Squeezy o un altro account Stripe, ciclo di billing preservato, niente doppio addebito.
Config di pricing, Checkout, Portal e sync di stato post-checkout
Quattro file che compongono un entry point di billing completo. La config PLANS è la fonte di verità; Checkout la legge; l'handler webhook scrive la riga subscription risultante; il Customer Portal apre via URL di sessione firmato.
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.
Stack correlati
Domande frequenti
In quanto tempo rilasciate il sistema di billing?
Due-quattro settimane per una SaaS tipica con tre piani, due valute e comportamento standard del trial. Più veloce per uno scope contenuto (un solo piano, una valuta, niente portal). Più lungo quando il modello di pricing è insolito (usage-based, tiered complesso, marketplace via Connect). La fase di scoping produce un numero concreto.
Checkout o Elements?
Checkout per la stragrande maggioranza delle SaaS: più veloce da rilasciare, scope PCI più piccolo (SAQ A), meno regressioni di conversione perché il flusso di Stripe è già ottimizzato. Elements quando il buyer vuole un checkout in-app completamente brandizzato (spesso premium consumer o marketplace). Scegliamo sui dati di conversione, non sui gusti.
Come gestite la multi-valuta?
Un solo account Stripe, più valute di presentazione. I prezzi vivono nella config PLANS con una voce per valuta. La valuta del buyer è rilevata dal locale o selezionata al checkout. La dashboard di reporting mostra sempre la valuta canonica per la riconciliazione.
E l'affidabilità dei webhook sotto carico?
Ogni evento è idempotente per event ID Stripe. L'handler persiste l'event ID in una tabella con vincolo unique prima del lavoro; un retry sbatte sul vincolo ed esce subito. La scrittura sul database è transazionale; un fallimento fa rollback e Stripe ritrasmette. La tabella di audit registra ogni evento che abbiamo mai visto.
Migrate da PayPal, Paddle, Lemon Squeezy?
Sì. Le subscription attive migrano con il ciclo di billing preservato. I metodi di pagamento si importano via i tool di migrazione del provider, dove supportati (Stripe ha importatori diretti per alcuni). Il cutover avviene in un solo giorno, dentro una freeze window.
Come gestite IVA UE e compliance fiscale?
Stripe Tax gestisce in automatico IVA, GST e sales tax per le regioni che interessano al buyer. I tax ID sono raccolti al checkout, le fatture si formano correttamente per giurisdizione, lo stato di compliance è documentato nel passaggio di consegne tecnico. Non inventiamo legge fiscale.
Di chi è l'infrastruttura di billing dopo la consegna?
Tua. L'account Stripe è tuo, il codice è nel tuo repository, il runbook vive lì accanto. Possiamo restare come partner per esperimenti continui di pricing, tuning del dunning e cambi fiscali, oppure passare al tuo team. In entrambi i casi il livello operativo è portabile.
Raccontaci il modello di pricing
Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. Il sistema di billing completo in due-quattro settimane.