feat: Stripe integration — checkout + webhook + subscription lifecycle

- 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)
This commit is contained in:
Z User committed 2026-10-28 22:48:33 +00:00
1 parent 3f90c33c5f
commit 7a13dad693
6 files changed
+365

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { createCheckoutSession } from '@/lib/stripe'
export const dynamic = 'force-dynamic'
// POST /api/aos/stripe/checkout
// Creates a Stripe Checkout session for a license subscription.
// Body: { licenseId: string }
export async function POST(req: Request) {
try {
const { licenseId } = await req.json()
if (!licenseId) {
return NextResponse.json({ error: 'licenseId required' }, { status: 400 })
}
const license = await db.license.findUnique({
where: { id: licenseId },
include: { company: true },
})
if (!license) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
if (license.parentId) {
return NextResponse.json({ error: 'checkout for module licenses not supported — pay for the core license' }, { status: 400 })
}
if (license.netPriceCents <= 0) {
return NextResponse.json({ error: 'this license has no price (free)' }, { status: 400 })
}
const result = await createCheckoutSession({
licenseId: license.id,
licenseKey: license.key,
product: license.product,
plan: license.plan,
billingCycle: license.billingCycle as 'monthly' | 'yearly',
netPriceCents: license.netPriceCents,
customerEmail: license.company.email ?? undefined,
})
if ('error' in result) {
return NextResponse.json({ error: result.error }, { status: 502 })
}
// Audit
await db.auditLog.create({
data: {
actor: 'client:checkout',
action: 'stripe.checkout.created',
entity: 'License',
entityId: license.id,
after: JSON.stringify({ plan: license.plan, cycle: license.billingCycle, netPriceCents: license.netPriceCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ url: result.url })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+178
View File
@@ -0,0 +1,178 @@
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 })
}
}