diff --git a/.env.example b/.env.example index a3f7368..179576d 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,8 @@ NEXT_PUBLIC_GITHUB_SECRET='your github secret ID' ## required for next-auth NEXTAUTH_SECRET='your next-auth secret' ## required for next-auth - generate one here: https://generate-secret.vercel.app/32 NEXTAUTH_URL='http://localhost:3000' ## Only required for localhost + +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY='your stripe publishable key' ## required for stripe +STRIPE_SECRET_KEY='your stripe secret key' ## required for stripe +STRIPE_WEBHOOK_SECRET_KEY='your webhook secret key' ## required for stripe +STRIPE_SUBSCRIPTION_PRICE_ID='your subscription price id' ## required for stripe diff --git a/README.md b/README.md index 31e9888..cc9ee28 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - 📘 Typescript - 🎨 TailwindCSS - Class sorting, merging and linting - 🛠️ Shadcn/ui - Customizable UI components +- 💵 Stripe - Payment handler - 🔒 Next-auth - Easy authentication library for Next.js (GitHub provider) - 🛡️ Prisma - ORM for node.js - 📋 React-hook-form - Manage your forms easy and efficient diff --git a/package-lock.json b/package-lock.json index 95da1c6..0c1d5f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", + "@stripe/stripe-js": "^3.4.0", "@t3-oss/env-nextjs": "^0.10.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -25,6 +26,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.51.3", + "stripe": "^15.6.0", "tailwind-merge": "^2.3.0", "zod": "^3.23.5" }, @@ -2837,6 +2839,14 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stripe/stripe-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.4.0.tgz", + "integrity": "sha512-a2kUP7OrsV0SSIk3UxWa+cnrW+PPIyuCbWIBH8vxfHIqmyeQN/d0lsplZJ2h7MlLsU/sB3EyhNBkhLLT+zHwKw==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -3156,7 +3166,6 @@ "version": "20.12.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.8.tgz", "integrity": "sha512-NU0rJLJnshZWdE/097cdCBbyW1h4hEg0xpovcoAQYHl8dnEyp/NAOiE45pvc+Bd1Dt+2r94v2eGFpQJ4R7g+2w==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -4086,7 +4095,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -4794,7 +4802,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -5069,7 +5076,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.4" }, @@ -5081,7 +5087,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -6138,7 +6143,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6204,7 +6208,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -6395,7 +6398,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -6436,7 +6438,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -6448,7 +6449,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -6460,7 +6460,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -6487,7 +6486,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -9273,7 +9271,6 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -10076,6 +10073,20 @@ } ] }, + "node_modules/qs": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", + "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -10559,7 +10570,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -10612,7 +10622,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", @@ -10996,6 +11005,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-15.6.0.tgz", + "integrity": "sha512-ARG46eQHMmHspnDpj3QTAH8GyEqtE0nesbzpTtQDT/C9nHvOFYri3mIzHEzArzDcKX7HSleTu2VpYoDZIIH7nA==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -11484,8 +11505,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicorn-magic": { "version": "0.1.0", diff --git a/package.json b/package.json index 83be895..8c89f8a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", + "@stripe/stripe-js": "^3.4.0", "@t3-oss/env-nextjs": "^0.10.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -48,6 +49,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.51.3", + "stripe": "^15.6.0", "tailwind-merge": "^2.3.0", "zod": "^3.23.5" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c9d610..206f1f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,13 +38,15 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + stripeCustomerId String? @unique + isActive Boolean @default(false) + accounts Account[] + sessions Session[] } model VerificationToken { diff --git a/src/app/api/auth/[...nextauth]/auth-options.ts b/src/app/api/auth/[...nextauth]/auth-options.ts index 991a80d..5088e12 100644 --- a/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/src/app/api/auth/[...nextauth]/auth-options.ts @@ -4,6 +4,7 @@ import GitHubProvider from 'next-auth/providers/github'; import { env } from '@/env.mjs'; import prisma from '@/lib/prisma'; +import { stripeServer } from '@/lib/stripe'; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), @@ -13,4 +14,34 @@ export const authOptions: NextAuthOptions = { clientSecret: env.NEXT_PUBLIC_GITHUB_SECRET || '', }), ], + callbacks: { + async session({ session, user }) { + if (!session.user) return session; + + session.user.id = user.id; + session.user.stripeCustomerId = user.stripeCustomerId; + session.user.isActive = user.isActive; + + return session; + }, + }, + events: { + createUser: async ({ user }) => { + if (!user.email || !user.name) return; + + await stripeServer.customers + .create({ + email: user.email, + name: user.name, + }) + .then(async (customer) => { + return prisma.user.update({ + where: { id: user.id }, + data: { + stripeCustomerId: customer.id, + }, + }); + }); + }, + }, }; diff --git a/src/app/api/stripe/checkout-session/route.ts b/src/app/api/stripe/checkout-session/route.ts new file mode 100644 index 0000000..50dbdee --- /dev/null +++ b/src/app/api/stripe/checkout-session/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'; +import { env } from '@/env.mjs'; +import { stripeServer } from '@/lib/stripe'; + +export const GET = async () => { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { + error: { + code: 'no-access', + message: 'You are not signed in.', + }, + }, + { status: 401 } + ); + } + + const checkoutSession = await stripeServer.checkout.sessions.create({ + mode: 'subscription', + customer: session.user.stripeCustomerId, + line_items: [ + { + price: env.STRIPE_SUBSCRIPTION_PRICE_ID, + quantity: 1, + }, + ], + success_url: `${env.NEXT_PUBLIC_SITE_URL}?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: env.NEXT_PUBLIC_SITE_URL, + }); + + return NextResponse.json({ session: checkoutSession }, { status: 200 }); +}; diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..576163f --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +import { env } from '@/env.mjs'; +import prisma from '@/lib/prisma'; +import { stripeServer } from '@/lib/stripe'; + +const webhookHandler = async (req: NextRequest) => { + try { + const buf = await req.text(); + const sig = req.headers.get('stripe-signature')!; + + let event: Stripe.Event; + + try { + event = stripeServer.webhooks.constructEvent( + buf, + sig, + env.STRIPE_WEBHOOK_SECRET_KEY + ); + } catch (err) { + return NextResponse.json( + { + error: { + message: 'Webhook Error', + }, + }, + { status: 400 } + ); + } + + const subscription = event.data.object as Stripe.Subscription; + + switch (event.type) { + case 'customer.subscription.created': + await prisma.user.update({ + where: { + stripeCustomerId: subscription.customer as string, + }, + data: { + isActive: true, + }, + }); + break; + default: + break; + } + return NextResponse.json({ received: true }); + } catch { + return NextResponse.json( + { + error: { + message: 'Method Not Allowed', + }, + }, + { status: 405 } + ).headers.set('Allow', 'POST'); + } +}; + +export { webhookHandler as POST }; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index d7214a7..55e9cf2 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1,9 +1,10 @@ -import { LogOut, LucideProps, Moon, Sun } from 'lucide-react'; +import { Loader2, LogOut, LucideProps, Moon, Sun } from 'lucide-react'; export const Icons = { sun: Sun, moon: Moon, logOut: LogOut, + loader: Loader2, github: (props: LucideProps) => ( { + const [isPending, setIsPending] = useState(false); + + const handleCreateCheckoutSession = async () => { + setIsPending(true); + + const res = await fetch('/api/stripe/checkout-session'); + const checkoutSession = await res.json().then(({ session }) => session); + const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); + await stripe!.redirectToCheckout({ + sessionId: checkoutSession.id, + }); + }; -export const UserDropdown = ({ session }: { session: Session }) => { return ( + My Account + +
+ {`${user?.name}`} +

{user?.name}

+ +
+ signOut()}> Log out diff --git a/src/env.mjs b/src/env.mjs index deea330..070be63 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -7,6 +7,12 @@ export const env = createEnv({ NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().min(1).optional(), NEXT_PUBLIC_GITHUB_ID: z.string().min(1).optional(), NEXT_PUBLIC_GITHUB_SECRET: z.string().min(1).optional(), + STRIPE_SECRET_KEY: z.string().min(1), + STRIPE_WEBHOOK_SECRET_KEY: z.string().min(1), + STRIPE_SUBSCRIPTION_PRICE_ID: z.string().min(1), + }, + client: { + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), }, runtimeEnv: { NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, @@ -14,5 +20,10 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID, NEXT_PUBLIC_GITHUB_ID: process.env.NEXT_PUBLIC_GITHUB_ID, NEXT_PUBLIC_GITHUB_SECRET: process.env.NEXT_PUBLIC_GITHUB_SECRET, + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY, + STRIPE_SUBSCRIPTION_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_PRICE_ID, }, }); diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..a8ddfda --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,7 @@ +import Stripe from 'stripe'; + +import { env } from '@/env.mjs'; + +export const stripeServer = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: '2024-04-10', +}); diff --git a/typed.d.ts b/typed.d.ts new file mode 100644 index 0000000..2c5d681 --- /dev/null +++ b/typed.d.ts @@ -0,0 +1,15 @@ +import { DefaultUser } from 'next-auth'; + +declare module 'next-auth' { + interface Session { + user?: DefaultUser & { + id: string; + stripeCustomerId: string; + isActive: boolean; + }; + } + interface User extends DefaultUser { + stripeCustomerId: string; + isActive: boolean; + } +}