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:
1 parent
3f90c33c5f
commit
7a13dad693
6 files changed
+365
No files matched your search
@@ -68,6 +68,7 @@
|
|||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
|
"stripe": "^23.0.1",
|
||||||
"tailwind-merge": "^3.7.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
@@ -1756,6 +1757,8 @@
|
|||||||
|
|
||||||
"strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmjs.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
"strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmjs.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
|
"stripe": ["stripe@23.0.1", "", { "peerDependencies": { "@types/node": ">=20" }, "optionalPeers": ["@types/node"] }, "sha512-VFLs9IAzSZ8GIm6/vqhjqOL0fbYrSNYCpgZ3LS3YkSi/i5RnyMpu/Hf9YxPTOCYwc3mZKU3IJvuvKffQH2NLMg=="],
|
||||||
|
|
||||||
"style-mod": ["style-mod@4.1.3", "https://registry.npmjs.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
"style-mod": ["style-mod@4.1.3", "https://registry.npmjs.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||||
|
|
||||||
"style-to-js": ["style-to-js@1.1.21", "https://registry.npmjs.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
"style-to-js": ["style-to-js@1.1.21", "https://registry.npmjs.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
|
"stripe": "^23.0.1",
|
||||||
"tailwind-merge": "^3.7.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe Client
|
||||||
|
* --------------
|
||||||
|
* Creates Stripe checkout sessions for license subscriptions and handles
|
||||||
|
* webhook events (invoice.paid, subscription lifecycle).
|
||||||
|
*
|
||||||
|
* Config (env):
|
||||||
|
* STRIPE_SECRET_KEY - sk_test_... or sk_live_...
|
||||||
|
* STRIPE_WEBHOOK_SECRET - whsec_... (for webhook verification)
|
||||||
|
* STRIPE_PUBLISHABLE_KEY - pk_test_... or pk_live_... (client-side)
|
||||||
|
* AOS_PUBLIC_URL - base URL for success/cancel redirects
|
||||||
|
*/
|
||||||
|
|
||||||
|
let stripeInstance: Stripe | null = null
|
||||||
|
|
||||||
|
export function getStripe(): Stripe | null {
|
||||||
|
const key = process.env.STRIPE_SECRET_KEY
|
||||||
|
if (!key) return null
|
||||||
|
if (!stripeInstance) {
|
||||||
|
stripeInstance = new Stripe(key, {
|
||||||
|
apiVersion: '2025-10-28.acacia' as Stripe.LatestApiVersion,
|
||||||
|
typescript: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stripeInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan → Stripe Price mapping (create these prices in Stripe dashboard)
|
||||||
|
// If STRIPE_PRICE_STARTER, STRIPE_PRICE_PRO, STRIPE_PRICE_ENTERPRISE env
|
||||||
|
// vars are set, they override these defaults.
|
||||||
|
const PLAN_PRICES: Record<string, string> = {
|
||||||
|
starter: process.env.STRIPE_PRICE_STARTER ?? '',
|
||||||
|
pro: process.env.STRIPE_PRICE_PRO ?? '',
|
||||||
|
enterprise: process.env.STRIPE_PRICE_ENTERPRISE ?? '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStripePriceId(plan: string, cycle: 'monthly' | 'yearly'): string | null {
|
||||||
|
// If we have per-plan price IDs, use them.
|
||||||
|
// Otherwise return null (checkout session will use custom amount).
|
||||||
|
const base = PLAN_PRICES[plan]
|
||||||
|
if (!base) return null
|
||||||
|
// For yearly, append '_yearly' suffix (create separate price in Stripe)
|
||||||
|
return cycle === 'yearly' ? `${base}_yearly` : base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a checkout session for a license subscription
|
||||||
|
export async function createCheckoutSession(input: {
|
||||||
|
licenseId: string
|
||||||
|
licenseKey: string
|
||||||
|
product: string
|
||||||
|
plan: string
|
||||||
|
billingCycle: 'monthly' | 'yearly'
|
||||||
|
netPriceCents: number
|
||||||
|
customerEmail?: string
|
||||||
|
successUrl?: string
|
||||||
|
cancelUrl?: string
|
||||||
|
}): Promise<{ url: string } | { error: string }> {
|
||||||
|
const stripe = getStripe()
|
||||||
|
if (!stripe) return { error: 'Stripe not configured (STRIPE_SECRET_KEY missing)' }
|
||||||
|
|
||||||
|
const baseUrl = process.env.AOS_PUBLIC_URL || 'https://dfmpanel.eu'
|
||||||
|
const successUrl = input.successUrl || `${baseUrl}/portal?tab=licenses&status=success&license=${input.licenseId}`
|
||||||
|
const cancelUrl = input.cancelUrl || `${baseUrl}/portal?tab=licenses&status=cancelled`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: 'subscription',
|
||||||
|
customer_email: input.customerEmail,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: 'eur',
|
||||||
|
product_data: {
|
||||||
|
name: `${input.product} — ${input.plan.toUpperCase()}`,
|
||||||
|
description: `Lizenz ${input.licenseKey} (${input.billingCycle === 'yearly' ? 'jährlich' : 'monatlich'})`,
|
||||||
|
},
|
||||||
|
unit_amount: input.netPriceCents,
|
||||||
|
recurring: {
|
||||||
|
interval: input.billingCycle === 'yearly' ? 'year' : 'month',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
licenseId: input.licenseId,
|
||||||
|
licenseKey: input.licenseKey,
|
||||||
|
product: input.product,
|
||||||
|
plan: input.plan,
|
||||||
|
},
|
||||||
|
success_url: successUrl,
|
||||||
|
cancel_url: cancelUrl,
|
||||||
|
locale: 'de',
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { url: session.url ?? '' }
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : 'Stripe checkout failed' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct webhook event from raw body + signature
|
||||||
|
export function constructWebhookEvent(
|
||||||
|
body: string | Buffer,
|
||||||
|
signature: string
|
||||||
|
): Stripe.Event | null {
|
||||||
|
const stripe = getStripe()
|
||||||
|
if (!stripe) return null
|
||||||
|
const secret = process.env.STRIPE_WEBHOOK_SECRET
|
||||||
|
if (!secret) return null
|
||||||
|
try {
|
||||||
|
return stripe.webhooks.constructEvent(body, signature, secret)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const PUBLIC_PATHS = [
|
|||||||
'/api/aos/seed-prod',
|
'/api/aos/seed-prod',
|
||||||
'/api/aos/licenses/check',
|
'/api/aos/licenses/check',
|
||||||
'/api/aos/licenses/validate',
|
'/api/aos/licenses/validate',
|
||||||
|
'/api/aos/stripe/webhook',
|
||||||
'/login',
|
'/login',
|
||||||
'/_next',
|
'/_next',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
@@ -22,6 +23,7 @@ const PUBLIC_API_PREFIXES = [
|
|||||||
'/api/aos/seed-prod',
|
'/api/aos/seed-prod',
|
||||||
'/api/aos/licenses/check',
|
'/api/aos/licenses/check',
|
||||||
'/api/aos/licenses/validate',
|
'/api/aos/licenses/validate',
|
||||||
|
'/api/aos/stripe/webhook',
|
||||||
'/api/v1/', // public REST API (token-auth'd separately)
|
'/api/v1/', // public REST API (token-auth'd separately)
|
||||||
'/api/mcp', // MCP (token-auth'd)
|
'/api/mcp', // MCP (token-auth'd)
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in new issue
Block a user