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 }) } }