Stack · Stripe

Stripe fatto bene è parte del prodotto, non un pulsante di checkout

Subscriptions con prorating, Customer Portal per il self-service, un webhook router idempotente che sopravvive al giorno in cui Stripe ritrasmette lo stesso evento quattro volte. Il sistema di billing che cresce col prodotto, invece di chiedere una riscrittura alla series A.

Perché questo stack

01

Customer Portal è la funzione più economica perché non la sviluppi tu

Cambi di piano, flussi di cancellazione, scarico ricevute, storico fatture, aggiornamento del metodo di pagamento. Tutto arriva gratis con Customer Portal. Svilupparlo in casa sono due mesi-uomo di lavoro che nessuno vuole poi mantenere.

02

Subscriptions gestisce i casi limite che affondano il billing fai-da-te

Prorating in upgrade, downgrade al confine del periodo, add-on a metà ciclo, estensione dei trial, retry su carta rifiutata, email di dunning. Stripe le gestisce tutte. Noi colleghiamo il listener così il database rispecchia quello che Stripe sa.

03

I webhook come fonte di verità

Il client non decide mai se un abbonamento è attivo. Il webhook riceve l'evento, valida la firma, scrive la riga canonica e l'applicazione legge da lì. Niente race condition fra il completamento del checkout e lo stato del database.

04

Tax e Radar sono compliance che altrimenti rimanderesti

Stripe Tax gestisce in automatico IVA, GST e sales tax per le regioni che interessano al tuo buyer. Radar gestisce le regole anti-frode senza scrivere un motore di euristiche. Entrambi arrivano come configurazione, non come progetto parallelo.

05

Sigma e i report per il team finanza senza dashboard custom

Il tuo team finanza scrive SQL sui dati Stripe vivi. Niente export CSV, niente script di riconciliazione mensile, niente registro contabile parallelo che diverge dalla verità.

Cosa sviluppiamo con questa tecnologia

Integrazione Stripe Checkout

Checkout hosted per lanci veloci, oppure Elements embedded per flussi completamente brandizzati. Scegliamo sui dati di conversione, non sui gusti.

Subscriptions con prorating

Piani tier, mensile e annuale, add-on usage-based, prorating ad ogni upgrade, downgrade al confine del periodo, abbonamenti in pausa.

Customer Portal embed

Gestione del piano self-service, flussi di cancellazione, storico fatture, aggiornamento metodo di pagamento. Configurato per combaciare con brand e prodotto.

Webhook event router

Handler idempotente che deduplica via event ID, routing type-safe per nome evento, transazione DB per evento, dead-letter queue per i messaggi avvelenati.

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.

Regole anti-frode Radar

Block rule, review rule, allow list per i clienti ricorrenti. Calibrate sui pattern di frode reali del prodotto, non su un set generico.

Stripe Connect per i marketplace

Account connessi Express, Standard o Custom a seconda del modello del marketplace. Onboarding, payout, KYC, reportistica 1099 dove serve.

Billing usage-based

Metering, finestre di aggregazione, prezzi a tier, soglie di overage. Stripe Meters o usage record custom a seconda del modello.

Personalizzazione fatture

Template fattura brandizzati, formattazione delle voci, multi-valuta, breakdown delle righe tax, footer copy personalizzato.

Dunning e smart retry

Recupero del pagamento fallito, finestre di smart retry, sequenza email di dunning collegata al tuo provider di email transazionale.

Job di riconciliazione

Un job notturno che confronta eventi Stripe e database e fa emergere le divergenze prima che lo faccia il team finanza.

Migrazione da PayPal, Paddle, Lemon Squeezy

Abbonamenti esistenti migrati col ciclo di billing conservato, metodi di pagamento dei clienti importati dove il provider sorgente lo permette.

Un webhook router idempotente che sopravvive ai retry di Stripe

Stripe ritrasmette i webhook falliti fino a tre giorni. Ogni handler che rilasciamo è idempotente per event ID, type-safe per nome evento e transazionale rispetto alla scrittura sul database.

La maggior parte dei tutorial Stripe collega un pulsante Checkout e si ferma lì. Il billing in produzione si rompe il giorno in cui Stripe ritrasmette un evento quattro volte e il database si ritrova con tre righe aggiornate a metà. Il webhook router qui sotto è quello che rilasciamo: idempotente per event ID, type-safe per nome evento, transazionale rispetto al database e osservabile quando un evento fallisce.

1. Il route handler

Il webhook arriva su una route in runtime Node. Ci serve il body raw per validare la firma, quindi lo leggiamo come testo prima di parsare. Il check di idempotenza avviene prima del processing dell'evento.

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/server'
import { recordWebhookEvent, isAlreadyProcessed } from '@/lib/stripe/events'
import { routeEvent } from '@/lib/stripe/router'

export const runtime = 'nodejs'

export async function POST(request: Request) {
  const signature = (await headers()).get('stripe-signature')
  if (!signature) return new Response('missing signature', { status: 400 })

  const body = await request.text()
  let event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    )
  } catch {
    return new Response('invalid signature', { status: 400 })
  }

  // Idempotenza: usciamo se questo event ID è già stato processato.
  if (await isAlreadyProcessed(event.id)) {
    return new Response(null, { status: 200 })
  }

  try {
    await routeEvent(event)
    await recordWebhookEvent(event.id, event.type, 'processed')
    return new Response(null, { status: 200 })
  } catch (err) {
    await recordWebhookEvent(
      event.id,
      event.type,
      'failed',
      err instanceof Error ? err.message : 'unknown',
    )
    // Restituire 500 fa ritrasmettere Stripe. Restituire 200 fa ack;
    // scegliamo in base a se il fallimento è recuperabile.
    return new Response('handler failed', { status: 500 })
  }
}

2. La tabella di idempotenza

Una sola tabella con vincolo unique su event_id è tutto il meccanismo. Un retry duplicato sbatte sul vincolo ed esce subito.

-- supabase/migrations/0010_stripe_webhook_events.sql
create table stripe_webhook_events (
  event_id text primary key,
  type text not null,
  status text not null,
  error text,
  received_at timestamptz not null default now()
);

create index stripe_webhook_events_received_idx
  on stripe_webhook_events(received_at desc);
// src/lib/stripe/events.ts
import { adminClient } from '@/lib/supabase/admin'

export async function isAlreadyProcessed(eventId: string): Promise<boolean> {
  const { data } = await adminClient
    .from('stripe_webhook_events')
    .select('event_id')
    .eq('event_id', eventId)
    .maybeSingle()
  return data !== null
}

export async function recordWebhookEvent(
  eventId: string,
  type: string,
  status: 'processed' | 'failed',
  error?: string,
): Promise<void> {
  await adminClient.from('stripe_webhook_events').upsert({
    event_id: eventId,
    type,
    status,
    error: error ?? null,
  })
}

3. Il router type-safe

Una discriminated union mappa i tipi di evento sugli handler. TypeScript restringe la forma dell'evento per ogni case, così l'handler riceve un payload tipizzato senza un cast runtime.

// src/lib/stripe/router.ts
import type Stripe from 'stripe'
import { handleCheckoutCompleted } from './handlers/checkout-completed'
import { handleSubscriptionUpdated } from './handlers/subscription-updated'
import { handleInvoicePaymentFailed } from './handlers/invoice-payment-failed'
import { handleCustomerDeleted } from './handlers/customer-deleted'

export async function routeEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case 'checkout.session.completed':
      return handleCheckoutCompleted(event.data.object)
    case 'customer.subscription.updated':
    case 'customer.subscription.created':
    case 'customer.subscription.deleted':
      return handleSubscriptionUpdated(event.data.object)
    case 'invoice.payment_failed':
      return handleInvoicePaymentFailed(event.data.object)
    case 'customer.deleted':
      return handleCustomerDeleted(event.data.object)
    default:
      // Gli eventi non gestiti tornano 200 senza lavoro; la tabella li
      // registra comunque così possiamo verificare il traffico senza perdere
      // l'evento.
      return
  }
}

4. Un handler transazionale

L'handler della subscription fa upsert della riga canonica dentro una sola transazione. O il database combacia con Stripe, o la scrittura fa rollback e Stripe ritrasmette.

// src/lib/stripe/handlers/subscription-updated.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'

export async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription,
): Promise<void> {
  const tenantId =
    subscription.metadata.tenant_id ??
    (await lookupTenantByCustomer(subscription.customer as string))

  if (!tenantId) {
    throw new Error(`no tenant for subscription ${subscription.id}`)
  }

  const { error } = await adminClient.from('subscriptions').upsert({
    id: subscription.id,
    tenant_id: tenantId,
    status: subscription.status,
    current_period_end: new Date(
      subscription.current_period_end * 1000,
    ).toISOString(),
    cancel_at_period_end: subscription.cancel_at_period_end,
    price_id: subscription.items.data[0]?.price.id ?? null,
    quantity: subscription.items.data[0]?.quantity ?? 1,
    updated_at: new Date().toISOString(),
  })

  if (error) throw error
}

async function lookupTenantByCustomer(customerId: string): Promise<string | null> {
  const { data } = await adminClient
    .from('tenants')
    .select('id')
    .eq('stripe_customer_id', customerId)
    .maybeSingle()
  return data?.id ?? null
}

5. Cosa ti compra questo

Quando Stripe ritrasmette un evento, il vincolo unique su event_id cortocircuita il lavoro duplicato. Quando la scrittura sul database fallisce, tutto l'handler fa rollback e Stripe ritrasmette. Quando esce un nuovo tipo di evento, aggiungi un case nel router e TypeScript ti dice la forma del payload. La tabella di audit registra ogni evento che l'applicazione abbia mai visto, così il giorno in cui un cliente chiede "perché il mio abbonamento mostra il piano sbagliato", hai le ricevute.

Il billing in produzione non è un pulsante Checkout. È la disciplina di trattare gli eventi Stripe come la fonte di verità e far rincorrere il database, retry-safe, type-safe, ogni volta.

Domande frequenti

Stripe Checkout o Elements?

Checkout per la maggior parte delle SaaS, più veloce da rilasciare, meno regressioni di conversione, hosted da Stripe così lo scope PCI resta limitato. Elements quando il buyer vuole un checkout in-app completamente brandizzato (spesso flussi premium consumer o marketplace). Scegliamo sui dati di conversione, non sui gusti.

Come gestite l'idempotenza dei webhook?

Ogni evento ha un event ID Stripe. Persistiamo quell'ID con un vincolo unique prima di processare l'evento; un retry duplicato sbatte sul vincolo e restituisce 200 senza rieseguire il lavoro. L'handler è anche dentro una transazione database, così una scrittura parziale non lascia mai la riga in stato a metà.

Migrate da PayPal, Paddle o Lemon Squeezy?

Sì. Gli abbonamenti attivi migrano conservando il ciclo di billing; i metodi di pagamento si importano via i tool di customer migration del provider dove supportati (Stripe ha importatori diretti per alcuni). Il passaggio avviene in un solo giorno con una freeze window e un runbook che il team esegue passo per passo.

Come gestite la multi-valuta?

Un solo account Stripe, più valute di presentazione. I prezzi sono quotati nella valuta del buyer al checkout, la valuta di payout è impostata per regione, il cambio lo gestisce Stripe. I report del dashboard mostrano sempre la valuta canonica per la riconciliazione.

Fate Stripe Connect per i marketplace?

Sì. Express per onboarding rapido (default), Custom quando il marketplace deve controllare in pieno l'esperienza dell'account connesso. Abbiamo collegato payout, reportistica 1099 dove serve, retry KYC e dashboard per gli account connessi in marketplace di tre verticali diversi.

Compliance PCI e IVA UE?

Lo scope PCI resta a SAQ A quando il checkout avviene su Stripe Hosted Checkout o via Elements con input in iframe. L'IVA UE la gestisce Stripe Tax raccogliendo il tax ID del buyer al checkout. Documentiamo lo stato di compliance nel passaggio di consegne tecnico.

Dunning e recupero ricavi?

Smart retry calibrati sulla tua base clienti, email di dunning collegate al provider di transazionale (Resend di default), banner in-app quando il metodo di pagamento richiede attenzione. L'MRR recuperato viene mostrato nella dashboard di amministrazione.

Raccontaci come fattura il tuo prodotto

Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. Un sistema di billing in due-quattro settimane.