diff --git a/.env b/.env index 498ab17..f734baa 100644 --- a/.env +++ b/.env @@ -1 +1,7 @@ -DATABASE_URL=postgres://postgres:postgres@localhost:5432/{DB_NAME} \ No newline at end of file +DATABASE_URL=postgres://postgres:postgres@localhost:5432/{DB_NAME} +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +NEXT_PUBLIC_STRIPE_PRO_PRICE_ID= +NEXT_PUBLIC_STRIPE_MAX_PRICE_ID= +NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID= \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index bc0fcb0..f8c077a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/kirimase.config.json b/kirimase.config.json index c2baa9a..9a70cf2 100644 --- a/kirimase.config.json +++ b/kirimase.config.json @@ -1,18 +1,19 @@ { "hasSrc": true, "packages": [ - "shadcn-ui", "drizzle", - "lucia" + "lucia", + "shadcn-ui", + "stripe" ], "preferredPackageManager": "bun", "t3": false, "alias": "@", "analytics": true, "rootPath": "src/", - "componentLib": "shadcn-ui", - "driver": "pg", - "provider": "postgresjs", "orm": "drizzle", - "auth": "lucia" + "auth": "lucia", + "componentLib": "shadcn-ui", + "provider": "node-postgres", + "driver": "pg" } \ No newline at end of file diff --git a/package.json b/package.json index 1385473..de03151 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "db:drop": "drizzle-kit drop", "db:pull": "drizzle-kit introspect:pg", "db:studio": "drizzle-kit studio", - "db:check": "drizzle-kit check:pg" + "db:check": "drizzle-kit check:pg", + "stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe" }, "dependencies": { "@lucia-auth/adapter-drizzle": "^1.0.4", @@ -22,6 +23,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@stripe/stripe-js": "^3.0.10", "@t3-oss/env-nextjs": "^0.9.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -37,6 +39,7 @@ "react": "^18", "react-dom": "^18", "sonner": "^1.4.3", + "stripe": "^14.21.0", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" diff --git a/src/app/(app)/account/PlanSettings.tsx b/src/app/(app)/account/PlanSettings.tsx new file mode 100644 index 0000000..2f32766 --- /dev/null +++ b/src/app/(app)/account/PlanSettings.tsx @@ -0,0 +1,73 @@ +"use client"; +import { + AccountCard, + AccountCardBody, + AccountCardFooter, +} from "./AccountCard"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { AuthSession } from "@/lib/auth/utils"; + +interface PlanSettingsProps { + stripeSubscriptionId: string | null; + stripeCurrentPeriodEnd: Date | null; + stripeCustomerId: string | null; + isSubscribed: boolean | "" | null; + isCanceled: boolean; + id?: string | undefined; + name?: string | undefined; + description?: string | undefined; + stripePriceId?: string | undefined; + price?: number | undefined; +} +export default function PlanSettings({ + subscriptionPlan, + session, +}: { + subscriptionPlan: PlanSettingsProps; + session: AuthSession["session"]; +}) { + return ( + + + {subscriptionPlan.isSubscribed ? ( +

+ ${subscriptionPlan.price ? subscriptionPlan.price / 100 : 0} / month +

+ ) : null} + {subscriptionPlan.stripeCurrentPeriodEnd ? ( +

+ Your plan will{" "} + {!subscriptionPlan.isSubscribed + ? null + : subscriptionPlan.isCanceled + ? "cancel" + : "renew"} + {" on "} + + {subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString( + "en-us" + )} + +

+ ) : null} +
+ + + + + +
+ ); +} diff --git a/src/app/(app)/account/billing/ManageSubscription.tsx b/src/app/(app)/account/billing/ManageSubscription.tsx new file mode 100644 index 0000000..ba66d9d --- /dev/null +++ b/src/app/(app)/account/billing/ManageSubscription.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import React from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +interface ManageUserSubscriptionButtonProps { + userId: string; + email: string; + isCurrentPlan: boolean; + isSubscribed: boolean; + stripeCustomerId?: string | null; + stripePriceId: string; +} + +export function ManageUserSubscriptionButton({ + userId, + email, + isCurrentPlan, + isSubscribed, + stripeCustomerId, + stripePriceId, +}: ManageUserSubscriptionButtonProps) { + const [isPending, startTransition] = React.useTransition(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + startTransition(async () => { + try { + const res = await fetch("/api/billing/manage-subscription", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + userId, + isSubscribed, + isCurrentPlan, + stripeCustomerId, + stripePriceId, + }), + }); + const session: { url: string } = await res.json(); + if (session) { + window.location.href = session.url ?? "/dashboard/billing"; + } + } catch (err) { + console.error((err as Error).message); + toast.error("Something went wrong, please try again later."); + } + }); + }; + + return ( +
+ +
+ ); +} diff --git a/src/app/(app)/account/billing/SuccessToast.tsx b/src/app/(app)/account/billing/SuccessToast.tsx new file mode 100644 index 0000000..a4700c1 --- /dev/null +++ b/src/app/(app)/account/billing/SuccessToast.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { toast } from "sonner"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +export default function SuccessToast() { + const searchParams = useSearchParams(); + + const success = searchParams.get("success") as Boolean | null; + useEffect(() => { + if (success) { + toast.success("Successfully updated subscription."); + } + }, [success]); + + return null; +} diff --git a/src/app/(app)/account/billing/page.tsx b/src/app/(app)/account/billing/page.tsx new file mode 100644 index 0000000..3027fd9 --- /dev/null +++ b/src/app/(app)/account/billing/page.tsx @@ -0,0 +1,112 @@ +import SuccessToast from "./SuccessToast"; +import { ManageUserSubscriptionButton } from "./ManageSubscription"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { storeSubscriptionPlans } from "@/config/subscriptions"; +import { checkAuth, getUserAuth } from "@/lib/auth/utils"; +import { getUserSubscriptionPlan } from "@/lib/stripe/subscription"; +import { CheckCircle2Icon } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +export default async function Billing() { + await checkAuth(); + const { session } = await getUserAuth(); + const subscriptionPlan = await getUserSubscriptionPlan(); + + if (!session) return redirect("/"); + + return ( +
+ + + + +

Billing

+ +

+ Subscription Details +

+

+ {subscriptionPlan.name} +

+

+ {!subscriptionPlan.isSubscribed + ? "You are not subscribed to any plan." + : subscriptionPlan.isCanceled + ? "Your plan will be canceled on " + : "Your plan renews on "} + {subscriptionPlan?.stripeCurrentPeriodEnd + ? subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString() + : null} +

+
+
+ {storeSubscriptionPlans.map((plan) => ( + + {plan.name === subscriptionPlan.name ? ( +
+
+ Current Plan +
+
+ ) : null} + + {plan.name} + {plan.description} + + +
+

+ ${plan.price / 100} / month +

+
+
    + {plan.features.map((feature, i) => ( +
  • + + {feature} +
  • + ))} +
+
+ + {session?.user.email ? ( + + ) : ( +
+ + + +
+ )} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx index 0588287..9a0292c 100644 --- a/src/app/(app)/account/page.tsx +++ b/src/app/(app)/account/page.tsx @@ -1,14 +1,18 @@ import UserSettings from "./UserSettings"; +import PlanSettings from "./PlanSettings"; import { checkAuth, getUserAuth } from "@/lib/auth/utils"; +import { getUserSubscriptionPlan } from "@/lib/stripe/subscription"; export default async function Account() { await checkAuth(); const { session } = await getUserAuth(); + const subscriptionPlan = await getUserSubscriptionPlan(); return (

Account

+
diff --git a/src/app/api/billing/manage-subscription/route.ts b/src/app/api/billing/manage-subscription/route.ts new file mode 100644 index 0000000..399a609 --- /dev/null +++ b/src/app/api/billing/manage-subscription/route.ts @@ -0,0 +1,51 @@ +import { stripe } from "@/lib/stripe/index"; +import { absoluteUrl } from "@/lib/utils"; + +interface ManageStripeSubscriptionActionProps { + isSubscribed: boolean; + stripeCustomerId?: string | null; + isCurrentPlan: boolean; + stripePriceId: string; + email: string; + userId: string; +} + +export async function POST(req: Request) { + const body: ManageStripeSubscriptionActionProps = await req.json(); + const { isSubscribed, stripeCustomerId, userId, stripePriceId, email } = body; + console.log(body); + const billingUrl = absoluteUrl("/account/billing"); + + if (isSubscribed && stripeCustomerId) { + const stripeSession = await stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: billingUrl, + }); + + return new Response(JSON.stringify({ url: stripeSession.url }), { + status: 200, + }); + } + + const stripeSession = await stripe.checkout.sessions.create({ + success_url: billingUrl.concat("?success=true"), + cancel_url: billingUrl, + payment_method_types: ["card"], + mode: "subscription", + billing_address_collection: "auto", + customer_email: email, + line_items: [ + { + price: stripePriceId, + quantity: 1, + }, + ], + metadata: { + userId, + }, + }); + + return new Response(JSON.stringify({ url: stripeSession.url }), { + status: 200, + }); +} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..1824a4a --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,98 @@ +import { db } from "@/lib/db/index"; +import { stripe } from "@/lib/stripe/index"; +import { headers } from "next/headers"; +import type Stripe from "stripe"; +import { subscriptions } from "@/lib/db/schema/subscriptions"; +import { eq } from "drizzle-orm"; + +export async function POST(request: Request) { + const body = await request.text(); + const signature = headers().get("Stripe-Signature") ?? ""; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET || "" + ); + console.log(event.type); + } catch (err) { + return new Response( + `Webhook Error: ${err instanceof Error ? err.message : "Unknown Error"}`, + { status: 400 } + ); + } + + const session = event.data.object as Stripe.Checkout.Session; + // console.log("this is the session metadata -> ", session); + + if (!session?.metadata?.userId && session.customer == null) { + console.error("session customer", session.customer); + console.error("no metadata for userid"); + return new Response(null, { + status: 200, + }); + } + + if (event.type === "checkout.session.completed") { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + const updatedData = { + stripeSubscriptionId: subscription.id, + stripeCustomerId: subscription.customer as string, + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), + }; + + if (session?.metadata?.userId != null) { + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, session.metadata.userId)); + if (sub != undefined) { + await db + .update(subscriptions) + .set(updatedData) + .where(eq(subscriptions.userId, sub.userId!)); + } else { + await db + .insert(subscriptions) + .values({ ...updatedData, userId: session.metadata.userId }); + } + + } else if ( + typeof session.customer === "string" && + session.customer != null + ) { + await db + .update(subscriptions) + .set(updatedData) + .where(eq(subscriptions.stripeCustomerId, session.customer)); + + } + } + + if (event.type === "invoice.payment_succeeded") { + // Retrieve the subscription details from Stripe. + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + + // Update the price id and set the new period end. + await db + .update(subscriptions) + .set({ + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }) + .where(eq(subscriptions.stripeSubscriptionId, subscription.id)); + + } + + return new Response(null, { status: 200 }); +} diff --git a/src/config/subscriptions.ts b/src/config/subscriptions.ts new file mode 100644 index 0000000..aa52a09 --- /dev/null +++ b/src/config/subscriptions.ts @@ -0,0 +1,35 @@ +export interface SubscriptionPlan { + id: string; + name: string; + description: string; + stripePriceId: string; + price: number; + features: Array; +} + +export const storeSubscriptionPlans: SubscriptionPlan[] = [ + { + id: "pro", + name: "Pro", + description: "Pro tier that offers x, y, and z features.", + stripePriceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID ?? "", + price: 1000, + features: ["Feature 1", "Feature 2", "Feature 3"], + }, + { + id: "max", + name: "Max", + description: "Super Pro tier that offers x, y, and z features.", + stripePriceId: process.env.NEXT_PUBLIC_STRIPE_MAX_PRICE_ID ?? "", + price: 3000, + features: ["Feature 1", "Feature 2", "Feature 3"], + }, + { + id: "ultra", + name: "Ultra", + description: "Ultra Pro tier that offers x, y, and z features.", + stripePriceId: process.env.NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID ?? "", + price: 5000, + features: ["Feature 1", "Feature 2", "Feature 3"], + }, +]; diff --git a/src/lib/db/schema/subscriptions.ts b/src/lib/db/schema/subscriptions.ts new file mode 100644 index 0000000..d2a3f55 --- /dev/null +++ b/src/lib/db/schema/subscriptions.ts @@ -0,0 +1,25 @@ +import { + pgTable, + primaryKey, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; + +export const subscriptions = pgTable( + "subscriptions", + { + userId: varchar("user_id", { length: 255 }) + .unique(), + stripeCustomerId: varchar("stripe_customer_id", { length: 255 }).unique(), + stripeSubscriptionId: varchar("stripe_subscription_id", { + length: 255, + }).unique(), + stripePriceId: varchar("stripe_price_id", { length: 255 }), + stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"), + }, + (table) => { + return { + pk: primaryKey(table.userId, table.stripeCustomerId), + }; + } +); diff --git a/src/lib/env.mjs b/src/lib/env.mjs index 6a0a110..2b4f3cd 100644 --- a/src/lib/env.mjs +++ b/src/lib/env.mjs @@ -8,9 +8,14 @@ export const env = createEnv({ .default("development"), DATABASE_URL: z.string().min(1), + STRIPE_SECRET_KEY: z.string().min(1), + STRIPE_WEBHOOK_SECRET: z.string().min(1), }, client: { - // NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1), + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), + NEXT_PUBLIC_STRIPE_PRO_PRICE_ID: z.string().min(1), + NEXT_PUBLIC_STRIPE_MAX_PRICE_ID: z.string().min(1), + NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID: z.string().min(1), // NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1), }, // If you're using Next.js < 13.4.4, you'll need to specify the runtimeEnv manually // runtimeEnv: { @@ -19,6 +24,9 @@ export const env = createEnv({ // }, // For Next.js >= 13.4.4, you only need to destructure client variables: experimental__runtimeEnv: { - // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY, + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, + NEXT_PUBLIC_STRIPE_PRO_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID, + NEXT_PUBLIC_STRIPE_MAX_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_MAX_PRICE_ID, + NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID, // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY, }, }); diff --git a/src/lib/stripe/index.ts b/src/lib/stripe/index.ts new file mode 100644 index 0000000..3e111f2 --- /dev/null +++ b/src/lib/stripe/index.ts @@ -0,0 +1,6 @@ +import Stripe from "stripe"; + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", { + apiVersion: "2023-10-16", + typescript: true, +}); diff --git a/src/lib/stripe/subscription.ts b/src/lib/stripe/subscription.ts new file mode 100644 index 0000000..14fcdd3 --- /dev/null +++ b/src/lib/stripe/subscription.ts @@ -0,0 +1,61 @@ +import { storeSubscriptionPlans } from "@/config/subscriptions"; +import { db } from "@/lib/db/index"; +import { subscriptions } from "@/lib/db/schema/subscriptions"; +import { eq } from "drizzle-orm"; +import { stripe } from "@/lib/stripe/index"; +import { getUserAuth } from "@/lib/auth/utils"; + +export async function getUserSubscriptionPlan() { + const { session } = await getUserAuth(); + + if (!session || !session.user) { + throw new Error("User not found."); + } + + const [ subscription ] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, session.user.id)); + + if (!subscription) + return { + id: undefined, + name: undefined, + description: undefined, + stripePriceId: undefined, + price: undefined, + stripeSubscriptionId: null, + stripeCurrentPeriodEnd: null, + stripeCustomerId: null, + isSubscribed: false, + isCanceled: false, + }; + + const isSubscribed = + subscription.stripePriceId && + subscription.stripeCurrentPeriodEnd && + subscription.stripeCurrentPeriodEnd.getTime() + 86_400_000 > Date.now(); + + const plan = isSubscribed + ? storeSubscriptionPlans.find( + (plan) => plan.stripePriceId === subscription.stripePriceId + ) + : null; + + let isCanceled = false; + if (isSubscribed && subscription.stripeSubscriptionId) { + const stripePlan = await stripe.subscriptions.retrieve( + subscription.stripeSubscriptionId + ); + isCanceled = stripePlan.cancel_at_period_end; + } + + return { + ...plan, + stripeSubscriptionId: subscription.stripeSubscriptionId, + stripeCurrentPeriodEnd: subscription.stripeCurrentPeriodEnd, + stripeCustomerId: subscription.stripeCustomerId, + isSubscribed, + isCanceled, + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 199deca..ef66810 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -7,3 +7,9 @@ export function cn(...inputs: ClassValue[]) { } export const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789"); + +export function absoluteUrl(path: string) { + return `${ + process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" + }${path}`; +} \ No newline at end of file