diff --git a/flow.toml b/flow.toml index a95f64c3..e95947af 100644 --- a/flow.toml +++ b/flow.toml @@ -1606,12 +1606,14 @@ set -euo pipefail echo "=== Stripe Payments Setup ===" echo "" -echo "This will configure Stripe for production payments." -echo "You'll need:" +echo "Creator Economy Model - creators set custom prices for:" +echo " - Subscription tiers (access to stream archives)" +echo " - One-time products (digital goods)" +echo "" +echo "You need:" echo " - Stripe account (https://dashboard.stripe.com)" echo " - Secret key (sk_live_... or sk_test_...)" echo " - Webhook signing secret (whsec_...)" -echo " - Price ID for subscription (price_...)" echo "" cd packages/web @@ -1649,13 +1651,6 @@ else MISSING=1 fi -if is_secret_set "STRIPE_ARCHIVE_PRICE_ID"; then - echo " ✓ STRIPE_ARCHIVE_PRICE_ID is set" -else - echo " ✗ STRIPE_ARCHIVE_PRICE_ID is NOT set" - MISSING=1 -fi - if [ "$MISSING" -eq 0 ]; then echo "" echo "All Stripe secrets are configured!" @@ -1666,9 +1661,8 @@ if [ "$MISSING" -eq 0 ]; then echo "=== Stripe Endpoints ===" echo "" echo "Your Stripe integration is ready:" - echo " - Checkout: POST /api/stripe/checkout" - echo " - Billing portal: POST /api/stripe/portal" - echo " - Billing status: GET /api/stripe/billing" + echo " - Creator tiers: /api/creator/tiers" + echo " - Subscribe: POST /api/creator/subscribe" echo " - Webhooks: POST /api/stripe/webhooks" echo "" echo "Webhook URL for Stripe Dashboard:" @@ -1680,14 +1674,10 @@ fi echo "" echo "=== Stripe Dashboard Setup ===" echo "" -echo "Before continuing, ensure you have:" +echo "1. Get your API keys at:" +echo " https://dashboard.stripe.com/apikeys" echo "" -echo "1. Created a Product in Stripe Dashboard:" -echo " https://dashboard.stripe.com/products/create" -echo " - Name: 'Archive Pro' (or similar)" -echo ' - Add a recurring price (e.g., $5/month)' -echo "" -echo "2. Created a Webhook endpoint:" +echo "2. Create a Webhook endpoint:" echo " https://dashboard.stripe.com/webhooks/create" echo " - URL: https://linsa.io/api/stripe/webhooks" echo " - Events to listen for:" @@ -1731,21 +1721,6 @@ if [ -n "$STRIPE_WEBHOOK_SECRET" ]; then echo "✓ STRIPE_WEBHOOK_SECRET set" fi -# STRIPE_ARCHIVE_PRICE_ID -echo "" -echo "=== STRIPE_ARCHIVE_PRICE_ID ===" -echo "Find this in your Product page -> Pricing section (starts with price_...)" -echo "" -if is_secret_set "STRIPE_ARCHIVE_PRICE_ID"; then - read -p "Already set. Enter new value to update (or press Enter to skip): " STRIPE_ARCHIVE_PRICE_ID -else - read -p "Enter STRIPE_ARCHIVE_PRICE_ID: " STRIPE_ARCHIVE_PRICE_ID -fi -if [ -n "$STRIPE_ARCHIVE_PRICE_ID" ]; then - echo "$STRIPE_ARCHIVE_PRICE_ID" | pnpm exec wrangler secret put STRIPE_ARCHIVE_PRICE_ID - echo "✓ STRIPE_ARCHIVE_PRICE_ID set" -fi - echo "" echo "=== Verification ===" echo "" @@ -1761,28 +1736,22 @@ check_final() { check_final "STRIPE_SECRET_KEY" check_final "STRIPE_WEBHOOK_SECRET" -check_final "STRIPE_ARCHIVE_PRICE_ID" echo "" echo "=== Setup Complete ===" echo "" -echo "Your Stripe integration endpoints:" -echo " - Checkout: POST /api/stripe/checkout" -echo " - Portal: POST /api/stripe/portal" -echo " - Billing: GET /api/stripe/billing" -echo " - Webhooks: POST /api/stripe/webhooks" +echo "Creator Economy endpoints:" +echo " - GET/POST /api/creator/tiers - Manage subscription tiers" +echo " - POST /api/creator/subscribe - Subscribe to a creator" +echo " - GET /api/creator/:username/access - Check access to creator content" +echo " - POST /api/stripe/webhooks - Stripe webhooks" echo "" echo "Webhook URL (add to Stripe Dashboard):" echo " https://linsa.io/api/stripe/webhooks" echo "" -echo "To test:" -echo " 1. Visit https://linsa.io/archive" -echo " 2. Click 'Subscribe' to start checkout" -echo " 3. Use test card: 4242 4242 4242 4242" -echo "" echo "Run 'f deploy' to deploy with new secrets." """ -description = "Configure Stripe payments for production: API keys, webhook, and price ID." +description = "Configure Stripe for creator economy: API keys and webhook." dependencies = ["node", "pnpm"] shortcuts = ["stripe", "pay"] @@ -1814,23 +1783,19 @@ MISSING=0 echo "Stripe Secrets:" check "STRIPE_SECRET_KEY" || MISSING=1 check "STRIPE_WEBHOOK_SECRET" || MISSING=1 -check "STRIPE_ARCHIVE_PRICE_ID" || MISSING=1 echo "" -echo "API Endpoints:" -echo " - Checkout: POST https://linsa.io/api/stripe/checkout" -echo " - Portal: POST https://linsa.io/api/stripe/portal" -echo " - Billing: GET https://linsa.io/api/stripe/billing" -echo " - Webhooks: POST https://linsa.io/api/stripe/webhooks" +echo "Creator Economy Endpoints:" +echo " - GET/POST /api/creator/tiers" +echo " - POST /api/creator/subscribe" +echo " - GET /api/creator/:username/access" +echo " - POST /api/stripe/webhooks" echo "" if [ "$MISSING" -eq 1 ]; then echo "⚠ Some secrets missing. Run 'f stripe-setup' to configure." else echo "✓ All Stripe secrets configured!" - echo "" - echo "To test checkout flow:" - echo " curl -X POST https://linsa.io/api/stripe/checkout -H 'Cookie: '" fi """ description = "Check Stripe configuration status." diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index fa272666..3ada8858 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -51,6 +51,8 @@ import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$us import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId' import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing' import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$' +import { Route as ApiCreatorTiersRouteImport } from './routes/api/creator/tiers' +import { Route as ApiCreatorSubscribeRouteImport } from './routes/api/creator/subscribe' import { Route as ApiChatMutationsRouteImport } from './routes/api/chat/mutations' import { Route as ApiChatGuestRouteImport } from './routes/api/chat/guest' import { Route as ApiChatAiRouteImport } from './routes/api/chat/ai' @@ -65,6 +67,7 @@ import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only' import { Route as ApiStreamsUsernameViewersRouteImport } from './routes/api/streams.$username.viewers' import { Route as ApiStreamsUsernameReplaysRouteImport } from './routes/api/streams.$username.replays' +import { Route as ApiCreatorUsernameAccessRouteImport } from './routes/api/creator/$username.access' import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId' import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate' @@ -279,6 +282,16 @@ const ApiFlowgladSplatRoute = ApiFlowgladSplatRouteImport.update({ path: '/api/flowglad/$', getParentRoute: () => rootRouteImport, } as any) +const ApiCreatorTiersRoute = ApiCreatorTiersRouteImport.update({ + id: '/api/creator/tiers', + path: '/api/creator/tiers', + getParentRoute: () => rootRouteImport, +} as any) +const ApiCreatorSubscribeRoute = ApiCreatorSubscribeRouteImport.update({ + id: '/api/creator/subscribe', + path: '/api/creator/subscribe', + getParentRoute: () => rootRouteImport, +} as any) const ApiChatMutationsRoute = ApiChatMutationsRouteImport.update({ id: '/api/chat/mutations', path: '/api/chat/mutations', @@ -352,6 +365,12 @@ const ApiStreamsUsernameReplaysRoute = path: '/replays', getParentRoute: () => ApiStreamsUsernameRoute, } as any) +const ApiCreatorUsernameAccessRoute = + ApiCreatorUsernameAccessRouteImport.update({ + id: '/api/creator/$username/access', + path: '/api/creator/$username/access', + getParentRoute: () => rootRouteImport, + } as any) const ApiCanvasImagesImageIdRoute = ApiCanvasImagesImageIdRouteImport.update({ id: '/$imageId', path: '/$imageId', @@ -402,6 +421,8 @@ export interface FileRoutesByFullPath { '/api/chat/ai': typeof ApiChatAiRoute '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute + '/api/creator/subscribe': typeof ApiCreatorSubscribeRoute + '/api/creator/tiers': typeof ApiCreatorTiersRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute @@ -416,6 +437,7 @@ export interface FileRoutesByFullPath { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute @@ -461,6 +483,8 @@ export interface FileRoutesByTo { '/api/chat/ai': typeof ApiChatAiRoute '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute + '/api/creator/subscribe': typeof ApiCreatorSubscribeRoute + '/api/creator/tiers': typeof ApiCreatorTiersRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute @@ -475,6 +499,7 @@ export interface FileRoutesByTo { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute @@ -522,6 +547,8 @@ export interface FileRoutesById { '/api/chat/ai': typeof ApiChatAiRoute '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute + '/api/creator/subscribe': typeof ApiCreatorSubscribeRoute + '/api/creator/tiers': typeof ApiCreatorTiersRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute @@ -536,6 +563,7 @@ export interface FileRoutesById { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute @@ -584,6 +612,8 @@ export interface FileRouteTypes { | '/api/chat/ai' | '/api/chat/guest' | '/api/chat/mutations' + | '/api/creator/subscribe' + | '/api/creator/tiers' | '/api/flowglad/$' | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' @@ -598,6 +628,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/creator/$username/access' | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' @@ -643,6 +674,8 @@ export interface FileRouteTypes { | '/api/chat/ai' | '/api/chat/guest' | '/api/chat/mutations' + | '/api/creator/subscribe' + | '/api/creator/tiers' | '/api/flowglad/$' | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' @@ -657,6 +690,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/creator/$username/access' | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' @@ -703,6 +737,8 @@ export interface FileRouteTypes { | '/api/chat/ai' | '/api/chat/guest' | '/api/chat/mutations' + | '/api/creator/subscribe' + | '/api/creator/tiers' | '/api/flowglad/$' | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' @@ -717,6 +753,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/creator/$username/access' | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' @@ -757,6 +794,8 @@ export interface RootRouteChildren { ApiChatAiRoute: typeof ApiChatAiRoute ApiChatGuestRoute: typeof ApiChatGuestRoute ApiChatMutationsRoute: typeof ApiChatMutationsRoute + ApiCreatorSubscribeRoute: typeof ApiCreatorSubscribeRoute + ApiCreatorTiersRoute: typeof ApiCreatorTiersRoute ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute ApiSpotifyNowPlayingRoute: typeof ApiSpotifyNowPlayingRoute ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren @@ -767,6 +806,7 @@ export interface RootRouteChildren { DemoApiNamesRoute: typeof DemoApiNamesRoute DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute + ApiCreatorUsernameAccessRoute: typeof ApiCreatorUsernameAccessRoute DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute @@ -1069,6 +1109,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiFlowgladSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/creator/tiers': { + id: '/api/creator/tiers' + path: '/api/creator/tiers' + fullPath: '/api/creator/tiers' + preLoaderRoute: typeof ApiCreatorTiersRouteImport + parentRoute: typeof rootRouteImport + } + '/api/creator/subscribe': { + id: '/api/creator/subscribe' + path: '/api/creator/subscribe' + fullPath: '/api/creator/subscribe' + preLoaderRoute: typeof ApiCreatorSubscribeRouteImport + parentRoute: typeof rootRouteImport + } '/api/chat/mutations': { id: '/api/chat/mutations' path: '/api/chat/mutations' @@ -1167,6 +1221,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStreamsUsernameReplaysRouteImport parentRoute: typeof ApiStreamsUsernameRoute } + '/api/creator/$username/access': { + id: '/api/creator/$username/access' + path: '/api/creator/$username/access' + fullPath: '/api/creator/$username/access' + preLoaderRoute: typeof ApiCreatorUsernameAccessRouteImport + parentRoute: typeof rootRouteImport + } '/api/canvas/images/$imageId': { id: '/api/canvas/images/$imageId' path: '/$imageId' @@ -1350,6 +1411,8 @@ const rootRouteChildren: RootRouteChildren = { ApiChatAiRoute: ApiChatAiRoute, ApiChatGuestRoute: ApiChatGuestRoute, ApiChatMutationsRoute: ApiChatMutationsRoute, + ApiCreatorSubscribeRoute: ApiCreatorSubscribeRoute, + ApiCreatorTiersRoute: ApiCreatorTiersRoute, ApiFlowgladSplatRoute: ApiFlowgladSplatRoute, ApiSpotifyNowPlayingRoute: ApiSpotifyNowPlayingRoute, ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren, @@ -1360,6 +1423,7 @@ const rootRouteChildren: RootRouteChildren = { DemoApiNamesRoute: DemoApiNamesRoute, DemoStartApiRequestRoute: DemoStartApiRequestRoute, DemoStartServerFuncsRoute: DemoStartServerFuncsRoute, + ApiCreatorUsernameAccessRoute: ApiCreatorUsernameAccessRoute, DemoStartSsrDataOnlyRoute: DemoStartSsrDataOnlyRoute, DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute, DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute, diff --git a/packages/web/src/routes/api/creator/$username.access.ts b/packages/web/src/routes/api/creator/$username.access.ts new file mode 100644 index 00000000..35343014 --- /dev/null +++ b/packages/web/src/routes/api/creator/$username.access.ts @@ -0,0 +1,99 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { db } from "@/db/connection" +import { creator_subscriptions, creator_tiers, users } from "@/db/schema" +import { eq, and } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/creator/$username/access")({ + server: { + handlers: { + // GET /api/creator/:username/access - Check if current user has access to creator's content + GET: async ({ request, params }) => { + const { username } = params + + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + const database = db() + + // Find the creator + const creator = await database.query.users.findFirst({ + where: eq(users.username, username), + }) + + if (!creator) { + return json({ error: "Creator not found" }, 404) + } + + // If user is the creator themselves, they have access + if (session?.user?.id === creator.id) { + return json({ + hasAccess: true, + isOwner: true, + subscription: null, + tier: null, + }) + } + + // If not logged in, no access + if (!session?.user?.id) { + return json({ + hasAccess: false, + isOwner: false, + reason: "not_authenticated", + subscription: null, + tier: null, + }) + } + + // Check for active subscription to this creator + const subscription = await database.query.creator_subscriptions.findFirst({ + where: and( + eq(creator_subscriptions.subscriber_id, session.user.id), + eq(creator_subscriptions.creator_id, creator.id), + eq(creator_subscriptions.status, "active") + ), + }) + + if (!subscription) { + return json({ + hasAccess: false, + isOwner: false, + reason: "no_subscription", + subscription: null, + tier: null, + }) + } + + // Get the tier info + const tier = await database.query.creator_tiers.findFirst({ + where: eq(creator_tiers.id, subscription.tier_id), + }) + + return json({ + hasAccess: true, + isOwner: false, + subscription: { + id: subscription.id, + status: subscription.status, + current_period_end: subscription.current_period_end, + cancel_at_period_end: subscription.cancel_at_period_end, + }, + tier: tier + ? { + id: tier.id, + name: tier.name, + benefits: tier.benefits, + } + : null, + }) + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/creator/subscribe.ts b/packages/web/src/routes/api/creator/subscribe.ts new file mode 100644 index 00000000..2baa20a0 --- /dev/null +++ b/packages/web/src/routes/api/creator/subscribe.ts @@ -0,0 +1,122 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { getStripe } from "@/lib/stripe" +import { db } from "@/db/connection" +import { creator_tiers, stripe_customers } from "@/db/schema" +import { eq } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/creator/subscribe")({ + server: { + handlers: { + // POST /api/creator/subscribe - Start subscription checkout for a creator's tier + POST: async ({ request }) => { + const session = await getAuth().api.getSession({ + headers: request.headers, + }) + + if (!session?.user?.id) { + return json({ error: "Unauthorized" }, 401) + } + + const body = (await request.json().catch(() => ({}))) as { + tier_id?: string + success_url?: string + cancel_url?: string + } + + if (!body.tier_id) { + return json({ error: "tier_id is required" }, 400) + } + + const stripe = getStripe() + if (!stripe) { + return json({ error: "Stripe not configured" }, 500) + } + + const database = db() + + try { + // Get the tier + const tier = await database.query.creator_tiers.findFirst({ + where: eq(creator_tiers.id, body.tier_id), + }) + + if (!tier) { + return json({ error: "Tier not found" }, 404) + } + + if (!tier.stripe_price_id) { + return json({ error: "Tier not configured for payments" }, 400) + } + + // Prevent subscribing to own tier + if (tier.creator_id === session.user.id) { + return json({ error: "Cannot subscribe to your own tier" }, 400) + } + + // Get or create Stripe customer + let [customer] = await database + .select() + .from(stripe_customers) + .where(eq(stripe_customers.user_id, session.user.id)) + .limit(1) + + let stripeCustomerId: string + + if (customer) { + stripeCustomerId = customer.stripe_customer_id + } else { + const stripeCustomer = await stripe.customers.create({ + email: session.user.email, + name: session.user.name ?? undefined, + metadata: { user_id: session.user.id }, + }) + + await database.insert(stripe_customers).values({ + user_id: session.user.id, + stripe_customer_id: stripeCustomer.id, + }) + + stripeCustomerId = stripeCustomer.id + } + + // Create checkout session + const origin = new URL(request.url).origin + const successUrl = body.success_url ?? `${origin}/${tier.creator_id}?subscribed=true` + const cancelUrl = body.cancel_url ?? `${origin}/${tier.creator_id}?canceled=true` + + const checkoutSession = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: "subscription", + line_items: [{ price: tier.stripe_price_id, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + subscription_data: { + metadata: { + subscriber_id: session.user.id, + creator_id: tier.creator_id, + tier_id: tier.id, + }, + }, + metadata: { + subscriber_id: session.user.id, + creator_id: tier.creator_id, + tier_id: tier.id, + }, + }) + + return json({ url: checkoutSession.url }) + } catch (error) { + console.error("[creator/subscribe] Error:", error) + return json({ error: "Failed to create checkout session" }, 500) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/creator/tiers.ts b/packages/web/src/routes/api/creator/tiers.ts new file mode 100644 index 00000000..eb6eda93 --- /dev/null +++ b/packages/web/src/routes/api/creator/tiers.ts @@ -0,0 +1,142 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { getStripe } from "@/lib/stripe" +import { db } from "@/db/connection" +import { creator_tiers, users } from "@/db/schema" +import { eq, and, asc } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/creator/tiers")({ + server: { + handlers: { + // GET /api/creator/tiers?creator_id=xxx or ?username=xxx + // Returns all active tiers for a creator + GET: async ({ request }) => { + const url = new URL(request.url) + const creatorId = url.searchParams.get("creator_id") + const username = url.searchParams.get("username") + + if (!creatorId && !username) { + return json({ error: "creator_id or username required" }, 400) + } + + const database = db() + + try { + let targetCreatorId = creatorId + + // Look up by username if provided + if (username && !creatorId) { + const user = await database.query.users.findFirst({ + where: eq(users.username, username), + }) + if (!user) { + return json({ error: "Creator not found" }, 404) + } + targetCreatorId = user.id + } + + const tiers = await database + .select() + .from(creator_tiers) + .where( + and( + eq(creator_tiers.creator_id, targetCreatorId!), + eq(creator_tiers.is_active, true) + ) + ) + .orderBy(asc(creator_tiers.sort_order), asc(creator_tiers.price_cents)) + + return json({ tiers }) + } catch (error) { + console.error("[creator/tiers] Error:", error) + return json({ error: "Failed to fetch tiers" }, 500) + } + }, + + // POST /api/creator/tiers - Create a new tier (creator only) + POST: async ({ request }) => { + const session = await getAuth().api.getSession({ + headers: request.headers, + }) + + if (!session?.user?.id) { + return json({ error: "Unauthorized" }, 401) + } + + const body = (await request.json().catch(() => ({}))) as { + name?: string + description?: string + price_cents?: number + benefits?: string + sort_order?: number + } + + if (!body.name || typeof body.name !== "string") { + return json({ error: "name is required" }, 400) + } + + if (typeof body.price_cents !== "number" || body.price_cents < 100) { + return json({ error: "price_cents must be at least 100 ($1)" }, 400) + } + + const stripe = getStripe() + const database = db() + + try { + let stripePriceId: string | undefined + + // Create Stripe price if Stripe is configured + if (stripe) { + // First, get or create a product for this creator + const productName = `${session.user.name || session.user.email} - ${body.name}` + + const product = await stripe.products.create({ + name: productName, + metadata: { + creator_id: session.user.id, + tier_name: body.name, + }, + }) + + const price = await stripe.prices.create({ + product: product.id, + unit_amount: body.price_cents, + currency: "usd", + recurring: { interval: "month" }, + metadata: { + creator_id: session.user.id, + tier_name: body.name, + }, + }) + + stripePriceId = price.id + } + + const [tier] = await database + .insert(creator_tiers) + .values({ + creator_id: session.user.id, + name: body.name.trim(), + description: body.description?.trim() || null, + price_cents: body.price_cents, + benefits: body.benefits?.trim() || null, + stripe_price_id: stripePriceId, + sort_order: body.sort_order ?? 0, + }) + .returning() + + return json({ tier }, 201) + } catch (error) { + console.error("[creator/tiers] Error creating tier:", error) + return json({ error: "Failed to create tier" }, 500) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/stripe/webhooks.ts b/packages/web/src/routes/api/stripe/webhooks.ts index bff5a0b4..7184285a 100644 --- a/packages/web/src/routes/api/stripe/webhooks.ts +++ b/packages/web/src/routes/api/stripe/webhooks.ts @@ -1,7 +1,12 @@ import { createFileRoute } from "@tanstack/react-router" import { getStripe, getStripeConfig } from "@/lib/stripe" import { db } from "@/db/connection" -import { stripe_customers, stripe_subscriptions, storage_usage } from "@/db/schema" +import { + stripe_customers, + stripe_subscriptions, + storage_usage, + creator_subscriptions, +} from "@/db/schema" import { eq, and } from "drizzle-orm" import type Stripe from "stripe" @@ -105,6 +110,13 @@ async function handleSubscriptionCreated( ) { const customerId = subscription.customer as string const priceId = subscription.items.data[0]?.price.id + const metadata = subscription.metadata || {} + + // Check if this is a creator subscription + if (metadata.creator_id && metadata.subscriber_id && metadata.tier_id) { + await handleCreatorSubscription(database, subscription, metadata) + return + } // Find user by Stripe customer ID const [customer] = await database @@ -185,10 +197,75 @@ async function handleSubscriptionCreated( console.log(`[stripe] Subscription synced for user ${customer.user_id}`) } +// Handle creator economy subscriptions +async function handleCreatorSubscription( + database: ReturnType, + subscription: Stripe.Subscription, + metadata: Record +) { + const { creator_id, subscriber_id, tier_id } = metadata + + // Period dates + const item = subscription.items.data[0] + const periodStart = item?.current_period_start + ? new Date(item.current_period_start * 1000) + : new Date() + const periodEnd = item?.current_period_end + ? new Date(item.current_period_end * 1000) + : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + + // Check for existing subscription + const existing = await database + .select() + .from(creator_subscriptions) + .where(eq(creator_subscriptions.stripe_subscription_id, subscription.id)) + .limit(1) + + const subscriptionData = { + stripe_subscription_id: subscription.id, + status: subscription.status, + current_period_start: periodStart, + current_period_end: periodEnd, + cancel_at_period_end: subscription.cancel_at_period_end, + updated_at: new Date(), + } + + if (existing.length > 0) { + await database + .update(creator_subscriptions) + .set(subscriptionData) + .where(eq(creator_subscriptions.stripe_subscription_id, subscription.id)) + console.log(`[stripe] Updated creator subscription ${subscription.id}`) + } else { + await database.insert(creator_subscriptions).values({ + subscriber_id, + creator_id, + tier_id, + ...subscriptionData, + }) + console.log(`[stripe] Created creator subscription: ${subscriber_id} -> ${creator_id}`) + } +} + async function handleSubscriptionDeleted( database: ReturnType, subscription: Stripe.Subscription ) { + const metadata = subscription.metadata || {} + + // Check if this is a creator subscription + if (metadata.creator_id && metadata.subscriber_id) { + await database + .update(creator_subscriptions) + .set({ + status: "canceled", + updated_at: new Date(), + }) + .where(eq(creator_subscriptions.stripe_subscription_id, subscription.id)) + console.log(`[stripe] Creator subscription ${subscription.id} marked as canceled`) + return + } + await database .update(stripe_subscriptions) .set({