Add Stripe subscription system and n8n chat webhook

- Add Stripe SDK and subscription management
- Create checkout, webhook, and portal API routes
- Add pricing page with plan cards
- Create subscriptions table in Supabase
- Update database types for subscriptions
- Configure n8n webhook for AI chat responses

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-13 23:24:22 +01:00
parent 53dbb0ed97
commit 1ec6bd89c8
9 changed files with 473 additions and 18 deletions

View File

@@ -0,0 +1,119 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Check, Loader2 } from 'lucide-react'
import { PLANS, type PlanKey } from '@/lib/stripe/config'
export default function PricingPage() {
const router = useRouter()
const [loading, setLoading] = useState<PlanKey | null>(null)
const handleSubscribe = async (plan: PlanKey) => {
if (plan === 'free') {
router.push('/auth/signup')
return
}
setLoading(plan)
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan }),
})
const data = await res.json()
if (data.error === 'Unauthorized') {
router.push('/auth/login?redirect=/pricing')
return
}
if (data.url) {
window.location.href = data.url
}
} catch (error) {
console.error('Checkout error:', error)
} finally {
setLoading(null)
}
}
return (
<div className="min-h-screen bg-gradient-to-b from-zinc-50 to-white dark:from-zinc-950 dark:to-zinc-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
<div className="text-center mb-16">
<h1 className="text-4xl font-bold tracking-tight mb-4">
Simple, transparent pricing
</h1>
<p className="text-xl text-zinc-600 dark:text-zinc-400 max-w-2xl mx-auto">
Start free, upgrade when you need more. No hidden fees.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{(Object.entries(PLANS) as [PlanKey, typeof PLANS[PlanKey]][]).map(([key, plan]) => (
<Card
key={key}
className={`relative ${
key === 'pro' ? 'border-brand shadow-lg scale-105' : ''
}`}
>
{key === 'pro' && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2" variant="brand">
Most Popular
</Badge>
)}
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>
<span className="text-4xl font-bold text-foreground">
${plan.price}
</span>
{plan.price > 0 && (
<span className="text-muted-foreground">/month</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-5 w-5 text-brand shrink-0 mt-0.5" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={key === 'pro' ? 'brand' : 'outline'}
onClick={() => handleSubscribe(key)}
disabled={loading !== null}
>
{loading === key ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : key === 'free' ? (
'Get Started'
) : (
'Subscribe'
)}
</Button>
</CardFooter>
</Card>
))}
</div>
<div className="mt-16 text-center text-sm text-muted-foreground">
<p>All plans include SSL encryption and 99.9% uptime SLA.</p>
<p className="mt-2">Questions? Contact support@mylder.io</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe, PLANS, type PlanKey } from '@/lib/stripe/config'
import type { Database } from '@/types/database'
type Subscription = Database['public']['Tables']['subscriptions']['Row']
export async function POST(request: Request) {
try {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { plan } = await request.json() as { plan: PlanKey }
const planConfig = PLANS[plan]
if (!planConfig || !planConfig.priceId) {
return NextResponse.json({ error: 'Invalid plan' }, { status: 400 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: subscription } = await (supabase as any)
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
let customerId = subscription?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
})
customerId = customer.id
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (supabase as any)
.from('subscriptions')
.upsert({
user_id: user.id,
stripe_customer_id: customerId,
plan: 'free',
status: 'active',
})
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: planConfig.priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?checkout=cancelled`,
metadata: { userId: user.id, plan },
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('Checkout error:', error)
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 })
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe } from '@/lib/stripe/config'
import type { Database } from '@/types/database'
type Subscription = Database['public']['Tables']['subscriptions']['Row']
export async function POST() {
try {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: subscription } = await (supabase as any)
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
if (!subscription?.stripe_customer_id) {
return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
}
const session = await stripe.billingPortal.sessions.create({
customer: subscription.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/settings`,
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('Portal error:', error)
return NextResponse.json({ error: 'Failed to create portal session' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/config'
import { createClient } from '@supabase/supabase-js'
import type Stripe from 'stripe'
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function POST(request: Request) {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
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 userId = session.metadata?.userId
const plan = session.metadata?.plan
if (userId && session.subscription) {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
await supabaseAdmin
.from('subscriptions')
.upsert({
user_id: userId,
stripe_customer_id: session.customer as string,
stripe_subscription_id: subscription.id,
plan: plan || 'pro',
status: subscription.status,
current_period_start: new Date((subscription as unknown as { current_period_start: number }).current_period_start * 1000).toISOString(),
current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
})
}
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
const { data: existingSub } = await supabaseAdmin
.from('subscriptions')
.select('user_id')
.eq('stripe_customer_id', customerId)
.single()
if (existingSub) {
const sub = subscription as unknown as { current_period_start: number; current_period_end: number }
await supabaseAdmin
.from('subscriptions')
.update({
status: subscription.status,
plan: subscription.status === 'canceled' ? 'free' : undefined,
current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
updated_at: new Date().toISOString(),
})
.eq('stripe_customer_id', customerId)
}
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
const customerId = invoice.customer as string
await supabaseAdmin
.from('subscriptions')
.update({ status: 'past_due', updated_at: new Date().toISOString() })
.eq('stripe_customer_id', customerId)
break
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook processing error:', error)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
}

12
src/lib/stripe/client.ts Normal file
View File

@@ -0,0 +1,12 @@
'use client'
import { loadStripe, type Stripe } from '@stripe/stripe-js'
let stripePromise: Promise<Stripe | null> | null = null
export function getStripe() {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
}
return stripePromise
}

57
src/lib/stripe/config.ts Normal file
View File

@@ -0,0 +1,57 @@
import Stripe from 'stripe'
const stripeSecretKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder'
export const stripe = new Stripe(stripeSecretKey)
export const PLANS = {
free: {
name: 'Free',
price: 0,
priceId: null,
features: [
'1 project',
'50 AI messages/month',
'Basic templates',
'Community support',
],
limits: {
projects: 1,
messages: 50,
},
},
pro: {
name: 'Pro',
price: 19,
priceId: process.env.STRIPE_PRO_PRICE_ID,
features: [
'Unlimited projects',
'2,000 AI messages/month',
'All templates',
'Priority support',
'API access',
],
limits: {
projects: -1,
messages: 2000,
},
},
team: {
name: 'Team',
price: 49,
priceId: process.env.STRIPE_TEAM_PRICE_ID,
features: [
'Everything in Pro',
'10,000 AI messages/month',
'Team collaboration',
'Admin dashboard',
'Custom integrations',
],
limits: {
projects: -1,
messages: 10000,
},
},
} as const
export type PlanKey = keyof typeof PLANS

View File

@@ -233,6 +233,47 @@ export interface Database {
updated_at?: string
}
}
subscriptions: {
Row: {
id: string
user_id: string
stripe_customer_id: string | null
stripe_subscription_id: string | null
plan: 'free' | 'pro' | 'team'
status: 'active' | 'canceled' | 'past_due' | 'incomplete'
current_period_start: string | null
current_period_end: string | null
cancel_at_period_end: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
user_id: string
stripe_customer_id?: string | null
stripe_subscription_id?: string | null
plan?: 'free' | 'pro' | 'team'
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
current_period_start?: string | null
current_period_end?: string | null
cancel_at_period_end?: boolean
created_at?: string
updated_at?: string
}
Update: {
id?: string
user_id?: string
stripe_customer_id?: string | null
stripe_subscription_id?: string | null
plan?: 'free' | 'pro' | 'team'
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
current_period_start?: string | null
current_period_end?: string | null
cancel_at_period_end?: boolean
created_at?: string
updated_at?: string
}
}
}
Views: {
[_ in never]: never