Update Stripe setup instructions and add new creator economy API routes

- Clarify instructions for creator-specific pricing and setup
- Remove outdated Stripe Price ID prompt from setup script
- Add API route definitions for `/api/creator/tiers`, `/api/creator/subscribe`, and `/api/creator/$username/access`
- Register new routes in route tree for API access
- Update documentation to reflect new endpoints and instructions
This commit is contained in:
Nikita
2025-12-24 16:58:43 -08:00
parent f7009b161c
commit 9d7ea0ada1
6 changed files with 526 additions and 57 deletions

View File

@@ -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: <session>'"
fi
"""
description = "Check Stripe configuration status."

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof db>,
subscription: Stripe.Subscription,
metadata: Record<string, string>
) {
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<typeof db>,
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({