From 7a13dad6934e74e98975c5b3c70b3f1720422bfa Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 22:48:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Stripe=20integration=20=E2=80=94=20chec?= =?UTF-8?q?kout=20+=20webhook=20+=20subscription=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- bun.lock | 3 + package.json | 1 + src/app/api/aos/stripe/checkout/route.ts | 62 ++++++++ src/app/api/aos/stripe/webhook/route.ts | 178 +++++++++++++++++++++++ src/lib/stripe/index.ts | 119 +++++++++++++++ src/middleware.ts | 2 + 6 files changed, 365 insertions(+) mode change 100755 => 100644 bun.lock create mode 100644 src/app/api/aos/stripe/checkout/route.ts create mode 100644 src/app/api/aos/stripe/webhook/route.ts create mode 100644 src/lib/stripe/index.ts diff --git a/bun.lock b/bun.lock old mode 100755 new mode 100644 index 91a309a..406c0ca --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "recharts": "^2.15.4", "sharp": "^0.34.3", "sonner": "^2.0.6", + "stripe": "^23.0.1", "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "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=="], + "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-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=="], diff --git a/package.json b/package.json index f211496..c99b789 100755 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "recharts": "^2.15.4", "sharp": "^0.34.3", "sonner": "^2.0.6", + "stripe": "^23.0.1", "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", diff --git a/src/app/api/aos/stripe/checkout/route.ts b/src/app/api/aos/stripe/checkout/route.ts new file mode 100644 index 0000000..3b1d818 --- /dev/null +++ b/src/app/api/aos/stripe/checkout/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/stripe/webhook/route.ts b/src/app/api/aos/stripe/webhook/route.ts new file mode 100644 index 0000000..ba13800 --- /dev/null +++ b/src/app/api/aos/stripe/webhook/route.ts @@ -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 }) + } +} diff --git a/src/lib/stripe/index.ts b/src/lib/stripe/index.ts new file mode 100644 index 0000000..1789965 --- /dev/null +++ b/src/lib/stripe/index.ts @@ -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 = { + 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 + } +} diff --git a/src/middleware.ts b/src/middleware.ts index b1bcd51..e9814ee 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,6 +9,7 @@ const PUBLIC_PATHS = [ '/api/aos/seed-prod', '/api/aos/licenses/check', '/api/aos/licenses/validate', + '/api/aos/stripe/webhook', '/login', '/_next', '/favicon.ico', @@ -22,6 +23,7 @@ const PUBLIC_API_PREFIXES = [ '/api/aos/seed-prod', '/api/aos/licenses/check', '/api/aos/licenses/validate', + '/api/aos/stripe/webhook', '/api/v1/', // public REST API (token-auth'd separately) '/api/mcp', // MCP (token-auth'd) ]