mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
feat: Add Cloudflare StreamPlayer component and update schema with billing and access control
- Introduced `CloudflareStreamPlayer` React component for embedding streams - Updated `package.json` with new dependencies: `@cloudflare/stream-react` and `stripe` - Extended database schema with user tiers, Stripe billing, storage, and archive management - Added access control logic in `access.ts` for user tiers and feature permissions - Enhanced billing logic with archive storage limits and subscription checks
This commit is contained in:
111
packages/web/src/routes/api/stripe/checkout.ts
Normal file
111
packages/web/src/routes/api/stripe/checkout.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { getStripe, getStripeConfig } from "@/lib/stripe"
|
||||
import { db } from "@/db/connection"
|
||||
import { 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/stripe/checkout")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const stripe = getStripe()
|
||||
const { archivePriceId } = getStripeConfig()
|
||||
|
||||
if (!stripe) {
|
||||
console.error("[stripe] Stripe not configured - missing STRIPE_SECRET_KEY")
|
||||
return json({ error: "Stripe not configured" }, 500)
|
||||
}
|
||||
|
||||
if (!archivePriceId) {
|
||||
console.error("[stripe] Price ID not configured - missing STRIPE_ARCHIVE_PRICE_ID")
|
||||
return json({ error: "Price ID not configured" }, 500)
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
// 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 {
|
||||
// Create new Stripe customer
|
||||
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
|
||||
}
|
||||
|
||||
// Parse request body for success/cancel URLs
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
successUrl?: string
|
||||
cancelUrl?: string
|
||||
}
|
||||
|
||||
const origin = new URL(request.url).origin
|
||||
const successUrl = body.successUrl ?? `${origin}/archive?billing=success`
|
||||
const cancelUrl = body.cancelUrl ?? `${origin}/archive?billing=canceled`
|
||||
|
||||
// Create checkout session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
mode: "subscription",
|
||||
line_items: [
|
||||
{
|
||||
price: archivePriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
user_id: session.user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return json({ url: checkoutSession.url })
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error("[stripe] Checkout error:", err.message)
|
||||
return json(
|
||||
{ error: `Failed to create checkout session: ${err.message}` },
|
||||
500
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
201
packages/web/src/routes/api/stripe/webhooks.ts
Normal file
201
packages/web/src/routes/api/stripe/webhooks.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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 { eq, and } from "drizzle-orm"
|
||||
import type Stripe from "stripe"
|
||||
|
||||
// Archive subscription limits
|
||||
const ARCHIVE_LIMITS = {
|
||||
archives: 10,
|
||||
storageBytes: 1073741824, // 1GB
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/stripe/webhooks")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const stripe = getStripe()
|
||||
const { webhookSecret } = getStripeConfig()
|
||||
|
||||
if (!stripe || !webhookSecret) {
|
||||
console.error("[stripe] Stripe not configured")
|
||||
return new Response("Stripe not configured", { status: 500 })
|
||||
}
|
||||
|
||||
const body = await request.text()
|
||||
const signature = request.headers.get("stripe-signature")
|
||||
|
||||
if (!signature) {
|
||||
return new Response("Missing stripe-signature header", { status: 400 })
|
||||
}
|
||||
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
||||
} catch (err) {
|
||||
console.error("[stripe] Webhook signature verification failed:", err)
|
||||
return new Response("Invalid signature", { status: 400 })
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
console.log("[stripe] Checkout completed:", session.id)
|
||||
|
||||
if (session.mode === "subscription" && session.subscription) {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
)
|
||||
await handleSubscriptionCreated(database, subscription)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "customer.subscription.created":
|
||||
case "customer.subscription.updated": {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
console.log(`[stripe] Subscription ${event.type}:`, subscription.id)
|
||||
await handleSubscriptionCreated(database, subscription)
|
||||
break
|
||||
}
|
||||
|
||||
case "customer.subscription.deleted": {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
console.log("[stripe] Subscription deleted:", subscription.id)
|
||||
await handleSubscriptionDeleted(database, subscription)
|
||||
break
|
||||
}
|
||||
|
||||
case "invoice.payment_succeeded": {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
console.log("[stripe] Invoice paid:", invoice.id)
|
||||
break
|
||||
}
|
||||
|
||||
case "invoice.payment_failed": {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
console.log("[stripe] Invoice payment failed:", invoice.id)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`[stripe] Unhandled event type: ${event.type}`)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[stripe] Webhook handler error:", error)
|
||||
return new Response("Webhook handler error", { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
async function handleSubscriptionCreated(
|
||||
database: ReturnType<typeof db>,
|
||||
subscription: Stripe.Subscription
|
||||
) {
|
||||
const customerId = subscription.customer as string
|
||||
const priceId = subscription.items.data[0]?.price.id
|
||||
|
||||
// Find user by Stripe customer ID
|
||||
const [customer] = await database
|
||||
.select()
|
||||
.from(stripe_customers)
|
||||
.where(eq(stripe_customers.stripe_customer_id, customerId))
|
||||
.limit(1)
|
||||
|
||||
if (!customer) {
|
||||
console.error("[stripe] No customer found for:", customerId)
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert subscription
|
||||
const existing = await database
|
||||
.select()
|
||||
.from(stripe_subscriptions)
|
||||
.where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id))
|
||||
.limit(1)
|
||||
|
||||
// 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)
|
||||
|
||||
const subscriptionData = {
|
||||
stripe_subscription_id: subscription.id,
|
||||
stripe_customer_id: customerId,
|
||||
stripe_price_id: priceId,
|
||||
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(stripe_subscriptions)
|
||||
.set(subscriptionData)
|
||||
.where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id))
|
||||
} else {
|
||||
await database.insert(stripe_subscriptions).values({
|
||||
user_id: customer.user_id,
|
||||
...subscriptionData,
|
||||
})
|
||||
}
|
||||
|
||||
// Create or update storage usage record for this billing period
|
||||
const [existingUsage] = await database
|
||||
.select()
|
||||
.from(storage_usage)
|
||||
.where(
|
||||
and(
|
||||
eq(storage_usage.user_id, customer.user_id),
|
||||
eq(storage_usage.period_start, periodStart)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingUsage) {
|
||||
await database.insert(storage_usage).values({
|
||||
user_id: customer.user_id,
|
||||
archives_used: 0,
|
||||
archives_limit: ARCHIVE_LIMITS.archives,
|
||||
storage_bytes_used: 0,
|
||||
storage_bytes_limit: ARCHIVE_LIMITS.storageBytes,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
})
|
||||
console.log(`[stripe] Created storage usage record for user ${customer.user_id}`)
|
||||
}
|
||||
|
||||
console.log(`[stripe] Subscription synced for user ${customer.user_id}`)
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(
|
||||
database: ReturnType<typeof db>,
|
||||
subscription: Stripe.Subscription
|
||||
) {
|
||||
await database
|
||||
.update(stripe_subscriptions)
|
||||
.set({
|
||||
status: "canceled",
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id))
|
||||
|
||||
console.log(`[stripe] Subscription ${subscription.id} marked as canceled`)
|
||||
}
|
||||
Reference in New Issue
Block a user