Saas Update Subscription

Expanding the User table and session

prisma
1model User {
2  id                 String     @id @default(cuid())
3  name               String?
4  email              String?    @unique
5  emailVerified      DateTime?
6  image              String?
7  role               String?    @default("free user")  // Add a new "role" column
8  accounts           Account[]
9  sessions           Session[]
10
11  stripeCustomerId   String?    @unique
12  subscriptionId     String?
13  subscriptionStatus String?    // Status of the subscription
14
15  createdAt DateTime @default(now())
16  updatedAt DateTime @updatedAt
17}

libs/auth.js

javascript
1. . .
2
3export const { handlers, signIn, signOut, auth } = NextAuth({
4  adapter: PrismaAdapter(prisma),
5  providers: [. . .],
6  pages: {. . .},
7  session: {. . .},
8  callbacks: {
9    session({ session, user }) {
10      session.user.role = user.role;
11      session.user.stripeCustomerId = user.stripeCustomerId;
12      session.user.subscriptionId = user.subscriptionId;
13      session.user.subscriptionStatus = user.subscriptionStatus;
14      return session;
15    },
16  },
17  events: {. . .},
18});

Expanding the user settings page

jsx
1import { auth } from "@/libs/auth";
2import prisma from "@/libs/db";
3import { redirect } from "next/navigation";
4import StripeButton from "@/components/stripeButton";
5
6export default async function SettingsPage() {
7  const session = await auth();
8
9  return (
10    <div className="flex min-h-[80vh] items-center justify-center bg-gray-100">
11      <div className="w-full max-w-md bg-white p-8 rounded-lg shadow-md">
12        <h1 className="text-2xl font-bold text-center mb-6">User Settings</h1>
13        <form
14          action={async (formData) => {
15            "use server";
16            await prisma.user.update({
17              where: {
18                email: session?.user?.email,
19              },
20              data: {
21                name: formData.get("name"),
22              },
23            });
24
25            redirect("/dashboard/settings");
26          }}
27          className="space-y-6">
28          <div>
29            <label
30              htmlFor="name"
31              className="block text-sm font-medium text-gray-700">
32              Name
33            </label>
34            <input
35              type="text"
36              id="name"
37              name="name"
38              defaultValue={session?.user?.name || ""}
39              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
40              required
41            />
42          </div>
43
44          <div>
45            <label
46              htmlFor="email"
47              className="block text-sm font-medium text-gray-700">
48              Email
49            </label>
50            <input
51              type="email"
52              id="email"
53              name="email"
54              defaultValue={session?.user?.email || ""}
55              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
56              required
57            />
58          </div>
59
60          <div>
61            <label
62              htmlFor="role"
63              className="block text-sm font-medium text-gray-700">
64              Role
65            </label>
66            <input
67              type="text"
68              id="role"
69              name="role"
70              defaultValue={session?.user?.role}
71              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 cursor-not-allowed"
72              readOnly
73            />
74          </div>
75
76          {session?.user?.subscriptionStatus && (
77            <div>
78              <label
79                htmlFor="role"
80                className="block text-sm font-medium text-gray-700">
81                Subscription Status
82              </label>
83              <input
84                type="text"
85                id="role"
86                name="role"
87                defaultValue={session?.user?.subscriptionStatus}
88                className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 cursor-not-allowed"
89                readOnly
90              />
91            </div>
92          )}
93
94          <div>
95            <button
96              type="submit"
97              className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
98              Update Profile
99            </button>
100          </div>
101
102          {session?.user?.subscriptionStatus === "active" && (
103            <StripeButton type={"cancel"} />
104          )}
105        </form>
106      </div>
107    </div>
108  );
109}

Cancelling the subscription

src/components/stripeButton.jsx

jsx
1"use client";
2
3import Button from "@/components/ui/button";
4import { useState } from "react";
5
6export default function StripeButton({ type, plan }) {
7  const [loading, setLoading] = useState(false);
8  const [message, setMessage] = useState("");
9  const [confirmOpen, setConfirmOpen] = useState(false);
10
11  async function checkout(plan) {
12    setLoading(true);
13
14    const response = await fetch(`/api/stripe/${plan}/checkout`, {
15      method: "POST",
16      headers: {
17        "Content-Type": "application/json",
18      },
19    });
20
21    setLoading(false);
22
23    if (response.ok) {
24      const data = await response.json();
25      window.open(data.url, "_target");
26    } else {
27      setMessage("Something went wrong.");
28    }
29  }
30
31  async function cancel() {
32    setLoading(true);
33
34    const response = await fetch(`/api/stripe/cancel`, {
35      method: "POST",
36      headers: {
37        "Content-Type": "application/json",
38      },
39    });
40
41    setLoading(false);
42
43    if (response.ok) {
44      setMessage("Your subscription has been successfully canceled.");
45    } else {
46      setMessage("Something went wrong.");
47    }
48  }
49
50  return (
51    <>
52      {type === "checkout" && (
53        <Button
54          style="primary"
55          loading={loading}
56          className="w-full text-center"
57          onClick={() => checkout(plan)}>
58          Get this plan
59        </Button>
60      )}
61
62      {type === "cancel" && (
63        <>
64          <div
65            className={`fixed top-0 left-0 w-full h-full grid place-items-center ${
66              confirmOpen ? "block" : "hidden"
67            }`}>
68            <div
69              className="fixed top-0 left-0 w-full h-full bg-gray-900 dark:bg-gray-100 backdrop-blur-md z-0"
70              onClick={() => setConfirmOpen(false)}
71            />
72
73            <div className="max-w-96 bg-gray-100 shadow-md rounded-md p-8 flex flex-col gap-4 z-10">
74              <h2 className="text-3xl">We are sorry to see you leave</h2>
75              <p>Are you sure?</p>
76
77              <Button
78                style="danger"
79                loading={loading}
80                className="w-full text-center"
81                onClick={cancel}>
82                Yes, I&apos;d like to cancel my subscription.
83              </Button>
84
85              <Button style="primary" onClick={() => setConfirmOpen(false)}>
86                Never mind.
87              </Button>
88
89              {message && <p>{message}</p>}
90            </div>
91          </div>
92          <Button
93            style="danger"
94            loading={loading}
95            className="w-full text-center"
96            onClick={() => setConfirmOpen(true)}>
97            Cancel my subscription
98          </Button>
99        </>
100      )}
101
102      <p>{message}</p>
103    </>
104  );
105}

Cancel my subscription

Updating webhook

src/app/api/stripe/webhook/route.js

javascript
1import Stripe from "stripe";
2import prisma from "@/libs/db";
3import { headers } from "next/headers";
4
5export async function POST(request) {
6  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8  // Get the Stripe signature
9  const headersList = await headers();
10  const signature = headersList.get("stripe-signature");
11
12  // Getting the raw body
13  const body = await request.text();
14
15  // Constructing the event with raw body, signature, and webhook secret
16  const event = stripe.webhooks.constructEvent(
17    body,
18    signature,
19    process.env.STRIPE_WEBHOOK_SECRET
20  );
21
22  switch (event.type) {
23    // Handling subscription creation
24    case "customer.subscription.created": {
25      await prisma.user.update({
26        where: { stripeCustomerId: event.data.object.customer },
27        data: {
28          subscriptionId: event.data.object.id,
29          subscriptionStatus: event.data.object.status,
30          role:
31            event.data.object.status === "active" || "trialing"
32              ? "paid user"
33              : "free user",
34        },
35      });
36
37      break;
38    }
39
40    case "customer.subscription.deleted": {
41      await prisma.user.update({
42        where: { stripeCustomerId: event.data.object.customer },
43        data: {
44          subscriptionStatus: event.data.object.status,
45          role: "free user",
46        },
47      });
48      break;
49    }
50
51    default:
52      console.warn(`Unhandled event type: ${event.type}`);
53  }
54
55  return new Response("Stripe success", {
56    status: 200,
57  });
58}

Stripe simulation

Subscription pending cancellation

Stripe simulation

Stripe simulation select time

Stripe simulation advance time

Dashboard cancelled subscription