diff --git a/backend/.env.example b/backend/.env.example index 9aef50b..e2f59d0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,4 +6,8 @@ SPOTIFY_CLIENT_ID=your-spotify-client-id SPOTIFY_CLIENT_SECRET=your-spotify-client-secret SPOTIFY_REDIRECT_URI=http://localhost:5173/auth/spotify/callback 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 diff --git a/backend/app/api/endpoints/billing.py b/backend/app/api/endpoints/billing.py new file mode 100644 index 0000000..9f8d653 --- /dev/null +++ b/backend/app/api/endpoints/billing.py @@ -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, + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 432fb68..584b76a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -22,6 +22,12 @@ class Settings(BaseSettings): # Claude API ANTHROPIC_API_KEY: str = "" + # Stripe + STRIPE_SECRET_KEY: str = "" + STRIPE_PUBLISHABLE_KEY: str = "" + STRIPE_PRICE_ID: str = "" + STRIPE_WEBHOOK_SECRET: str = "" + # Frontend FRONTEND_URL: str = "http://localhost:5173" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1985b09..d7086b9 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -18,6 +18,10 @@ class User(Base): 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_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) spotify_access_token: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 423f0b5..57712a4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Playlists from './pages/Playlists' import PlaylistDetail from './pages/PlaylistDetail' import Discover from './pages/Discover' import Recommendations from './pages/Recommendations' +import Billing from './pages/Billing' function RootRedirect() { const { user, loading } = useAuth() @@ -82,6 +83,16 @@ function AppRoutes() { } /> + + + + + + } + /> } /> ) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b324011..2b213a3 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' 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' const navItems = [ @@ -8,6 +8,7 @@ const navItems = [ { path: '/playlists', label: 'Playlists', icon: ListMusic }, { path: '/discover', label: 'Discover', icon: Compass }, { path: '/saved', label: 'Saved', icon: Heart }, + { path: '/billing', label: 'Pro', icon: Crown }, ] export default function Layout({ children }: { children: React.ReactNode }) { diff --git a/frontend/src/pages/Billing.tsx b/frontend/src/pages/Billing.tsx new file mode 100644 index 0000000..b93e22c --- /dev/null +++ b/frontend/src/pages/Billing.tsx @@ -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(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 ( +
+ +
+ ) + } + + const isPro = billing?.is_pro || false + + return ( +
+
+

Billing

+

Manage your subscription

+
+ + {success && ( +
+

Welcome to Vynl Pro! Your subscription is now active.

+
+ )} + + {canceled && ( +
+

Checkout was canceled. No charges were made.

+
+ )} + + {/* Current Plan */} +
+
+
+
+
+ +
+
+

+ {isPro ? 'Vynl Pro' : 'Free Plan'} +

+

+ {isPro ? '$4.99/month' : '10 recommendations/day, 1 playlist'} +

+
+
+ {isPro && billing?.subscription_status && ( + + {billing.subscription_status === 'active' ? 'Active' : billing.subscription_status} + + )} +
+ {isPro && billing?.current_period_end && ( +

+ Next billing date: {new Date(billing.current_period_end * 1000).toLocaleDateString()} +

+ )} +
+ + {/* Pro Features */} +
+

+ {isPro ? 'Your Pro features' : 'Upgrade to Pro'} +

+
+ {proFeatures.map((feature) => { + const Icon = feature.icon + return ( +
+
+ +
+ {feature.text} + {isPro && } +
+ ) + })} +
+
+ + {/* Action */} +
+ {isPro ? ( + + ) : ( + + )} +
+
+
+ ) +}