Skip to content

Commit a27ade7

Browse files
feat: ✨ Implement subscription payment with strip
- Implement payment feature with stripe - Updated middleware to handle webhook - Add subscription button - Update lib/utils.ts - Add stripe package and react hot toast - Create stripe webhook - Add settings page - Implement error handling - Update readme Payment feature with stripe implemented
1 parent f14a5d9 commit a27ade7

23 files changed

+377
-51
lines changed

README.md

+44-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ It is a `SaaS` (Software as a Service), `AI Platform`
44

55
### Stack
66

7-
`Nest.js 14, React, Typescript, OpenAI API, Replicate API, Clerk, Prisma, Zod, Postgres, Supabase, zustand, Stripe, Crisp, Tailwind, Shadcn-ui, Axios, React hook form, React markdown`.
7+
`Nest.js 14, React, Typescript, OpenAI API, Replicate API, Clerk, Prisma, Zod, Postgres, Supabase, zustand, Stripe, Crisp, Tailwind, Shadcn-ui, Axios, React hook form, react hot toast, React markdown`.
88

99
## Getting Started
1010

@@ -24,12 +24,40 @@ bun dev
2424

2525
Create:
2626

27+
```
28+
29+
CLERK_SECRET_KEY
30+
31+
NEXT_PUBLIC_CLERK_SIGN_IN_URL
32+
NEXT_PUBLIC_CLERK_SIGN_UP_URL
33+
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL
34+
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL
35+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
36+
37+
OPENAI_API_KEY
38+
39+
REPLICATE_API_TOKEN
40+
41+
NEXT_PUBLIC_APP_URL
42+
43+
DATABASE_URL
44+
DATABASE_PASS
45+
46+
STRIPE_API_KEY
47+
STRIPE_WEBHOOK_SECRET
48+
49+
```
50+
2751
## For prisma
2852

2953
```bash
3054
# Generate prisma setup
3155
npx prisma init # than make your changes on prisma schema with your provider and connection string
3256

57+
# Generate/Create tables
58+
59+
npx prisma generate
60+
3361
# Install prisma client
3462
npm i @prisma/client
3563

@@ -38,4 +66,19 @@ npx prisma db push
3866

3967
# Open prisma studio on localhost
4068
npx prisma studio
69+
70+
# Reset database (You will lose all the data)
71+
npx prisma migrate reset
4172
```
73+
74+
## For stripe
75+
76+
- Create the connection with the sample endpoint
77+
- Test in local environment
78+
- Download cli
79+
- $ stripe login (check documentation)
80+
- $ stripe listen --forward-to (localhost:3000/api/webhook)
81+
now you got the secret, copy it and add it to your .env `STRIPE_WEBHOOK_SECRET`
82+
- $ stripe trigger (trigger events with the cli)
83+
- Keep dev running , prisma and stripe cli bash's
84+
- Go to stipe website and search customer portal and activate 'Activate test link'

app/(dashboard)/(routes)/code/page.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Code } from "lucide-react";
66
import { useForm } from "react-hook-form";
77
import { useState } from "react";
88
import { useRouter } from "next/navigation";
9+
import toast from "react-hot-toast";
910
import OpenAI from "openai";
1011
import ReactMarkdown from "react-markdown";
1112

@@ -55,6 +56,7 @@ const CodePage = () => {
5556
if (error?.response?.status === 403) {
5657
proModal.onOpen();
5758
} else {
59+
toast.error("Something went wrong.");
5860
}
5961
} finally {
6062
router.refresh();

app/(dashboard)/(routes)/conversation/page.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as z from "zod";
55
import { useState } from "react";
66
import { useRouter } from "next/navigation";
77
import { MessageSquare } from "lucide-react";
8+
import toast from "react-hot-toast";
89
import { useForm } from "react-hook-form";
910

1011
import { zodResolver } from "@hookform/resolvers/zod";
@@ -56,6 +57,7 @@ export default function ConversationPage() {
5657
if (error?.response?.status === 403) {
5758
proModal.onOpen();
5859
} else {
60+
toast.error("Something went wrong.");
5961
}
6062
} finally {
6163
router.refresh();

app/(dashboard)/(routes)/dashboard/constants.ts

-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { Code, ImageIcon, MessageSquare, Music, VideoIcon } from "lucide-react";
22

3-
export const MAX_FREE_COUNTS = 5;
4-
53
export const tools = [
64
{
75
label: "Conversation",

app/(dashboard)/(routes)/image/page.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
88
import { Download, ImageIcon } from "lucide-react";
99
import { useForm } from "react-hook-form";
1010
import { useRouter } from "next/navigation";
11+
import toast from "react-hot-toast";
1112

1213
import Heading from "@/components/heading";
1314
import { Button } from "@/components/ui/button";
@@ -56,6 +57,7 @@ const PhotoPage = () => {
5657
if (error?.response?.status === 403) {
5758
proModal.onOpen();
5859
} else {
60+
toast.error("Something went wrong.");
5961
}
6062
} finally {
6163
router.refresh();

app/(dashboard)/(routes)/music/page.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useState } from "react";
66
import { zodResolver } from "@hookform/resolvers/zod";
77
import { useForm } from "react-hook-form";
88
import { useRouter } from "next/navigation";
9+
import toast from "react-hot-toast";
910
import { Music, Send } from "lucide-react";
1011

1112
import Heading from "@/components/heading";
@@ -44,6 +45,7 @@ const MusicPage = () => {
4445
if (error?.response?.status === 403) {
4546
proModal.onOpen();
4647
} else {
48+
toast.error("Something went wrong.");
4749
}
4850
} finally {
4951
router.refresh();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as z from "zod";
2+
3+
export const formSchema = z.object({
4+
prompt: z.string().min(1, {
5+
message: "Prompt is required.",
6+
}),
7+
});
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Settings } from "lucide-react";
2+
3+
import Heading from "@/components/heading";
4+
import { SubscriptionButton } from "@/components/subscription-button";
5+
import { checkSubscription } from "@/lib/subscription";
6+
7+
const SettingsPage = async () => {
8+
const isPro = await checkSubscription();
9+
10+
return (
11+
<div>
12+
<Heading
13+
title="Settings"
14+
description="Manage account settings."
15+
icon={Settings}
16+
iconColor="text-gray-700"
17+
bgColor="bg-gray-700/10"
18+
/>
19+
<div className="px-4 lg:px-8 space-y-4">
20+
<div className="text-muted-foreground text-sm">
21+
{isPro
22+
? "You are currently on a Pro plan."
23+
: "You are currently on a free plan."}
24+
</div>
25+
<SubscriptionButton isPro={isPro} />
26+
</div>
27+
</div>
28+
);
29+
};
30+
31+
export default SettingsPage;

app/(dashboard)/(routes)/video/page.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useForm } from "react-hook-form";
88

99
import { FileAudio } from "lucide-react";
1010
import { useRouter } from "next/navigation";
11+
import toast from "react-hot-toast";
1112

1213
import Heading from "@/components/heading";
1314
import { Button } from "@/components/ui/button";
@@ -45,6 +46,7 @@ const VideoPage = () => {
4546
if (error?.response?.status === 403) {
4647
proModal.onOpen();
4748
} else {
49+
toast.error("Something went wrong.");
4850
}
4951
} finally {
5052
router.refresh();

app/(dashboard)/error.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"use client";
2+
3+
import { Empty } from "@/components/empty";
4+
5+
const Error = () => {
6+
return <Empty label="Something went wrong." />;
7+
};
8+
9+
export default Error;

app/api/stripe/route.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { auth, currentUser } from "@clerk/nextjs";
2+
import { NextResponse } from "next/server";
3+
4+
import prismadb from "@/lib/prismadb";
5+
import { stripe } from "@/lib/stripe";
6+
import { absoluteUrl } from "@/lib/utils";
7+
8+
const settingsUrl = absoluteUrl("/settings");
9+
10+
export async function GET() {
11+
try {
12+
const { userId } = auth();
13+
const user = await currentUser();
14+
15+
if (!userId || !user) {
16+
return new NextResponse("Unauthorized", { status: 401 });
17+
}
18+
19+
const userSubscription = await prismadb.userSubscription.findUnique({
20+
where: {
21+
userId,
22+
},
23+
});
24+
25+
if (userSubscription && userSubscription.stripeCustomerId) {
26+
const stripeSession = await stripe.billingPortal.sessions.create({
27+
customer: userSubscription.stripeCustomerId,
28+
return_url: settingsUrl,
29+
});
30+
31+
return new NextResponse(JSON.stringify({ url: stripeSession.url }));
32+
}
33+
34+
const stripeSession = await stripe.checkout.sessions.create({
35+
success_url: settingsUrl,
36+
cancel_url: settingsUrl,
37+
payment_method_types: ["card"],
38+
mode: "subscription",
39+
billing_address_collection: "auto",
40+
customer_email: user.emailAddresses[0].emailAddress,
41+
line_items: [
42+
{
43+
price_data: {
44+
currency: "EUR",
45+
product_data: {
46+
name: "Omniscient Pro",
47+
description: "Unlimited AI Generations",
48+
},
49+
unit_amount: 2000,
50+
recurring: {
51+
interval: "month",
52+
},
53+
},
54+
quantity: 1,
55+
},
56+
],
57+
metadata: {
58+
userId,
59+
},
60+
});
61+
62+
return new NextResponse(JSON.stringify({ url: stripeSession.url }));
63+
} catch (error) {
64+
console.log("[STRIPE_ERROR]", error);
65+
return new NextResponse("Internal Error", { status: 500 });
66+
}
67+
}

app/api/webhook/route.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Stripe from "stripe";
2+
import { headers } from "next/headers";
3+
import { NextResponse } from "next/server";
4+
5+
import prismadb from "@/lib/prismadb";
6+
import { stripe } from "@/lib/stripe";
7+
8+
export async function POST(req: Request) {
9+
const body = await req.text();
10+
const signature = headers().get("Stripe-Signature") as string;
11+
12+
let event: Stripe.Event;
13+
14+
try {
15+
event = stripe.webhooks.constructEvent(
16+
body,
17+
signature,
18+
process.env.STRIPE_WEBHOOK_SECRET!
19+
);
20+
} catch (error: any) {
21+
return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 });
22+
}
23+
24+
const session = event.data.object as Stripe.Checkout.Session;
25+
26+
if (event.type === "checkout.session.completed") {
27+
const subscription = await stripe.subscriptions.retrieve(
28+
session.subscription as string
29+
);
30+
31+
if (!session?.metadata?.userId) {
32+
return new NextResponse("User id is required", { status: 400 });
33+
}
34+
35+
await prismadb.userSubscription.create({
36+
data: {
37+
userId: session?.metadata?.userId,
38+
stripeSubscriptionId: subscription.id,
39+
stripeCustomerId: subscription.customer as string,
40+
stripePriceId: subscription.items.data[0].price.id,
41+
stripeCurrentPeriodEnd: new Date(
42+
subscription.current_period_end * 1000
43+
),
44+
},
45+
});
46+
}
47+
48+
if (event.type === "invoice.payment_succeeded") {
49+
const subscription = await stripe.subscriptions.retrieve(
50+
session.subscription as string
51+
);
52+
53+
await prismadb.userSubscription.update({
54+
where: {
55+
stripeSubscriptionId: subscription.id,
56+
},
57+
data: {
58+
stripePriceId: subscription.items.data[0].price.id,
59+
stripeCurrentPeriodEnd: new Date(
60+
subscription.current_period_end * 1000
61+
),
62+
},
63+
});
64+
}
65+
66+
return new NextResponse(null, { status: 200 });
67+
}

app/layout.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { Metadata } from "next";
21
import { Inter } from "next/font/google";
32
import { ClerkProvider } from "@clerk/nextjs";
43
import { constructMetadata } from "@/lib/metadata";
54

5+
import { ToasterProvider } from "@/components/toaster-provider";
66
import { ModalProvider } from "@/components/modal-provider";
77

88
import "./globals.css";
@@ -18,9 +18,12 @@ export default function RootLayout({
1818
}) {
1919
return (
2020
<ClerkProvider>
21-
<html lang="en">
21+
<html lang="en" suppressHydrationWarning>
2222
<body className={inter.className}>
23+
<ToasterProvider />
24+
2325
<ModalProvider />
26+
2427
{children}
2528
</body>
2629
</html>

components/pro-modal.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import axios from "axios";
44
import { useState } from "react";
55
import { Check, Zap } from "lucide-react";
6-
// import { toast } from "react-hot-toast";
6+
import { toast } from "react-hot-toast";
77

88
import {
99
Dialog,
@@ -31,7 +31,7 @@ export const ProModal = () => {
3131

3232
window.location.href = response.data.url;
3333
} catch (error) {
34-
// toast.error("Something went wrong");
34+
toast.error("Something went wrong");
3535
} finally {
3636
setLoading(false);
3737
}

0 commit comments

Comments
 (0)