diff --git a/.env.example b/.env.example index e173a4b..9e85e69 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,9 @@ EMAIL_FROM=noreply@example.com OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_MODEL= + +# Subscriptions +ZENOTE_SELF_HOSTED=true +STRIPE_SECRET_KEY= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/bun.lockb b/bun.lockb index 77a04a4..893c8fd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 60e96b5..dacc78f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", + "@stripe/stripe-js": "^2.1.11", "@t3-oss/env-nextjs": "^0.7.0", "@tailwindcss/typography": "^0.5.10", "@tanstack/react-query": "^4.32.6", @@ -83,6 +84,7 @@ "react-day-picker": "^8.9.1", "react-dom": "18.2.0", "react-hook-form": "^7.47.0", + "stripe": "^14.3.0", "superjson": "^1.13.1", "swr": "^2.2.4", "tailwind-merge": "^1.14.0", diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts new file mode 100644 index 0000000..ae5004e --- /dev/null +++ b/src/app/api/webhook/route.ts @@ -0,0 +1,66 @@ +import { env } from '@/env.mjs' +import { getStripeServer } from '@/lib/stripe' +import { db } from '@/server/db' +import { users } from '@/server/db/schema' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import Stripe from 'stripe' + +export async function POST(req: Request) { + if (!env.STRIPE_WEBHOOK_SECRET) + return NextResponse.json( + { message: 'Stripe not configured' }, + { status: 500 } + ) + const stripe = getStripeServer() + if (!stripe) + return NextResponse.json( + { message: 'Stripe not configured' }, + { status: 500 } + ) + const body = await req.text() + const sig = req.headers.get('stripe-signature') + try { + const event = stripe.webhooks.constructEvent( + body, + sig!, + env.STRIPE_WEBHOOK_SECRET + ) + if (!event) + return NextResponse.json({ message: 'Invalid event' }, { status: 500 }) + switch (event.type) { + case 'customer.subscription.created': + await db + .update(users) + .set({ + subscriptionTier: 'standard', + stripeId: String(event.data.object.customer) + }) + .where(eq(users.id, event.data.object.metadata.localId ?? '')) + break + case 'customer.subscription.deleted': + await db + .update(users) + .set({ + subscriptionTier: 'none' + }) + .where(eq(users.id, event.data.object.metadata.localId ?? '')) + break + case 'payment_intent.succeeded': + await db + .update(users) + .set({ + subscriptionTier: 'lifetime', + stripeId: String(event.data.object.customer) + }) + .where(eq(users.id, event.data.object.metadata.localId ?? '')) + break + } + return NextResponse.json({ ok: true }) + } catch (error) { + if (error instanceof Stripe.errors.StripeError) { + const { message } = error + return NextResponse.json({ message }, { status: error.statusCode }) + } + } +} diff --git a/src/app/channels/[channelId]/page.tsx b/src/app/channels/[channelId]/page.tsx index 366fd85..58ee356 100644 --- a/src/app/channels/[channelId]/page.tsx +++ b/src/app/channels/[channelId]/page.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button' import { api } from '@/trpc/server' import { NotesList } from '@/components/channels/notes-list' import { QuickEditorForm } from '@/components/notes-create/quick-editor-form' +import { ChannelMenu } from '@/components/channels/channel-menu' const ChannelPage = async ({ params }: { params: { channelId: string } }) => { const { channel, notes } = await api.channels.get.query({ @@ -12,7 +13,12 @@ const ChannelPage = async ({ params }: { params: { channelId: string } }) => { return (