- src/lib/stripe/index.ts: Stripe client (checkout session + webhook event) - POST /api/aos/stripe/checkout: create subscription checkout for a license - POST /api/aos/stripe/webhook: handle events: - checkout.session.completed → link Stripe customer/subscription to license - invoice.paid → activate license (remove trial) - customer.subscription.deleted → cancel license + schedule instance deletion - invoice.payment_failed → suspend license (dunning) - Middleware: stripe/webhook is public (Stripe calls it without auth) - stripe package installed (v23)
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { db } from '@/lib/db'
|
|
import { constructWebhookEvent } from '@/lib/stripe'
|
|
import type Stripe from 'stripe'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
export const maxDuration = 30
|
|
|
|
// POST /api/aos/stripe/webhook
|
|
// Handles Stripe webhook events:
|
|
// - checkout.session.completed → link Stripe customer + subscription to license
|
|
// - invoice.paid → activate license (remove trial, set to active)
|
|
// - customer.subscription.deleted → cancel license (grace → delete)
|
|
// - invoice.payment_failed → suspend license after dunning
|
|
export async function POST(req: Request) {
|
|
const body = await req.text()
|
|
const signature = req.headers.get('stripe-signature') ?? ''
|
|
|
|
const event = constructWebhookEvent(body, signature)
|
|
if (!event) {
|
|
return NextResponse.json({ error: 'invalid signature' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
switch (event.type) {
|
|
case 'checkout.session.completed': {
|
|
const session = event.data.object as Stripe.Checkout.Session
|
|
const licenseId = session.metadata?.licenseId
|
|
if (!licenseId) break
|
|
|
|
// Link Stripe customer + subscription to the license
|
|
await db.license.update({
|
|
where: { id: licenseId },
|
|
data: {
|
|
stripeCustomerId: session.customer as string,
|
|
stripeSubscriptionId: session.subscription as string,
|
|
},
|
|
})
|
|
await db.auditLog.create({
|
|
data: {
|
|
actor: 'stripe:webhook',
|
|
action: 'stripe.checkout.completed',
|
|
entity: 'License',
|
|
entityId: licenseId,
|
|
after: JSON.stringify({ customerId: session.customer, subscriptionId: session.subscription }),
|
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'invoice.paid': {
|
|
const invoice = event.data.object as Stripe.Invoice
|
|
const subscriptionId = (invoice as Stripe.Invoice & { subscription?: string }).subscription
|
|
if (!subscriptionId) break
|
|
|
|
// Find license by Stripe subscription ID and activate it
|
|
const license = await db.license.findFirst({
|
|
where: { stripeSubscriptionId: subscriptionId },
|
|
})
|
|
if (!license) break
|
|
|
|
// If license was in trial, move to active (paid subscription started)
|
|
if (license.trialEndsAt && new Date(license.trialEndsAt) > new Date()) {
|
|
await db.license.update({
|
|
where: { id: license.id },
|
|
data: {
|
|
status: 'active',
|
|
trialEndsAt: null,
|
|
subscriptionStartsAt: new Date(),
|
|
},
|
|
})
|
|
}
|
|
|
|
await db.auditLog.create({
|
|
data: {
|
|
actor: 'stripe:webhook',
|
|
action: 'stripe.invoice.paid',
|
|
entity: 'License',
|
|
entityId: license.id,
|
|
after: JSON.stringify({ invoiceId: invoice.id, amount: invoice.amount_paid }),
|
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'customer.subscription.deleted': {
|
|
const subscription = event.data.object as Stripe.Subscription
|
|
const license = await db.license.findFirst({
|
|
where: { stripeSubscriptionId: subscription.id },
|
|
})
|
|
if (!license) break
|
|
|
|
// Subscription cancelled → license goes to grace, then deletion
|
|
// (handled by the cleanup cron job)
|
|
const now = new Date()
|
|
await db.license.update({
|
|
where: { id: license.id },
|
|
data: {
|
|
status: 'cancelled',
|
|
cancelledAt: now,
|
|
cancellationEffectiveAt: new Date(now.getTime() + 7 * 86400000), // 7-day grace
|
|
cancellationReason: 'Stripe subscription deleted',
|
|
},
|
|
})
|
|
|
|
// If there's a linked instance, schedule it for deletion too
|
|
const instance = await db.instance.findFirst({
|
|
where: { licenseId: license.id, status: { notIn: ['deleted', 'deleting'] } },
|
|
})
|
|
if (instance) {
|
|
await db.instance.update({
|
|
where: { id: instance.id },
|
|
data: {
|
|
status: 'grace',
|
|
graceEndsAt: new Date(now.getTime() + 7 * 86400000),
|
|
},
|
|
})
|
|
}
|
|
|
|
await db.auditLog.create({
|
|
data: {
|
|
actor: 'stripe:webhook',
|
|
action: 'stripe.subscription.deleted',
|
|
entity: 'License',
|
|
entityId: license.id,
|
|
after: JSON.stringify({ subscriptionId: subscription.id, graceEndsAt: now.getTime() + 7 * 86400000 }),
|
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'invoice.payment_failed': {
|
|
const invoice = event.data.object as Stripe.Invoice
|
|
const subscriptionId = (invoice as Stripe.Invoice & { subscription?: string }).subscription
|
|
if (!subscriptionId) break
|
|
|
|
const license = await db.license.findFirst({
|
|
where: { stripeSubscriptionId: subscriptionId },
|
|
})
|
|
if (!license) break
|
|
|
|
// Payment failed → suspend license (dunning)
|
|
await db.license.update({
|
|
where: { id: license.id },
|
|
data: {
|
|
status: 'suspended',
|
|
suspendReason: 'Stripe payment failed',
|
|
suspendedAt: new Date(),
|
|
},
|
|
})
|
|
|
|
await db.auditLog.create({
|
|
data: {
|
|
actor: 'stripe:webhook',
|
|
action: 'stripe.payment.failed',
|
|
entity: 'License',
|
|
entityId: license.id,
|
|
after: JSON.stringify({ invoiceId: invoice.id, attempt: invoice.attempt_count }),
|
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
default:
|
|
// Unhandled event type — log but don't fail
|
|
break
|
|
}
|
|
|
|
return NextResponse.json({ received: true })
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'webhook handler failed'
|
|
return NextResponse.json({ error: msg }, { status: 500 })
|
|
}
|
|
}
|