
Un solo pagamento, sei partecipanti: come ho modellato i bundle multi-persona per il B Lab Italia Summit 2026
A maggio 2026, il B Lab Italia Summit si è svolto a Milano con trecento partecipanti sparsi tra singoli, duo e intere squadre aziendali. Io ho costruito da zero la piattaforma di ticketing: pagamenti, gestione anagrafi, check-in con QR code e backoffice per lo staff. Stack standard (Next.js, Payload CMS v3, MongoDB, Stripe), ma la complessità vera arrivava da un requisito apparentemente semplice: vendere biglietti in bundle fino a sei persone con un unico pagamento.
Il problema: un pagatore, sei partecipanti
Un bundle da 6 persone significa una sola carta creditaria che paga, un'unica fattura intestata al responsabile aziendale, ma sei QR code distinti che entrano all'evento, sei email di conferma, sei profili di sessione indipendenti. Se il sistema duplica anche una sola volta, magari al retry di un webhook, avrò sei QR fantasma che circolano, sei email spedite due volte, e lo staff al check-in non sa quale scansionare.
Inoltre, l'organizzatore voleva poter cambiare un partecipante dentro un bundle già pagato, cercare velocemente un singolo nome in admin senza perdersi in array nidificati, ed esportare un CSV lineare con una riga per persona da dare al desk check-in. Niente di impossibile, ma niente di scontato nemmeno.
Scelta 1: BundleAssignments come collection dedicata
Ho creato una collection Payload separata, BundleAssignments, invece di annidare i partecipanti dentro l'ordine. Ogni assignment è un record autonomo con relazione all'ordine padre, ai QRCodes generati, alle SessionPreferences.
Tre vantaggi concreti: primo, cercare un partecipante per nome o email ritorna un record semplice e interrogabile da admin senza scavare in nidificazioni; secondo, l'export CSV per il check-desk è una query lineare su BundleAssignments; terzo, se lo staff modifica l'email di una persona una settimana prima dell'evento, non tocco l'ordine, aggiorno solo l'assignment.
Scelta 2: Stripe Embedded Checkout
Ho scelto Embedded Checkout al posto del redirect verso Stripe Hosted. L'evento è premium, il brand del Summit deve rimanere a schermo per tutto il funnel di pagamento. Un redirect esterno spezza l'esperienza e aumenta il tasso di abbandono.
Embedded mantiene il checkout dentro un iframe del Summit, con logo, colori e copy consistenti. Tehnicamente è una riga di SDK, ma la percezione dell'utente cambia drasticamente.
Scelta 3: Idempotency e rate limiting sul webhook
Il webhook checkout.session.completed genera fino a 6 QR code + 6 email. Se Stripe lo riprova (retry), il sistema non deve duplicare nulla. La soluzione: chiave idempotente lato server associata a ogni session Stripe, e webhook scritto per essere replaysafe.
// Generazione session idempotente
const idempotencyKey = `order_${orderId}_${bundleSize}`;
const session = await stripe.checkout.sessions.create({
idempotency_key: idempotencyKey,
// ... payment_intent_data, line_items, etc
});Aggiunto anche rate limiting: 10 richieste per IP ogni 15 minuti sull'endpoint di checkout. Così, anche un bot arrabbiato non mi fa riempire il DB di sessioni duplicate.
Scelta 4: Generazione asincrona di QR ed email
Il webhook risponde subito a Stripe (200 OK in meno di un secondo). Il lavoro pesante (generare 6 QR con token UUID univoci, costruire 6 email bilingui con React Email, spedire via Resend) avviene in un job backgroundale con Payload Jobs e 3 retry automatici.
// Webhook: risponde fast
app.post('/webhooks/stripe', async (req, res) => {
const session = req.body.data.object;
await db.orders.create({
stripeSessionId: session.id,
status: 'pending_qr_generation'
});
// Schedule job
await jobs.enqueue('generateQRAndEmail', { orderId: order.id });
res.sendStatus(200);
});
// Job: genera QR + email con retry
payload.defineJob({
slug: 'generateQRAndEmail',
handler: async ({ orderId }) => {
const order = await db.orders.findById(orderId);
for (const assignment of order.bundleAssignments) {
const qrToken = uuid();
await db.qrcodes.create({ token: qrToken, assignment });
await resend.emails.send({
to: assignment.email,
template: 'TicketConfirmation',
props: { qrToken, language: assignment.language }
});
}
await db.orders.update(orderId, { status: 'confirmed' });
}
});Se un job fallisce, Payload lo riprova tre volte con backoff esponenziale. Se fallisce definitivamente, ho visibilità in admin e posso triggerare manualmente il job da un bottone.
Scelta 5: Refund handling senza ghost ticket
Il webhook charge.refunded invalida automaticamente tutti i QR code collegati all'ordine rimborso. Niente biglietti che rimangono attivi mentre il denaro torna al mittente. Lo staff vede subito che quel QR è revoked quando lo scansiona al check-in.
Inoltre, il system invia una email di conferma rimborso al responsabile dell'ordine. Se è un bundle aziendale, il CFO riceve una ricevuta che documenta il rimborso.
Il risultato: zero incidenti, modello stabile
Il summit si è svolto regolarmente con 300 partecipanti, 45 bundle totali, zero duplicazioni di QR, zero email spedite male. Lo staff ha fatto il check-in direttamente da smartphone usando la fotocamera del browser per leggere i codici.
Cosa ho imparato
Progetti come questo insegnano che la complessità non è nello stack, ma nella modellazione dati e nel flusso di operazioni asincrone. Stripe Embedded, Next.js e MongoDB sono strumenti solidi, ma il vero lavoro è pensare a replay di webhook, invalidazione di stati, audit trail. Un webhook duplicato è raro ma non impossibile, e costruire con quello in mente dal giorno uno evita refund e debug a caldo durante un evento.
Se stai lavorando su un sistema di ticketing, un evento con flussi complessi, oppure integrazioni Stripe con pagamenti multi-persona, sono disponibile per consulenza e sviluppo. Scrivimi pure.


