Add Stripe subscription billing integration
- Add stripe_customer_id and stripe_subscription_id fields to User model - Add Stripe config settings (secret key, publishable key, price ID, webhook secret) - Create billing API endpoints: checkout session, webhook handler, portal, status - Add frontend Billing page with upgrade/manage subscription UI - Add billing route and Pro nav link - Add stripe dependency to requirements
This commit is contained in:
@@ -6,4 +6,8 @@ SPOTIFY_CLIENT_ID=your-spotify-client-id
|
|||||||
SPOTIFY_CLIENT_SECRET=your-spotify-client-secret
|
SPOTIFY_CLIENT_SECRET=your-spotify-client-secret
|
||||||
SPOTIFY_REDIRECT_URI=http://localhost:5173/auth/spotify/callback
|
SPOTIFY_REDIRECT_URI=http://localhost:5173/auth/spotify/callback
|
||||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||||
|
STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key
|
||||||
|
STRIPE_PRICE_ID=price_your-pro-plan-price-id
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-signing-secret
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|||||||
152
backend/app/api/endpoints/billing.py
Normal file
152
backend/app/api/endpoints/billing.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import stripe
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/billing", tags=["billing"])
|
||||||
|
|
||||||
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create-checkout")
|
||||||
|
async def create_checkout(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if user.is_pro:
|
||||||
|
raise HTTPException(status_code=400, detail="Already subscribed to Pro")
|
||||||
|
|
||||||
|
# Create Stripe customer if needed
|
||||||
|
if not user.stripe_customer_id:
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
metadata={"vynl_user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
user.stripe_customer_id = customer.id
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
session = stripe.checkout.Session.create(
|
||||||
|
customer=user.stripe_customer_id,
|
||||||
|
mode="subscription",
|
||||||
|
line_items=[{"price": settings.STRIPE_PRICE_ID, "quantity": 1}],
|
||||||
|
success_url=f"{settings.FRONTEND_URL}/billing?success=true",
|
||||||
|
cancel_url=f"{settings.FRONTEND_URL}/billing?canceled=true",
|
||||||
|
metadata={"vynl_user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"url": session.url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def stripe_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
payload = await request.body()
|
||||||
|
sig_header = request.headers.get("stripe-signature", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = stripe.Webhook.construct_event(
|
||||||
|
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||||
|
except stripe.SignatureVerificationError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||||
|
|
||||||
|
event_type = event["type"]
|
||||||
|
data = event["data"]["object"]
|
||||||
|
|
||||||
|
if event_type == "checkout.session.completed":
|
||||||
|
customer_id = data.get("customer")
|
||||||
|
subscription_id = data.get("subscription")
|
||||||
|
if customer_id:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.stripe_customer_id == customer_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
user.is_pro = True
|
||||||
|
user.stripe_subscription_id = subscription_id
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
elif event_type == "customer.subscription.deleted":
|
||||||
|
customer_id = data.get("customer")
|
||||||
|
if customer_id:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.stripe_customer_id == customer_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
user.is_pro = False
|
||||||
|
user.stripe_subscription_id = None
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
elif event_type == "customer.subscription.updated":
|
||||||
|
customer_id = data.get("customer")
|
||||||
|
sub_status = data.get("status")
|
||||||
|
if customer_id:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.stripe_customer_id == customer_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
user.is_pro = sub_status in ("active", "trialing")
|
||||||
|
user.stripe_subscription_id = data.get("id")
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
elif event_type == "invoice.payment_failed":
|
||||||
|
customer_id = data.get("customer")
|
||||||
|
if customer_id:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.stripe_customer_id == customer_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
user.is_pro = False
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/portal")
|
||||||
|
async def create_portal(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user.stripe_customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No billing account found")
|
||||||
|
|
||||||
|
session = stripe.billing_portal.Session.create(
|
||||||
|
customer=user.stripe_customer_id,
|
||||||
|
return_url=f"{settings.FRONTEND_URL}/billing",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"url": session.url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def billing_status(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
subscription_status = None
|
||||||
|
current_period_end = None
|
||||||
|
|
||||||
|
if user.stripe_subscription_id:
|
||||||
|
try:
|
||||||
|
sub = stripe.Subscription.retrieve(user.stripe_subscription_id)
|
||||||
|
subscription_status = sub.status
|
||||||
|
current_period_end = sub.current_period_end
|
||||||
|
except stripe.StripeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_pro": user.is_pro,
|
||||||
|
"subscription_status": subscription_status,
|
||||||
|
"current_period_end": current_period_end,
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ class Settings(BaseSettings):
|
|||||||
# Claude API
|
# Claude API
|
||||||
ANTHROPIC_API_KEY: str = ""
|
ANTHROPIC_API_KEY: str = ""
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY: str = ""
|
||||||
|
STRIPE_PUBLISHABLE_KEY: str = ""
|
||||||
|
STRIPE_PRICE_ID: str = ""
|
||||||
|
STRIPE_WEBHOOK_SECRET: str = ""
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_URL: str = "http://localhost:5173"
|
FRONTEND_URL: str = "http://localhost:5173"
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class User(Base):
|
|||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||||||
|
stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
# Spotify OAuth
|
# Spotify OAuth
|
||||||
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||||||
spotify_access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
spotify_access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Playlists from './pages/Playlists'
|
|||||||
import PlaylistDetail from './pages/PlaylistDetail'
|
import PlaylistDetail from './pages/PlaylistDetail'
|
||||||
import Discover from './pages/Discover'
|
import Discover from './pages/Discover'
|
||||||
import Recommendations from './pages/Recommendations'
|
import Recommendations from './pages/Recommendations'
|
||||||
|
import Billing from './pages/Billing'
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -82,6 +83,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/billing"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Billing />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Menu, X, LogOut, User } from 'lucide-react'
|
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Crown, Menu, X, LogOut, User } from 'lucide-react'
|
||||||
import { useAuth } from '../lib/auth'
|
import { useAuth } from '../lib/auth'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -8,6 +8,7 @@ const navItems = [
|
|||||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||||
{ path: '/discover', label: 'Discover', icon: Compass },
|
{ path: '/discover', label: 'Discover', icon: Compass },
|
||||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||||
|
{ path: '/billing', label: 'Pro', icon: Crown },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
181
frontend/src/pages/Billing.tsx
Normal file
181
frontend/src/pages/Billing.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { Crown, Check, Loader2, ExternalLink, Sparkles, Music, Infinity, Download } from 'lucide-react'
|
||||||
|
import { useAuth } from '../lib/auth'
|
||||||
|
import { createCheckout, createBillingPortal, getBillingStatus } from '../lib/api'
|
||||||
|
|
||||||
|
interface BillingInfo {
|
||||||
|
is_pro: boolean
|
||||||
|
subscription_status: string | null
|
||||||
|
current_period_end: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const proFeatures = [
|
||||||
|
{ icon: Infinity, text: 'Unlimited recommendations per day' },
|
||||||
|
{ icon: Music, text: 'Unlimited playlist imports' },
|
||||||
|
{ icon: Sparkles, text: 'Advanced taste analysis' },
|
||||||
|
{ icon: Download, text: 'Export playlists to any platform' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Billing() {
|
||||||
|
const { user, refreshUser } = useAuth()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [billing, setBilling] = useState<BillingInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [checkoutLoading, setCheckoutLoading] = useState(false)
|
||||||
|
const [portalLoading, setPortalLoading] = useState(false)
|
||||||
|
|
||||||
|
const success = searchParams.get('success') === 'true'
|
||||||
|
const canceled = searchParams.get('canceled') === 'true'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBillingStatus()
|
||||||
|
.then(setBilling)
|
||||||
|
.catch(() => setBilling({ is_pro: user?.is_pro || false, subscription_status: null, current_period_end: null }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [user?.is_pro])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (success && refreshUser) {
|
||||||
|
refreshUser()
|
||||||
|
}
|
||||||
|
}, [success, refreshUser])
|
||||||
|
|
||||||
|
const handleUpgrade = async () => {
|
||||||
|
setCheckoutLoading(true)
|
||||||
|
try {
|
||||||
|
const { url } = await createCheckout()
|
||||||
|
window.location.href = url
|
||||||
|
} catch {
|
||||||
|
setCheckoutLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManage = async () => {
|
||||||
|
setPortalLoading(true)
|
||||||
|
try {
|
||||||
|
const { url } = await createBillingPortal()
|
||||||
|
window.location.href = url
|
||||||
|
} catch {
|
||||||
|
setPortalLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPro = billing?.is_pro || false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-charcoal">Billing</h1>
|
||||||
|
<p className="text-charcoal-muted mt-1">Manage your subscription</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
|
||||||
|
<p className="text-green-800 font-medium">Welcome to Vynl Pro! Your subscription is now active.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canceled && (
|
||||||
|
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
|
||||||
|
<p className="text-amber-800 font-medium">Checkout was canceled. No charges were made.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Plan */}
|
||||||
|
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-purple-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${isPro ? 'bg-purple' : 'bg-purple-50'}`}>
|
||||||
|
<Crown className={`w-5 h-5 ${isPro ? 'text-white' : 'text-purple'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-charcoal">
|
||||||
|
{isPro ? 'Vynl Pro' : 'Free Plan'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-charcoal-muted">
|
||||||
|
{isPro ? '$4.99/month' : '10 recommendations/day, 1 playlist'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isPro && billing?.subscription_status && (
|
||||||
|
<span className="px-3 py-1 bg-green-50 text-green-700 text-sm font-medium rounded-full">
|
||||||
|
{billing.subscription_status === 'active' ? 'Active' : billing.subscription_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isPro && billing?.current_period_end && (
|
||||||
|
<p className="text-sm text-charcoal-muted mt-3">
|
||||||
|
Next billing date: {new Date(billing.current_period_end * 1000).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pro Features */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-charcoal uppercase tracking-wider mb-4">
|
||||||
|
{isPro ? 'Your Pro features' : 'Upgrade to Pro'}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{proFeatures.map((feature) => {
|
||||||
|
const Icon = feature.icon
|
||||||
|
return (
|
||||||
|
<div key={feature.text} className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Icon className="w-4 h-4 text-purple" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-charcoal">{feature.text}</span>
|
||||||
|
{isPro && <Check className="w-4 h-4 text-green-500 ml-auto" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<div className="p-6 bg-purple-50/50 border-t border-purple-100">
|
||||||
|
{isPro ? (
|
||||||
|
<button
|
||||||
|
onClick={handleManage}
|
||||||
|
disabled={portalLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-white border border-purple-200 text-purple font-semibold rounded-xl hover:bg-purple-50 transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{portalLoading ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Manage Subscription
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
disabled={checkoutLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-dark transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{checkoutLoading ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Crown className="w-4 h-4" />
|
||||||
|
Upgrade to Pro — $4.99/mo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user