From bccf771ffce25e187f4e00a782655399a647da3f Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Tue, 14 Oct 2025 16:58:11 +0200 Subject: [PATCH] Add Stripe subscription integration with pricing plans --- prisma/schema.prisma | 15 +- src/app/(app)/dashboard/page.tsx | 71 +++++-- src/app/(app)/layout.tsx | 9 + src/app/(app)/pricing/page.tsx | 272 +++++++++++++++++++++++++++ src/app/api/stripe/checkout/route.ts | 80 ++++++++ src/app/api/stripe/webhook/route.ts | 104 ++++++++++ src/app/api/user/plan/route.ts | 39 ++++ src/lib/stripe.ts | 76 ++++++++ 8 files changed, 654 insertions(+), 12 deletions(-) create mode 100644 src/app/(app)/pricing/page.tsx create mode 100644 src/app/api/stripe/checkout/route.ts create mode 100644 src/app/api/stripe/webhook/route.ts create mode 100644 src/app/api/user/plan/route.ts create mode 100644 src/lib/stripe.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 860c33c..77e3ef3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,13 +19,26 @@ model User { emailVerified DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + + // Stripe subscription fields + stripeCustomerId String? @unique + stripeSubscriptionId String? @unique + stripePriceId String? + stripeCurrentPeriodEnd DateTime? + plan Plan @default(FREE) + qrCodes QRCode[] integrations Integration[] accounts Account[] sessions Session[] } +enum Plan { + FREE + PRO + BUSINESS +} + model Account { id String @id @default(cuid()) userId String diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index a92c3ae..3b05f43 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import Link from 'next/link'; +import { useSession } from 'next-auth/react'; import { StatsGrid } from '@/components/dashboard/StatsGrid'; import { QRCodeCard } from '@/components/dashboard/QRCodeCard'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; @@ -23,8 +24,10 @@ interface QRCodeData { export default function DashboardPage() { const { t } = useTranslation(); + const { data: session } = useSession(); const [qrCodes, setQrCodes] = useState([]); const [loading, setLoading] = useState(true); + const [userPlan, setUserPlan] = useState('FREE'); const [stats, setStats] = useState({ totalScans: 0, activeQRCodes: 0, @@ -116,19 +119,20 @@ export default function DashboardPage() { ]; useEffect(() => { - // Load real QR codes from API + // Load real QR codes and user plan from API const fetchData = async () => { try { - const response = await fetch('/api/qrs'); - if (response.ok) { - const data = await response.json(); + // Fetch QR codes + const qrResponse = await fetch('/api/qrs'); + if (qrResponse.ok) { + const data = await qrResponse.json(); setQrCodes(data); - + // Calculate real stats const totalScans = data.reduce((sum: number, qr: QRCodeData) => sum + (qr.scans || 0), 0); const activeQRCodes = data.filter((qr: QRCodeData) => qr.status === 'ACTIVE').length; const conversionRate = activeQRCodes > 0 ? Math.round((totalScans / (activeQRCodes * 100)) * 100) : 0; - + setStats({ totalScans, activeQRCodes, @@ -143,6 +147,15 @@ export default function DashboardPage() { conversionRate: 0, }); } + + // Fetch user plan + if (session?.user?.email) { + const userResponse = await fetch('/api/user/plan'); + if (userResponse.ok) { + const userData = await userResponse.json(); + setUserPlan(userData.plan || 'FREE'); + } + } } catch (error) { console.error('Error fetching data:', error); setQrCodes([]); @@ -157,7 +170,7 @@ export default function DashboardPage() { }; fetchData(); - }, []); + }, [session]); const handleEdit = (id: string) => { console.log('Edit QR:', id); @@ -175,12 +188,48 @@ export default function DashboardPage() { console.log('Delete QR:', id); }; + const getPlanBadgeColor = (plan: string) => { + switch (plan) { + case 'PRO': + return 'info'; + case 'BUSINESS': + return 'warning'; + default: + return 'default'; + } + }; + + const getPlanEmoji = (plan: string) => { + switch (plan) { + case 'FREE': + return '🟢'; + case 'PRO': + return '🔵'; + case 'BUSINESS': + return '🟣'; + default: + return '⚪'; + } + }; + return (
- {/* Header */} -
-

{t('dashboard.title')}

-

{t('dashboard.subtitle')}

+ {/* Header with Plan Badge */} +
+
+

{t('dashboard.title')}

+

{t('dashboard.subtitle')}

+
+
+ + {getPlanEmoji(userPlan)} {userPlan} Plan + + {userPlan === 'FREE' && ( + + + + )} +
{/* Stats Grid */} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index a8b741b..13c97d3 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -45,6 +45,15 @@ export default function AppLayout({ ), }, + { + name: 'Pricing', + href: '/pricing', + icon: ( + + + + ), + }, ]; return ( diff --git a/src/app/(app)/pricing/page.tsx b/src/app/(app)/pricing/page.tsx new file mode 100644 index 0000000..697cf10 --- /dev/null +++ b/src/app/(app)/pricing/page.tsx @@ -0,0 +1,272 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { showToast } from '@/components/ui/Toast'; + +export default function PricingPage() { + const router = useRouter(); + const { data: session } = useSession(); + const [loading, setLoading] = useState(null); + const [billingInterval, setBillingInterval] = useState<'monthly' | 'yearly'>('monthly'); + + const plans = [ + { + id: 'FREE', + name: 'Free / Starter', + icon: '🟢', + price: 0, + priceYearly: 0, + description: 'Privatnutzer & Testkunden', + features: [ + '3 dynamische QR-Codes', + 'Basis-Tracking (Scans + Standort)', + 'Einfache Designs', + 'Unbegrenzte statische QR-Codes', + ], + cta: 'Get Started', + popular: false, + priceIdMonthly: null, + priceIdYearly: null, + }, + { + id: 'PRO', + name: 'Pro', + icon: '🔵', + price: 9, + priceYearly: 90, + description: 'Selbstständige / kleine Firmen', + features: [ + '50 QR-Codes', + 'Branding (Logo, Farben)', + 'Detaillierte Analytics', + 'CSV-Export', + 'Passwortschutz', + ], + cta: 'Upgrade to Pro', + popular: true, + priceIdMonthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY, + priceIdYearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY, + }, + { + id: 'BUSINESS', + name: 'Business', + icon: '🟣', + price: 29, + priceYearly: 290, + description: 'Agenturen / Startups', + features: [ + '500 QR-Codes', + 'Team-Zugänge (bis 3 User)', + 'API-Zugang', + 'Benutzerdefinierte Domains', + 'White-Label', + 'Prioritäts-Support', + ], + cta: 'Upgrade to Business', + popular: false, + priceIdMonthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_BUSINESS_MONTHLY, + priceIdYearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_BUSINESS_YEARLY, + }, + ]; + + const handleSubscribe = async (planId: string, priceId: string | null | undefined) => { + if (!session) { + router.push('/login?redirect=/pricing'); + return; + } + + if (planId === 'FREE') { + showToast('Sie nutzen bereits den kostenlosen Plan!', 'info'); + return; + } + + if (!priceId) { + showToast('Preisdetails nicht verfügbar', 'error'); + return; + } + + try { + setLoading(planId); + + const response = await fetch('/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + priceId, + plan: planId, + }), + }); + + const data = await response.json(); + + if (response.ok && data.url) { + window.location.href = data.url; + } else { + showToast(data.error || 'Fehler beim Erstellen der Checkout-Session', 'error'); + } + } catch (error) { + console.error('Error creating checkout session:', error); + showToast('Ein Fehler ist aufgetreten', 'error'); + } finally { + setLoading(null); + } + }; + + return ( +
+
+

+ Wählen Sie Ihren Plan +

+

+ Starten Sie kostenlos. Upgraden Sie jederzeit. +

+ + {/* Billing Toggle */} +
+ + +
+
+ +
+ {plans.map((plan) => { + const price = billingInterval === 'yearly' ? plan.priceYearly : plan.price; + const priceId = + billingInterval === 'yearly' ? plan.priceIdYearly : plan.priceIdMonthly; + const isLoading = loading === plan.id; + + return ( + + {plan.popular && ( +
+ + Beliebteste Wahl + +
+ )} + + +
{plan.icon}
+ {plan.name} +

{plan.description}

+ +
+
+ {price}€ + + /{billingInterval === 'yearly' ? 'Jahr' : 'Monat'} + +
+ {billingInterval === 'yearly' && plan.price > 0 && ( +

+ {(price / 12).toFixed(2)}€ pro Monat +

+ )} +
+
+ + +
    + {plan.features.map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ + +
+
+ ); + })} +
+ + {/* FAQ Section */} +
+

Häufige Fragen

+
+ + +

Kann ich jederzeit kündigen?

+

+ Ja, Sie können Ihr Abo jederzeit kündigen. Es läuft dann bis zum Ende des + bezahlten Zeitraums weiter. +

+
+
+ + + +

Welche Zahlungsmethoden akzeptieren Sie?

+

+ Wir akzeptieren alle gängigen Kreditkarten und SEPA-Lastschrift über Stripe. +

+
+
+ + + +

Was passiert mit meinen QR-Codes bei Downgrade?

+

+ Ihre QR-Codes bleiben erhalten, Sie können nur keine neuen mehr erstellen, wenn das Limit erreicht ist. +

+
+
+
+
+
+ ); +} diff --git a/src/app/api/stripe/checkout/route.ts b/src/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..80c0d47 --- /dev/null +++ b/src/app/api/stripe/checkout/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { stripe } from '@/lib/stripe'; +import { db } from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { priceId, plan } = await request.json(); + + if (!priceId || !plan) { + return NextResponse.json( + { error: 'Missing priceId or plan' }, + { status: 400 } + ); + } + + // Get user from database + const user = await db.user.findUnique({ + where: { email: session.user.email }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Create or get Stripe customer + let customerId = user.stripeCustomerId; + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { + userId: user.id, + }, + }); + + customerId = customer.id; + + // Update user with Stripe customer ID + await db.user.update({ + where: { id: user.id }, + data: { stripeCustomerId: customerId }, + }); + } + + // Create Stripe Checkout Session + const checkoutSession = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + payment_method_types: ['card', 'sepa_debit'], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`, + cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`, + metadata: { + userId: user.id, + plan, + }, + }); + + return NextResponse.json({ url: checkoutSession.url }); + } catch (error) { + console.error('Error creating checkout session:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..f710369 --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import { stripe } from '@/lib/stripe'; +import { db } from '@/lib/db'; +import Stripe from 'stripe'; + +export async function POST(request: NextRequest) { + const body = await request.text(); + const signature = headers().get('stripe-signature'); + + if (!signature) { + return NextResponse.json( + { error: 'No signature' }, + { status: 400 } + ); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ); + } catch (error) { + console.error('Webhook signature verification failed:', error); + 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; + + if (session.mode === 'subscription') { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + + await db.user.update({ + where: { + stripeCustomerId: session.customer as string, + }, + data: { + stripeSubscriptionId: subscription.id, + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + plan: (session.metadata?.plan || 'FREE') as any, + }, + }); + } + break; + } + + case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription; + + await db.user.update({ + where: { + stripeSubscriptionId: subscription.id, + }, + data: { + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }); + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + + await db.user.update({ + where: { + stripeSubscriptionId: subscription.id, + }, + data: { + stripeSubscriptionId: null, + stripePriceId: null, + stripeCurrentPeriodEnd: null, + plan: 'FREE', + }, + }); + break; + } + } + + return NextResponse.json({ received: true }); + } catch (error) { + console.error('Error processing webhook:', error); + return NextResponse.json( + { error: 'Webhook processing failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/user/plan/route.ts b/src/app/api/user/plan/route.ts new file mode 100644 index 0000000..c0f9dab --- /dev/null +++ b/src/app/api/user/plan/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await db.user.findUnique({ + where: { email: session.user.email }, + select: { + plan: true, + stripeCurrentPeriodEnd: true, + stripePriceId: true, + }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ + plan: user.plan, + currentPeriodEnd: user.stripeCurrentPeriodEnd, + priceId: user.stripePriceId, + }); + } catch (error) { + console.error('Error fetching user plan:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..31c61c0 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,76 @@ +import Stripe from 'stripe'; + +if (!process.env.STRIPE_SECRET_KEY) { + throw new Error('STRIPE_SECRET_KEY is not set'); +} + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2024-11-20.acacia', + typescript: true, +}); + +export const STRIPE_PLANS = { + FREE: { + name: 'Free / Starter', + price: 0, + currency: 'EUR', + interval: 'month', + features: [ + '3 dynamische QR-Codes', + 'Basis-Tracking (Scans + Standort)', + 'Einfache Designs', + 'Unbegrenzte statische QR-Codes', + ], + limits: { + dynamicQRCodes: 3, + staticQRCodes: -1, // unlimited + teamMembers: 1, + }, + priceId: null, // No Stripe price for free plan + }, + PRO: { + name: 'Pro', + price: 9, + priceYearly: 90, + currency: 'EUR', + interval: 'month', + features: [ + '50 QR-Codes', + 'Branding (Logo, Farben)', + 'Detaillierte Analytics (Datum, Gerät, Stadt)', + 'CSV-Export', + 'Passwortschutz', + ], + limits: { + dynamicQRCodes: 50, + staticQRCodes: -1, + teamMembers: 1, + }, + priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY, + priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY, + }, + BUSINESS: { + name: 'Business', + price: 29, + priceYearly: 290, + currency: 'EUR', + interval: 'month', + features: [ + '500 QR-Codes', + 'Team-Zugänge (bis 3 User)', + 'API-Zugang', + 'Benutzerdefinierte Domains', + 'White-Label', + 'Prioritäts-Support', + ], + limits: { + dynamicQRCodes: 500, + staticQRCodes: -1, + teamMembers: 3, + }, + priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY, + priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY, + }, +} as const; + +export type PlanType = keyof typeof STRIPE_PLANS;