Add Stripe subscription integration with pricing plans

This commit is contained in:
Timo Knuth 2025-10-14 16:58:11 +02:00
parent 157e53af83
commit bccf771ffc
8 changed files with 654 additions and 12 deletions

View File

@ -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

View File

@ -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<QRCodeData[]>([]);
const [loading, setLoading] = useState(true);
const [userPlan, setUserPlan] = useState<string>('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 (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
<p className="text-gray-600 mt-2">{t('dashboard.subtitle')}</p>
{/* Header with Plan Badge */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
<p className="text-gray-600 mt-2">{t('dashboard.subtitle')}</p>
</div>
<div className="flex items-center space-x-3">
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
{getPlanEmoji(userPlan)} {userPlan} Plan
</Badge>
{userPlan === 'FREE' && (
<Link href="/pricing">
<Button variant="primary">Upgrade</Button>
</Link>
)}
</div>
</div>
{/* Stats Grid */}

View File

@ -45,6 +45,15 @@ export default function AppLayout({
</svg>
),
},
{
name: 'Pricing',
href: '/pricing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
];
return (

View File

@ -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<string | null>(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 (
<div className="container mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
Wählen Sie Ihren Plan
</h1>
<p className="text-xl text-gray-600 mb-8">
Starten Sie kostenlos. Upgraden Sie jederzeit.
</p>
{/* Billing Toggle */}
<div className="inline-flex items-center space-x-4 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => setBillingInterval('monthly')}
className={`px-6 py-2 rounded-md font-medium transition-colors ${
billingInterval === 'monthly'
? 'bg-white text-gray-900 shadow'
: 'text-gray-600'
}`}
>
Monatlich
</button>
<button
onClick={() => setBillingInterval('yearly')}
className={`px-6 py-2 rounded-md font-medium transition-colors ${
billingInterval === 'yearly'
? 'bg-white text-gray-900 shadow'
: 'text-gray-600'
}`}
>
Jährlich
<Badge variant="success" className="ml-2">
Spare 17%
</Badge>
</button>
</div>
</div>
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{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 (
<Card
key={plan.id}
className={`relative ${
plan.popular
? 'border-primary-500 border-2 shadow-xl scale-105'
: 'border-gray-200'
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="info" className="px-4 py-1 text-sm">
Beliebteste Wahl
</Badge>
</div>
)}
<CardHeader className="text-center pb-6">
<div className="text-4xl mb-4">{plan.icon}</div>
<CardTitle className="text-2xl mb-2">{plan.name}</CardTitle>
<p className="text-sm text-gray-600">{plan.description}</p>
<div className="mt-6">
<div className="flex items-baseline justify-center">
<span className="text-5xl font-bold">{price}</span>
<span className="text-gray-600 ml-2">
/{billingInterval === 'yearly' ? 'Jahr' : 'Monat'}
</span>
</div>
{billingInterval === 'yearly' && plan.price > 0 && (
<p className="text-sm text-gray-500 mt-2">
{(price / 12).toFixed(2)} pro Monat
</p>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<ul className="space-y-3">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
size="lg"
onClick={() => handleSubscribe(plan.id, priceId)}
disabled={isLoading}
>
{isLoading ? 'Lädt...' : plan.cta}
</Button>
</CardContent>
</Card>
);
})}
</div>
{/* FAQ Section */}
<div className="mt-20 max-w-3xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-8">Häufige Fragen</h2>
<div className="space-y-4">
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">Kann ich jederzeit kündigen?</h3>
<p className="text-gray-600">
Ja, Sie können Ihr Abo jederzeit kündigen. Es läuft dann bis zum Ende des
bezahlten Zeitraums weiter.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">Welche Zahlungsmethoden akzeptieren Sie?</h3>
<p className="text-gray-600">
Wir akzeptieren alle gängigen Kreditkarten und SEPA-Lastschrift über Stripe.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">Was passiert mit meinen QR-Codes bei Downgrade?</h3>
<p className="text-gray-600">
Ihre QR-Codes bleiben erhalten, Sie können nur keine neuen mehr erstellen, wenn das Limit erreicht ist.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

76
src/lib/stripe.ts Normal file
View File

@ -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;