Vai al contenuto

Stack · Stripe

Stripe fatto bene è parte del prodotto

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

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.

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.

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.

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.

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.

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.

Approfondimenti

Domande frequenti

Raccontaci come fattura il tuo prodotto

Un sistema di billing in due-quattro settimane.