From bea47496894ea02b249ac1b06515b7d5c0ef2bdc Mon Sep 17 00:00:00 2001 From: Hemant Date: Mon, 31 Jul 2023 21:11:12 +0530 Subject: [PATCH] Integrated Stripe successfully in test mode --- .../(routes)/settings/constants.ts | 7 ++ app/(dashboard)/(routes)/settings/page.tsx | 31 +++++++++ app/(dashboard)/layout.tsx | 4 +- app/api/code/route.ts | 12 ++-- app/api/conversation/route.ts | 13 ++-- app/api/image/route.ts | 12 ++-- app/api/music/route.ts | 12 ++-- app/api/stripe/route.ts | 67 +++++++++++++++++++ app/api/video/route.ts | 12 ++-- app/api/webhook/route.ts | 67 +++++++++++++++++++ components/free-counter.tsx | 2 +- components/mobile-sidebar.tsx | 9 +-- components/navbar.tsx | 4 +- components/sidebar.tsx | 5 +- components/subscription-button.tsx | 33 +++++++++ lib/stripe.ts | 6 ++ lib/subscription.ts | 31 +++++++++ lib/utils.ts | 6 +- middleware.ts | 2 +- package.json | 1 + yarn.lock | 17 ++++- 21 files changed, 310 insertions(+), 43 deletions(-) create mode 100644 app/(dashboard)/(routes)/settings/constants.ts create mode 100644 app/(dashboard)/(routes)/settings/page.tsx create mode 100644 app/api/stripe/route.ts create mode 100644 app/api/webhook/route.ts create mode 100644 components/subscription-button.tsx create mode 100644 lib/stripe.ts create mode 100644 lib/subscription.ts diff --git a/app/(dashboard)/(routes)/settings/constants.ts b/app/(dashboard)/(routes)/settings/constants.ts new file mode 100644 index 0000000..d037863 --- /dev/null +++ b/app/(dashboard)/(routes)/settings/constants.ts @@ -0,0 +1,7 @@ +import * as z from "zod"; + +export const formSchema = z.object({ + prompt: z.string().min(1, { + message: "Prompt is required." + }), +}); diff --git a/app/(dashboard)/(routes)/settings/page.tsx b/app/(dashboard)/(routes)/settings/page.tsx new file mode 100644 index 0000000..d972a6d --- /dev/null +++ b/app/(dashboard)/(routes)/settings/page.tsx @@ -0,0 +1,31 @@ +import { Settings } from "lucide-react"; + +import { Heading } from "@/components/heading"; +import { SubscriptionButton } from "@/components/subscription-button"; +import { checkSubscription } from "@/lib/subscription"; + +const SettingsPage = async () => { + const isPro = await checkSubscription(); + + return ( +
+ +
+
+ {isPro ? "You are currently on a Pro plan." : "You are currently on a free plan."} +
+ +
+
+ ); +} + +export default SettingsPage; + diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index c5ed8eb..2ec9ef1 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,14 +1,16 @@ import Navbar from "@/components/navbar"; import { Sidebar } from "@/components/sidebar"; import { getApiLimitCount } from "@/lib/api-limit"; +import { checkSubscription } from "@/lib/subscription"; const DashboardLayout = async ({ children }: { children: React.ReactNode }) => { const apiLimitCount = await getApiLimitCount(); + const isPro = await checkSubscription() return (
- +
diff --git a/app/api/code/route.ts b/app/api/code/route.ts index 1d09195..da87681 100644 --- a/app/api/code/route.ts +++ b/app/api/code/route.ts @@ -2,7 +2,7 @@ import { auth } from "@clerk/nextjs"; import { NextResponse } from "next/server"; import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai"; -// import { checkSubscription } from "@/lib/subscription"; +import { checkSubscription } from "@/lib/subscription"; import { incrementApiLimit, checkApiLimit } from "@/lib/api-limit"; const configuration = new Configuration({ @@ -35,9 +35,9 @@ export async function POST(req: Request) { } const freeTrial = await checkApiLimit(); - // const isPro = await checkSubscription(); + const isPro = await checkSubscription(); - if (!freeTrial) { + if (!freeTrial && !isPro) { return new NextResponse("Free trial has expired. Please upgrade to pro.", { status: 403 }); } @@ -46,9 +46,9 @@ export async function POST(req: Request) { messages: [instructionMessage, ...messages] }); - // if (!isPro) { - await incrementApiLimit(); - // } + if (!isPro) { + await incrementApiLimit(); + } return NextResponse.json(response.data.choices[0].message); } catch (error) { diff --git a/app/api/conversation/route.ts b/app/api/conversation/route.ts index 5c58f4b..a83a896 100644 --- a/app/api/conversation/route.ts +++ b/app/api/conversation/route.ts @@ -2,7 +2,7 @@ import { auth } from "@clerk/nextjs"; import { NextResponse } from "next/server"; import { Configuration, OpenAIApi } from "openai"; -// import { checkSubscription } from "@/lib/subscription"; +import { checkSubscription } from "@/lib/subscription"; import { incrementApiLimit, checkApiLimit } from "@/lib/api-limit"; const configuration = new Configuration({ @@ -31,10 +31,9 @@ export async function POST(req: Request) { } const freeTrial = await checkApiLimit(); - // const isPro = await checkSubscription(); + const isPro = await checkSubscription(); - // if (!freeTrial && !isPro) { - if (!freeTrial) { + if (!freeTrial && !isPro) { return new NextResponse("Free trial has expired. Please upgrade to pro.", { status: 403 }); } @@ -43,9 +42,9 @@ export async function POST(req: Request) { messages }); - // if (!isPro) { - await incrementApiLimit(); - // } + if (!isPro) { + await incrementApiLimit(); + } return NextResponse.json(response.data.choices[0].message); } catch (error) { diff --git a/app/api/image/route.ts b/app/api/image/route.ts index 520f272..1c42879 100644 --- a/app/api/image/route.ts +++ b/app/api/image/route.ts @@ -2,7 +2,7 @@ import { auth } from "@clerk/nextjs"; import { NextResponse } from "next/server"; import { Configuration, OpenAIApi } from "openai"; -// import { checkSubscription } from "@/lib/subscription"; +import { checkSubscription } from "@/lib/subscription"; import { incrementApiLimit, checkApiLimit } from "@/lib/api-limit"; const configuration = new Configuration({ @@ -38,9 +38,9 @@ export async function POST(req: Request) { } const freeTrial = await checkApiLimit(); - // const isPro = await checkSubscription(); + const isPro = await checkSubscription(); - if (!freeTrial) { + if (!freeTrial && !isPro) { return new NextResponse("Free trial has expired. Please upgrade to pro.", { status: 403 }); } @@ -50,9 +50,9 @@ export async function POST(req: Request) { size: resolution, }); - // if (!isPro) { - await incrementApiLimit(); - // } + if (!isPro) { + await incrementApiLimit(); + } return NextResponse.json(response.data.data); } catch (error) { diff --git a/app/api/music/route.ts b/app/api/music/route.ts index 0f986ef..ea1e35c 100644 --- a/app/api/music/route.ts +++ b/app/api/music/route.ts @@ -2,7 +2,7 @@ import Replicate from "replicate"; import { auth } from "@clerk/nextjs"; import { NextResponse } from "next/server"; -// import { checkSubscription } from "@/lib/subscription"; +import { checkSubscription } from "@/lib/subscription"; import { incrementApiLimit, checkApiLimit } from "@/lib/api-limit"; const replicate = new Replicate({ @@ -24,9 +24,9 @@ export async function POST(req: Request) { } const freeTrial = await checkApiLimit(); - // const isPro = await checkSubscription(); + const isPro = await checkSubscription(); - if (!freeTrial) { + if (!freeTrial && !isPro) { return new NextResponse("Free trial has expired. Please upgrade to pro.", { status: 403 }); } @@ -39,9 +39,9 @@ export async function POST(req: Request) { } ); - // if (!isPro) { - await incrementApiLimit(); - // } + if (!isPro) { + await incrementApiLimit(); + } return NextResponse.json(response); } catch (error) { diff --git a/app/api/stripe/route.ts b/app/api/stripe/route.ts new file mode 100644 index 0000000..85d4e1f --- /dev/null +++ b/app/api/stripe/route.ts @@ -0,0 +1,67 @@ +import { auth, currentUser } from "@clerk/nextjs"; +import { NextResponse } from "next/server"; + +import prismadb from "@/lib/prismadb"; +import { stripe } from "@/lib/stripe"; +import { absoluteUrl } from "@/lib/utils"; + +const settingsUrl = absoluteUrl("/settings"); + +export async function GET() { + try { + const { userId } = auth(); + const user = await currentUser(); + + if (!userId || !user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const userSubscription = await prismadb.userSubscription.findUnique({ + where: { + userId + } + }) + + if (userSubscription && userSubscription.stripeCustomerId) { + const stripeSession = await stripe.billingPortal.sessions.create({ + customer: userSubscription.stripeCustomerId, + return_url: settingsUrl, + }) + + return new NextResponse(JSON.stringify({ url: stripeSession.url })) + } + + const stripeSession = await stripe.checkout.sessions.create({ + success_url: settingsUrl, + cancel_url: settingsUrl, + payment_method_types: ["card"], + mode: "subscription", + billing_address_collection: "auto", + customer_email: user.emailAddresses[0].emailAddress, + line_items: [ + { + price_data: { + currency: "INR", + product_data: { + name: "Genius Pro", + description: "Unlimited AI Generations" + }, + unit_amount: 200000, + recurring: { + interval: "month" + } + }, + quantity: 1, + }, + ], + metadata: { + userId, + }, + }) + + return new NextResponse(JSON.stringify({ url: stripeSession.url })) + } catch (error) { + console.log("[STRIPE_ERROR]", error); + return new NextResponse("Internal Error", { status: 500 }); + } +}; diff --git a/app/api/video/route.ts b/app/api/video/route.ts index 24afdfc..f24f097 100644 --- a/app/api/video/route.ts +++ b/app/api/video/route.ts @@ -3,7 +3,7 @@ import { auth } from "@clerk/nextjs"; import { NextResponse } from "next/server"; import { incrementApiLimit, checkApiLimit } from "@/lib/api-limit"; -// import { checkSubscription } from "@/lib/subscription"; +import { checkSubscription } from "@/lib/subscription"; const replicate = new Replicate({ auth: process.env.REPLICATE_API_TOKEN!, @@ -24,9 +24,9 @@ export async function POST(req: Request) { } const freeTrial = await checkApiLimit(); - // const isPro = await checkSubscription(); + const isPro = await checkSubscription(); - if (!freeTrial) { + if (!freeTrial && !isPro) { return new NextResponse("Free trial has expired. Please upgrade to pro.", { status: 403 }); } @@ -39,9 +39,9 @@ export async function POST(req: Request) { } ); - // if (!isPro) { - await incrementApiLimit(); - // } + if (!isPro) { + await incrementApiLimit(); + } return NextResponse.json(response); } catch (error) { diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..bc1423d --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,67 @@ +import Stripe from "stripe" +import { headers } from "next/headers" +import { NextResponse } from "next/server" + +import prismadb from "@/lib/prismadb" +import { stripe } from "@/lib/stripe" + +export async function POST(req: Request) { + const body = await req.text() + const signature = headers().get("Stripe-Signature") as string + + let event: Stripe.Event + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ) + } catch (error: any) { + return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 }) + } + + const session = event.data.object as Stripe.Checkout.Session + + if (event.type === "checkout.session.completed") { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + + if (!session?.metadata?.userId) { + return new NextResponse("User id is required", { status: 400 }); + } + + await prismadb.userSubscription.create({ + data: { + userId: session?.metadata?.userId, + stripeSubscriptionId: subscription.id, + stripeCustomerId: subscription.customer as string, + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }) + } + + if (event.type === "invoice.payment_succeeded") { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + + await prismadb.userSubscription.update({ + where: { + stripeSubscriptionId: subscription.id, + }, + data: { + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }) + } + + return new NextResponse(null, { status: 200 }) +}; diff --git a/components/free-counter.tsx b/components/free-counter.tsx index 84a9e35..1fb75a4 100644 --- a/components/free-counter.tsx +++ b/components/free-counter.tsx @@ -10,7 +10,7 @@ import { MAX_FREE_COUNTS } from "@/constants"; import { useProModal } from "@/hooks/use-pro-modal"; interface FreeCounterProps { - isPro?: boolean; + isPro: boolean; apiLimitCount: number; } diff --git a/components/mobile-sidebar.tsx b/components/mobile-sidebar.tsx index 8e162f2..95a2dd2 100644 --- a/components/mobile-sidebar.tsx +++ b/components/mobile-sidebar.tsx @@ -1,17 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; import { Menu } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Sidebar } from "@/components/sidebar"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; -import { Sidebar } from "@/components/sidebar"; interface MobileSidebarProps { apiLimitCount: number + isPro: boolean } -export const MobileSidebar: React.FC = ({ apiLimitCount }) => { +export const MobileSidebar: React.FC = ({ apiLimitCount = 0, isPro = false }) => { const [isMounted, setIsMounted] = useState(false); useEffect(() => setIsMounted(true), []); @@ -26,7 +27,7 @@ export const MobileSidebar: React.FC = ({ apiLimitCount }) = - + ); diff --git a/components/navbar.tsx b/components/navbar.tsx index 1427536..dcd2106 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -2,13 +2,15 @@ import { UserButton } from "@clerk/nextjs"; import { MobileSidebar } from "@/components/mobile-sidebar"; import { getApiLimitCount } from "@/lib/api-limit"; +import { checkSubscription } from "@/lib/subscription"; const Navbar: React.FC = async () => { const apiLimitCount = await getApiLimitCount(); + const isPro = await checkSubscription() return (
- +
diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 4466310..ef6bc9b 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -58,9 +58,10 @@ const routes = [ interface SidebarProps { apiLimitCount: number; + isPro: boolean; } -export const Sidebar: React.FC = ({ apiLimitCount = 0 }) => { +export const Sidebar: React.FC = ({ apiLimitCount = 0, isPro = false }) => { const pathname = usePathname(); return ( @@ -95,7 +96,7 @@ export const Sidebar: React.FC = ({ apiLimitCount = 0 }) => {
); diff --git a/components/subscription-button.tsx b/components/subscription-button.tsx new file mode 100644 index 0000000..3e4acb0 --- /dev/null +++ b/components/subscription-button.tsx @@ -0,0 +1,33 @@ +"use client"; + +import axios from "axios"; +import { useState } from "react"; +import { Zap } from "lucide-react"; +import { toast } from "react-hot-toast"; + +import { Button } from "@/components/ui/button"; + +export const SubscriptionButton = ({ isPro = false }: { isPro: boolean }) => { + const [loading, setLoading] = useState(false); + + const onClick = async () => { + try { + setLoading(true); + + const response = await axios.get("/api/stripe"); + + window.location.href = response.data.url; + } catch (error) { + toast.error("Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( + + ) +}; diff --git a/lib/stripe.ts b/lib/stripe.ts new file mode 100644 index 0000000..a297572 --- /dev/null +++ b/lib/stripe.ts @@ -0,0 +1,6 @@ +import Stripe from "stripe" + +export const stripe = new Stripe(process.env.STRIPE_API_KEY!, { + apiVersion: "2022-11-15", + typescript: true, +}); diff --git a/lib/subscription.ts b/lib/subscription.ts new file mode 100644 index 0000000..47d9c83 --- /dev/null +++ b/lib/subscription.ts @@ -0,0 +1,31 @@ +import { auth } from "@clerk/nextjs"; + +import prismadb from "@/lib/prismadb"; + +const DAY_IN_MS = 86_400_000; + +export const checkSubscription = async () => { + const { userId } = auth(); + + if (!userId) return false + + const userSubscription = await prismadb.userSubscription.findUnique({ + where: { + userId: userId, + }, + select: { + stripeSubscriptionId: true, + stripeCurrentPeriodEnd: true, + stripeCustomerId: true, + stripePriceId: true, + }, + }) + + if (!userSubscription) return false; + + const isValid = + userSubscription.stripePriceId && + userSubscription.stripeCurrentPeriodEnd?.getTime()! + DAY_IN_MS > Date.now() + + return !!isValid; +}; diff --git a/lib/utils.ts b/lib/utils.ts index ec79801..66894a5 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,10 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" - + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function absoluteUrl(path: string) { + return `${process.env.NEXT_PUBLIC_APP_URL}${path}` +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index 73ad107..519729f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -4,7 +4,7 @@ import { authMiddleware } from "@clerk/nextjs"; // Please edit this to allow other routes to be public as needed. // See https://clerk.com/docs/nextjs/middleware for more information about configuring your middleware export default authMiddleware({ - publicRoutes: ['/'] + publicRoutes: ['/', '/api/webhook'] }); export const config = { diff --git a/package.json b/package.json index b9bf0e7..5b94635 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react-hot-toast": "^2.4.1", "react-markdown": "^8.0.7", "replicate": "^0.14.0", + "stripe": "^12.16.0", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.6", diff --git a/yarn.lock b/yarn.lock index adc85eb..85b0a3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -756,7 +756,7 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@20.4.5": +"@types/node@*", "@types/node@20.4.5", "@types/node@>=8.1.0": version "20.4.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.5.tgz#9dc0a5cb1ccce4f7a731660935ab70b9c00a5d69" integrity sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg== @@ -3150,6 +3150,13 @@ pvutils@^1.1.3: resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== +qs@^6.11.0: + version "6.11.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" + integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA== + dependencies: + side-channel "^1.0.4" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -3557,6 +3564,14 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +stripe@^12.16.0: + version "12.16.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-12.16.0.tgz#a52f3ab9480b10435dd1e9792820c65bfd406506" + integrity sha512-iwVkyZsS9KsWmEVK5qnbPeX/m94+Z3fXIRU7Q4iNBS2Zuj1spGIjLKMN3tejUs/ZZ2o7dCYFJvVdC2aZMYo8GA== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.11.0" + style-to-object@^0.4.0: version "0.4.1" resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.1.tgz#53cf856f7cf7f172d72939d9679556469ba5de37"