Skip to content

feat: added stripe subscription flow #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=

# AI secrets
OPEN_AI_ORG=
OPEN_AI_TOKEN=

# Databases
Expand Down
2 changes: 1 addition & 1 deletion actions/auth/get-current-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { getServerSession } from 'next-auth/next';

import { authOptions } from '@/lib/auth';
import { authOptions } from '@/lib/auth/auth';

export const getCurrentUser = async () => {
const session = await getServerSession(authOptions);
Expand Down
22 changes: 22 additions & 0 deletions actions/auth/get-updated-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use server';

import { TEN_MINUTE_SEC } from '@/constants/common';
import { fetchCachedData } from '@/lib/cache';
import { db } from '@/lib/db';

export const getUpdatedUser = async (userId = '') => {
const updatedUser = await fetchCachedData(
`updated-user-[${userId}]`,
async () => {
const updatedUser = await db.user.findUnique({
where: { id: userId },
select: { role: true },
});

return { role: updatedUser?.role };
},
TEN_MINUTE_SEC,
);

return updatedUser;
};
7 changes: 6 additions & 1 deletion actions/auth/login-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ export const loginUser = async (
name?: string | null,
pictureUrl?: string | null,
) => {
const existingUser = await db.user.findUnique({ where: { email } });
const existingUser = await db.user.findUnique({
where: { email },
include: { stripeSubscription: true },
});

if (existingUser) {
return {
hasSubscription: Boolean(existingUser.stripeSubscription),
id: existingUser.id,
image: existingUser.pictureUrl,
isPublic: existingUser.isPublic,
Expand Down Expand Up @@ -80,6 +84,7 @@ export const loginUser = async (
});

return {
hasSubscription: false,
id: user.id,
image: user.pictureUrl,
isPublic: user.isPublic,
Expand Down
70 changes: 70 additions & 0 deletions actions/stripe/get-user-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use server';

import { StripeSubscriptionPeriod } from '@prisma/client';
import { compareAsc, fromUnixTime } from 'date-fns';

import { ONE_DAY_SEC } from '@/constants/common';
import { fetchCachedData } from '@/lib/cache';
import { db } from '@/lib/db';
import { stripe } from '@/server/stripe';

export const getUserSubscription = async (userId = '', noCache = false) => {
try {
const callback = async () => {
const userSubscription = await db.stripeSubscription.findUnique({
where: { userId },
});

if (!userSubscription) {
return null;
}

const stripeSubscription = await stripe.subscriptions.retrieve(
userSubscription.stripeSubscriptionId,
);

if (!stripeSubscription) {
return null;
}

if (
stripeSubscription.cancel_at &&
compareAsc(fromUnixTime(stripeSubscription.cancel_at), Date.now()) < 0
) {
await db.stripeSubscription.delete({
where: { stripeSubscriptionId: stripeSubscription.id },
});

return null;
}

const planDescription = await db.stripeSubscriptionDescription.findFirst({
where: {
period: `${stripeSubscription.items.data[0].plan.interval}ly` as StripeSubscriptionPeriod,
},
});

return {
cancelAt: stripeSubscription.cancel_at ? fromUnixTime(stripeSubscription.cancel_at) : null,
endPeriod: fromUnixTime(stripeSubscription.current_period_end),
price: {
currency: stripeSubscription.items.data[0].price.currency,
unitAmount: stripeSubscription.items.data[0].price.unit_amount,
},
plan: stripeSubscription.items.data[0].plan,
planName: planDescription?.name ?? 'Nova Plus',
startPeriod: fromUnixTime(stripeSubscription.current_period_start),
};
};

const subscription = noCache
? await callback()
: await fetchCachedData(`user-subscription-[${userId}]`, callback, ONE_DAY_SEC);

return subscription;
} catch (error) {
console.error('[GET_USER_SUBSCRIPTION]', error);

return null;
}
};
3 changes: 1 addition & 2 deletions app/(chat)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { getCurrentUser } from '@/actions/auth/get-current-user';
import { getGlobalProgress } from '@/actions/courses/get-global-progress';
import { getUserNotifications } from '@/actions/users/get-user-notifications';
import { NavBar } from '@/components/navbar/navbar';
import { UserRole } from '@/constants/auth';

export const metadata: Metadata = {
title: 'Chat AI',
Expand All @@ -24,7 +23,7 @@ const ChatLayout = async ({ children }: ChatLayoutProps) => {
take: 5,
});

if (![UserRole.ADMIN, UserRole.TEACHER].includes(user?.role as UserRole)) {
if (!user?.hasSubscription) {
return redirect('/');
}

Expand Down
2 changes: 0 additions & 2 deletions app/(dashboard)/(routes)/landing-course/[courseId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,6 @@ const LandingCourseIdPage = async ({ params }: LandingCourseIdPageProps) => {
)}
</div>
</div>
{/* TODO: External recourses. [https://trello.com/c/R4RkoqmC/13-add-external-resources-for-landing-course-page] */}
{/* <div className="w-full flex space-x-4"></div> */}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ type LeadersTableProps = {
userId: string;
xp: number;
}[];
hasSubscription?: boolean;
userId?: string;
};

export const LeadersTable = ({ leaders, userId }: LeadersTableProps) => {
export const LeadersTable = ({ leaders, hasSubscription = false, userId }: LeadersTableProps) => {
const filteredLeaders = leaders.filter((leader) => leader.userId !== userId);
const currentLeader = leaders.find((leader) => leader.userId === userId);

Expand Down Expand Up @@ -54,11 +55,12 @@ export const LeadersTable = ({ leaders, userId }: LeadersTableProps) => {
<div className="flex space-x-2 items-center">
<p className="text-small font-semibold">{leader.name}</p>
{leader.userId === userId && <TextBadge label="You" variant="indigo" />}
{hasSubscription && <TextBadge label="Nova&nbsp;Plus" variant="lime" />}
</div>
</div>
</TableCell>
<TableCell className="text-right">
<TextBadge label={String(leader.xp)} variant="lime" />
<TextBadge label={String(leader.xp)} variant="yellow" />
</TableCell>
</TableRow>
))}
Expand Down
6 changes: 5 additions & 1 deletion app/(dashboard)/(routes)/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ const LeaderBoard = async () => {
return (
<Suspense fallback={<LeaderBoardSkeleton />}>
<div className="p-6 space-y-4">
<LeadersTable leaders={leaders} userId={user?.userId} />
<LeadersTable
hasSubscription={user?.hasSubscription}
leaders={leaders}
userId={user?.userId}
/>
</div>
</Suspense>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client';

import { format } from 'date-fns';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';

import { getUserSubscription } from '@/actions/stripe/get-user-subscription';
import { Banner } from '@/components/common/banner';
import { Button, Card, CardContent } from '@/components/ui';
import { TIMESTAMP_SUBSCRIPTION_TEMPLATE } from '@/constants/common';
import { fetcher } from '@/lib/fetcher';

type ActivePlanProps = {
userSubscription: Awaited<ReturnType<typeof getUserSubscription>>;
};

export const ActivePlan = ({ userSubscription }: ActivePlanProps) => {
const pathname = usePathname();

const [isFetching, setIsFetching] = useState(false);

const handleManageSubscription = async () => {
setIsFetching(true);

await toast.promise(
fetcher.post('/api/payments/subscription', {
body: {
returnUrl: pathname,
},
responseType: 'json',
}),
{
loading: 'Subscription processing...',
success: (data) => {
setIsFetching(false);

window.location.assign(data.url);

return 'Checkout';
},
error: () => {
setIsFetching(false);

return 'Something went wrong';
},
},
);
};
return (
<div className="flex flex-col gap-4">
<p className="font-medium text-xl">Active Plan</p>
{userSubscription?.cancelAt && (
<Banner
label={`Your subscription will be canceled and all resources will be stopped on ${format(userSubscription.endPeriod, TIMESTAMP_SUBSCRIPTION_TEMPLATE)}. Renew your subscription now to continue using Nova Plus.`}
variant="warning"
/>
)}
{userSubscription && (
<Card className="shadow-none rounded-sm">
<CardContent>
<div className="pt-6 flex flex-col justify-center space-y-2 mb-4">
<p className="text-lg font-semibold">{userSubscription.planName}</p>
<p className="text-sm">
Your subscription will be {userSubscription?.cancelAt ? 'cancelled' : 'renewed'} on{' '}
{format(userSubscription.endPeriod, TIMESTAMP_SUBSCRIPTION_TEMPLATE)}
</p>
</div>
<Button disabled={isFetching} isLoading={isFetching} onClick={handleManageSubscription}>
Manage Subscription
</Button>
</CardContent>
</Card>
)}
{!userSubscription && (
<div className="">
<p className="text-sm">You do not have any active subscriptions.</p>
</div>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import { getUserBilling } from '@/actions/stripe/get-user-billing';
import { DataTable } from '@/components/data-table/data-table';

import { columns } from './data-table/columns';

type BillingHistoryProps = {
userBilling: Awaited<ReturnType<typeof getUserBilling>>;
};

export const BillingHistory = ({ userBilling }: BillingHistoryProps) => {
return (
<div className="flex flex-col gap-4 mt-8">
<p className="font-medium text-xl">Billing History</p>
<DataTable
columns={columns}
data={userBilling}
isServerSidePagination={false}
noLabel="No invoices"
/>
</div>
);
};
16 changes: 7 additions & 9 deletions app/(dashboard)/(routes)/settings/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import { getCurrentUser } from '@/actions/auth/get-current-user';
import { getUserBilling } from '@/actions/stripe/get-user-billing';
import { DataTable } from '@/components/data-table/data-table';
import { getUserSubscription } from '@/actions/stripe/get-user-subscription';

import { columns } from './_components/data-table/columns';
import { ActivePlan } from './_components/active-plan';
import { BillingHistory } from './_components/billing-history';

const BillingPage = async () => {
const user = await getCurrentUser();
const userBilling = await getUserBilling(user?.userId);
const userSubscription = await getUserSubscription(user?.userId, true);

return (
<div className="p-6 flex flex-col">
<h1 className="text-2xl font-medium">Billing History</h1>
<h1 className="text-2xl font-medium">Billing & Subscription</h1>
<div className="mt-12">
<DataTable
columns={columns}
data={userBilling}
isServerSidePagination={false}
noLabel="No invoices"
/>
<ActivePlan userSubscription={userSubscription} />
<BillingHistory userBilling={userBilling} />
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import NextAuth from 'next-auth';

import { authOptions } from '@/lib/auth';
import { authOptions } from '@/lib/auth/auth';

const handler = NextAuth(authOptions);

Expand Down
5 changes: 3 additions & 2 deletions app/api/courses/[courseId]/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Stripe from 'stripe';

import { getCurrentUser } from '@/actions/auth/get-current-user';
import { db } from '@/lib/db';
import { absoluteUrl } from '@/lib/utils';
import { stripe } from '@/server/stripe';

export const POST = async (req: NextRequest, { params }: { params: { courseId: string } }) => {
Expand Down Expand Up @@ -64,7 +65,7 @@ export const POST = async (req: NextRequest, { params }: { params: { courseId: s

const session = await stripe.checkout.sessions.create({
allow_promotion_codes: true,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/landing-course/${course.id}?canceled=true`,
cancel_url: absoluteUrl(`/landing-course/${course.id}?canceled=true`),
customer: stripeCustomer.stripeCustomerId,
expires_at: getUnixTime(addSeconds(Date.now(), 3600)),
payment_method_types: ['card'],
Expand All @@ -73,7 +74,7 @@ export const POST = async (req: NextRequest, { params }: { params: { courseId: s
},
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/courses/${course.id}?success=true`,
success_url: absoluteUrl(`/courses/${course.id}?success=true`),
metadata: {
...details,
courseId: course.id,
Expand Down
5 changes: 3 additions & 2 deletions app/api/payments/stripe-connect/[userId]/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/actions/auth/get-current-user';
import { DEFAULT_COUNTRY_CODE } from '@/constants/locale';
import { db } from '@/lib/db';
import { absoluteUrl } from '@/lib/utils';
import { stripe } from '@/server/stripe';

export const POST = async (_: NextRequest, { params }: { params: { userId: string } }) => {
Expand Down Expand Up @@ -53,8 +54,8 @@ export const POST = async (_: NextRequest, { params }: { params: { userId: strin

const connectAccountLink = await stripe.accountLinks.create({
account: connectAccountId,
refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/teacher/analytics`,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/teacher/analytics`,
refresh_url: absoluteUrl('/teacher/analytics'),
return_url: absoluteUrl('/teacher/analytics'),
type: 'account_onboarding',
});

Expand Down
Loading
Loading