UNPKG

skills-ws

Version:

87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.

152 lines (134 loc) • 5.94 kB
## 4. Webhook Handler — Production Grade Idempotency must be **durable and atomic**, not Redis-only. A Redis flush would let old events replay and re-mutate billing state, and two concurrent deliveries of the same event can both pass a `GET` before either `SETEX`. Use the database as the source of truth with a unique constraint on the event id, and *claim before processing*: ```prisma // schema.prisma model ProcessedWebhook { eventId String @id // Stripe event.id — unique constraint = the idempotency lock type String processedAt DateTime @default(now()) } ``` ```typescript // app/api/webhooks/stripe/route.ts import { NextRequest, NextResponse } from 'next/server'; import Stripe from 'stripe'; import { stripe } from '@/lib/stripe'; import { db } from '@/lib/db'; export async function POST(req: NextRequest) { const body = await req.text(); // raw body — required for signature verification const sig = req.headers.get('stripe-signature')!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); } catch { return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } // Claim the event durably and atomically BEFORE doing work. The unique PK makes a // duplicate delivery (or a concurrent one) fail the insert → we ack 200 and skip. try { await db.processedWebhook.create({ data: { eventId: event.id, type: event.type } }); } catch (err) { if (isUniqueViolation(err)) return NextResponse.json({ received: true, duplicate: true }); throw err; } try { await handleEvent(event); return NextResponse.json({ received: true }); } catch (err) { // Roll back the claim so Stripe's retry can reprocess. Stripe retries 5xx with backoff. await db.processedWebhook.delete({ where: { eventId: event.id } }).catch(() => {}); console.error(`Webhook error [${event.type}]:`, err); return NextResponse.json({ error: 'Handler failed' }, { status: 500 }); } } // Prisma unique-constraint code is P2002; adapt for your driver. function isUniqueViolation(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'; } async function handleEvent(event: Stripe.Event) { switch (event.type) { case 'customer.subscription.created': case 'customer.subscription.updated': { const sub = event.data.object as Stripe.Subscription; // A sub can have multiple items (e.g. base + metered). Pick the licensed plan item, // not blindly items.data[0], or a metered add-on can shadow the real plan. const planMap: Record<string, string> = { [process.env.STRIPE_PRICE_PRO!]: 'pro', [process.env.STRIPE_PRICE_TEAM!]: 'team', }; const planItem = sub.items.data.find((i) => planMap[i.price.id]) ?? sub.items.data[0]; // Basil-line: period end is on the item, not the subscription. const periodEnd = planItem.current_period_end; // Ordering: events can arrive out of order. event.created is the authoritative clock — // ignore an update older than what we last applied so a late 'created' can't clobber // a newer 'updated'. (Store lastEventAt alongside the subscription row.) await db.user.updateMany({ where: { stripeCustomerId: sub.customer as string, OR: [{ lastEventAt: null }, { lastEventAt: { lt: new Date(event.created * 1000) } }], }, data: { stripeSubscriptionId: sub.id, subscriptionStatus: sub.status, // raw Stripe status; entitlement derived separately (§10) planId: planMap[planItem.price.id] ?? 'unknown', currentPeriodEnd: new Date(periodEnd * 1000), cancelAtPeriodEnd: sub.cancel_at_period_end, lastEventAt: new Date(event.created * 1000), }, }); break; } case 'customer.subscription.deleted': { const sub = event.data.object as Stripe.Subscription; await db.user.update({ where: { stripeCustomerId: sub.customer as string }, data: { subscriptionStatus: 'canceled', stripeSubscriptionId: null, planId: null }, }); break; } case 'invoice.payment_succeeded': { const inv = event.data.object as Stripe.Invoice; await db.invoice.upsert({ where: { stripeInvoiceId: inv.id }, create: { stripeInvoiceId: inv.id, stripeCustomerId: inv.customer as string, amount: inv.amount_paid, currency: inv.currency, status: 'paid', pdfUrl: inv.invoice_pdf, paidAt: new Date(inv.status_transitions.paid_at! * 1000), }, update: { status: 'paid' }, }); break; } case 'invoice.payment_failed': { const inv = event.data.object as Stripe.Invoice; // Dunning emails based on attempt count if (inv.attempt_count === 1) { await sendEmail(inv.customer_email!, 'payment-failed-soft', { url: inv.hosted_invoice_url }); } else if (inv.attempt_count >= 3) { await sendEmail(inv.customer_email!, 'payment-failed-final', { url: inv.hosted_invoice_url }); } await db.user.update({ where: { stripeCustomerId: inv.customer as string }, data: { subscriptionStatus: 'past_due' }, }); break; } case 'customer.subscription.trial_will_end': { const sub = event.data.object as Stripe.Subscription; const cust = await stripe.customers.retrieve(sub.customer as string) as Stripe.Customer; await sendEmail(cust.email!, 'trial-ending', { trialEnd: new Date(sub.trial_end! * 1000).toLocaleDateString(), }); break; } } } async function sendEmail(to: string, template: string, data: Record<string, any>) { console.log(`[email] ${template} → ${to}`, data); } ``` ---