mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +01:00
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:
77
flow.toml
77
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: <session>'"
|
||||
fi
|
||||
"""
|
||||
description = "Check Stripe configuration status."
|
||||
|
||||
@@ -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,
|
||||
|
||||
99
packages/web/src/routes/api/creator/$username.access.ts
Normal file
99
packages/web/src/routes/api/creator/$username.access.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
122
packages/web/src/routes/api/creator/subscribe.ts
Normal file
122
packages/web/src/routes/api/creator/subscribe.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
142
packages/web/src/routes/api/creator/tiers.ts
Normal file
142
packages/web/src/routes/api/creator/tiers.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user