Skip to content

Commit 66b53d1

Browse files
feat: added stripe subscription flow (#39)
* updated scheme * updated scheme * updated auth * added ui for subscription modal * modal refactor * modal refactor * modal refactor * code improvements * code improvements * code improvements * code improvements * code improvements * code improvements * sonar issues
1 parent 2c62972 commit 66b53d1

File tree

39 files changed

+903
-264
lines changed

39 files changed

+903
-264
lines changed

.env.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ YANDEX_CLIENT_ID=
2727
YANDEX_CLIENT_SECRET=
2828

2929
# AI secrets
30-
OPEN_AI_ORG=
3130
OPEN_AI_TOKEN=
3231

3332
# Databases

actions/auth/get-current-user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

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

5-
import { authOptions } from '@/lib/auth';
5+
import { authOptions } from '@/lib/auth/auth';
66

77
export const getCurrentUser = async () => {
88
const session = await getServerSession(authOptions);

actions/auth/get-updated-user.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use server';
2+
3+
import { TEN_MINUTE_SEC } from '@/constants/common';
4+
import { fetchCachedData } from '@/lib/cache';
5+
import { db } from '@/lib/db';
6+
7+
export const getUpdatedUser = async (userId = '') => {
8+
const updatedUser = await fetchCachedData(
9+
`updated-user-[${userId}]`,
10+
async () => {
11+
const updatedUser = await db.user.findUnique({
12+
where: { id: userId },
13+
select: { role: true },
14+
});
15+
16+
return { role: updatedUser?.role };
17+
},
18+
TEN_MINUTE_SEC,
19+
);
20+
21+
return updatedUser;
22+
};

actions/auth/login-user.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ export const loginUser = async (
1010
name?: string | null,
1111
pictureUrl?: string | null,
1212
) => {
13-
const existingUser = await db.user.findUnique({ where: { email } });
13+
const existingUser = await db.user.findUnique({
14+
where: { email },
15+
include: { stripeSubscription: true },
16+
});
1417

1518
if (existingUser) {
1619
return {
20+
hasSubscription: Boolean(existingUser.stripeSubscription),
1721
id: existingUser.id,
1822
image: existingUser.pictureUrl,
1923
isPublic: existingUser.isPublic,
@@ -80,6 +84,7 @@ export const loginUser = async (
8084
});
8185

8286
return {
87+
hasSubscription: false,
8388
id: user.id,
8489
image: user.pictureUrl,
8590
isPublic: user.isPublic,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use server';
2+
3+
import { StripeSubscriptionPeriod } from '@prisma/client';
4+
import { compareAsc, fromUnixTime } from 'date-fns';
5+
6+
import { ONE_DAY_SEC } from '@/constants/common';
7+
import { fetchCachedData } from '@/lib/cache';
8+
import { db } from '@/lib/db';
9+
import { stripe } from '@/server/stripe';
10+
11+
export const getUserSubscription = async (userId = '', noCache = false) => {
12+
try {
13+
const callback = async () => {
14+
const userSubscription = await db.stripeSubscription.findUnique({
15+
where: { userId },
16+
});
17+
18+
if (!userSubscription) {
19+
return null;
20+
}
21+
22+
const stripeSubscription = await stripe.subscriptions.retrieve(
23+
userSubscription.stripeSubscriptionId,
24+
);
25+
26+
if (!stripeSubscription) {
27+
return null;
28+
}
29+
30+
if (
31+
stripeSubscription.cancel_at &&
32+
compareAsc(fromUnixTime(stripeSubscription.cancel_at), Date.now()) < 0
33+
) {
34+
await db.stripeSubscription.delete({
35+
where: { stripeSubscriptionId: stripeSubscription.id },
36+
});
37+
38+
return null;
39+
}
40+
41+
const planDescription = await db.stripeSubscriptionDescription.findFirst({
42+
where: {
43+
period: `${stripeSubscription.items.data[0].plan.interval}ly` as StripeSubscriptionPeriod,
44+
},
45+
});
46+
47+
return {
48+
cancelAt: stripeSubscription.cancel_at ? fromUnixTime(stripeSubscription.cancel_at) : null,
49+
endPeriod: fromUnixTime(stripeSubscription.current_period_end),
50+
price: {
51+
currency: stripeSubscription.items.data[0].price.currency,
52+
unitAmount: stripeSubscription.items.data[0].price.unit_amount,
53+
},
54+
plan: stripeSubscription.items.data[0].plan,
55+
planName: planDescription?.name ?? 'Nova Plus',
56+
startPeriod: fromUnixTime(stripeSubscription.current_period_start),
57+
};
58+
};
59+
60+
const subscription = noCache
61+
? await callback()
62+
: await fetchCachedData(`user-subscription-[${userId}]`, callback, ONE_DAY_SEC);
63+
64+
return subscription;
65+
} catch (error) {
66+
console.error('[GET_USER_SUBSCRIPTION]', error);
67+
68+
return null;
69+
}
70+
};

app/(chat)/layout.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { getCurrentUser } from '@/actions/auth/get-current-user';
55
import { getGlobalProgress } from '@/actions/courses/get-global-progress';
66
import { getUserNotifications } from '@/actions/users/get-user-notifications';
77
import { NavBar } from '@/components/navbar/navbar';
8-
import { UserRole } from '@/constants/auth';
98

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

27-
if (![UserRole.ADMIN, UserRole.TEACHER].includes(user?.role as UserRole)) {
26+
if (!user?.hasSubscription) {
2827
return redirect('/');
2928
}
3029

app/(dashboard)/(routes)/landing-course/[courseId]/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,6 @@ const LandingCourseIdPage = async ({ params }: LandingCourseIdPageProps) => {
118118
)}
119119
</div>
120120
</div>
121-
{/* TODO: External recourses. [https://trello.com/c/R4RkoqmC/13-add-external-resources-for-landing-course-page] */}
122-
{/* <div className="w-full flex space-x-4"></div> */}
123121
</div>
124122
</div>
125123
</div>

app/(dashboard)/(routes)/leaderboard/_components/leaders-table.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ type LeadersTableProps = {
2222
userId: string;
2323
xp: number;
2424
}[];
25+
hasSubscription?: boolean;
2526
userId?: string;
2627
};
2728

28-
export const LeadersTable = ({ leaders, userId }: LeadersTableProps) => {
29+
export const LeadersTable = ({ leaders, hasSubscription = false, userId }: LeadersTableProps) => {
2930
const filteredLeaders = leaders.filter((leader) => leader.userId !== userId);
3031
const currentLeader = leaders.find((leader) => leader.userId === userId);
3132

@@ -54,11 +55,12 @@ export const LeadersTable = ({ leaders, userId }: LeadersTableProps) => {
5455
<div className="flex space-x-2 items-center">
5556
<p className="text-small font-semibold">{leader.name}</p>
5657
{leader.userId === userId && <TextBadge label="You" variant="indigo" />}
58+
{hasSubscription && <TextBadge label="Nova&nbsp;Plus" variant="lime" />}
5759
</div>
5860
</div>
5961
</TableCell>
6062
<TableCell className="text-right">
61-
<TextBadge label={String(leader.xp)} variant="lime" />
63+
<TextBadge label={String(leader.xp)} variant="yellow" />
6264
</TableCell>
6365
</TableRow>
6466
))}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ const LeaderBoard = async () => {
1919
return (
2020
<Suspense fallback={<LeaderBoardSkeleton />}>
2121
<div className="p-6 space-y-4">
22-
<LeadersTable leaders={leaders} userId={user?.userId} />
22+
<LeadersTable
23+
hasSubscription={user?.hasSubscription}
24+
leaders={leaders}
25+
userId={user?.userId}
26+
/>
2327
</div>
2428
</Suspense>
2529
);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use client';
2+
3+
import { format } from 'date-fns';
4+
import { usePathname } from 'next/navigation';
5+
import { useState } from 'react';
6+
import toast from 'react-hot-toast';
7+
8+
import { getUserSubscription } from '@/actions/stripe/get-user-subscription';
9+
import { Banner } from '@/components/common/banner';
10+
import { Button, Card, CardContent } from '@/components/ui';
11+
import { TIMESTAMP_SUBSCRIPTION_TEMPLATE } from '@/constants/common';
12+
import { fetcher } from '@/lib/fetcher';
13+
14+
type ActivePlanProps = {
15+
userSubscription: Awaited<ReturnType<typeof getUserSubscription>>;
16+
};
17+
18+
export const ActivePlan = ({ userSubscription }: ActivePlanProps) => {
19+
const pathname = usePathname();
20+
21+
const [isFetching, setIsFetching] = useState(false);
22+
23+
const handleManageSubscription = async () => {
24+
setIsFetching(true);
25+
26+
await toast.promise(
27+
fetcher.post('/api/payments/subscription', {
28+
body: {
29+
returnUrl: pathname,
30+
},
31+
responseType: 'json',
32+
}),
33+
{
34+
loading: 'Subscription processing...',
35+
success: (data) => {
36+
setIsFetching(false);
37+
38+
window.location.assign(data.url);
39+
40+
return 'Checkout';
41+
},
42+
error: () => {
43+
setIsFetching(false);
44+
45+
return 'Something went wrong';
46+
},
47+
},
48+
);
49+
};
50+
return (
51+
<div className="flex flex-col gap-4">
52+
<p className="font-medium text-xl">Active Plan</p>
53+
{userSubscription?.cancelAt && (
54+
<Banner
55+
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.`}
56+
variant="warning"
57+
/>
58+
)}
59+
{userSubscription && (
60+
<Card className="shadow-none rounded-sm">
61+
<CardContent>
62+
<div className="pt-6 flex flex-col justify-center space-y-2 mb-4">
63+
<p className="text-lg font-semibold">{userSubscription.planName}</p>
64+
<p className="text-sm">
65+
Your subscription will be {userSubscription?.cancelAt ? 'cancelled' : 'renewed'} on{' '}
66+
{format(userSubscription.endPeriod, TIMESTAMP_SUBSCRIPTION_TEMPLATE)}
67+
</p>
68+
</div>
69+
<Button disabled={isFetching} isLoading={isFetching} onClick={handleManageSubscription}>
70+
Manage Subscription
71+
</Button>
72+
</CardContent>
73+
</Card>
74+
)}
75+
{!userSubscription && (
76+
<div className="">
77+
<p className="text-sm">You do not have any active subscriptions.</p>
78+
</div>
79+
)}
80+
</div>
81+
);
82+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use client';
2+
3+
import { getUserBilling } from '@/actions/stripe/get-user-billing';
4+
import { DataTable } from '@/components/data-table/data-table';
5+
6+
import { columns } from './data-table/columns';
7+
8+
type BillingHistoryProps = {
9+
userBilling: Awaited<ReturnType<typeof getUserBilling>>;
10+
};
11+
12+
export const BillingHistory = ({ userBilling }: BillingHistoryProps) => {
13+
return (
14+
<div className="flex flex-col gap-4 mt-8">
15+
<p className="font-medium text-xl">Billing History</p>
16+
<DataTable
17+
columns={columns}
18+
data={userBilling}
19+
isServerSidePagination={false}
20+
noLabel="No invoices"
21+
/>
22+
</div>
23+
);
24+
};

app/(dashboard)/(routes)/settings/billing/page.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
import { getCurrentUser } from '@/actions/auth/get-current-user';
22
import { getUserBilling } from '@/actions/stripe/get-user-billing';
3-
import { DataTable } from '@/components/data-table/data-table';
3+
import { getUserSubscription } from '@/actions/stripe/get-user-subscription';
44

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

78
const BillingPage = async () => {
89
const user = await getCurrentUser();
910
const userBilling = await getUserBilling(user?.userId);
11+
const userSubscription = await getUserSubscription(user?.userId, true);
1012

1113
return (
1214
<div className="p-6 flex flex-col">
13-
<h1 className="text-2xl font-medium">Billing History</h1>
15+
<h1 className="text-2xl font-medium">Billing & Subscription</h1>
1416
<div className="mt-12">
15-
<DataTable
16-
columns={columns}
17-
data={userBilling}
18-
isServerSidePagination={false}
19-
noLabel="No invoices"
20-
/>
17+
<ActivePlan userSubscription={userSubscription} />
18+
<BillingHistory userBilling={userBilling} />
2119
</div>
2220
</div>
2321
);

app/api/auth/[...nextauth]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import NextAuth from 'next-auth';
22

3-
import { authOptions } from '@/lib/auth';
3+
import { authOptions } from '@/lib/auth/auth';
44

55
const handler = NextAuth(authOptions);
66

app/api/courses/[courseId]/checkout/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Stripe from 'stripe';
55

66
import { getCurrentUser } from '@/actions/auth/get-current-user';
77
import { db } from '@/lib/db';
8+
import { absoluteUrl } from '@/lib/utils';
89
import { stripe } from '@/server/stripe';
910

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

6566
const session = await stripe.checkout.sessions.create({
6667
allow_promotion_codes: true,
67-
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/landing-course/${course.id}?canceled=true`,
68+
cancel_url: absoluteUrl(`/landing-course/${course.id}?canceled=true`),
6869
customer: stripeCustomer.stripeCustomerId,
6970
expires_at: getUnixTime(addSeconds(Date.now(), 3600)),
7071
payment_method_types: ['card'],
@@ -73,7 +74,7 @@ export const POST = async (req: NextRequest, { params }: { params: { courseId: s
7374
},
7475
line_items: lineItems,
7576
mode: 'payment',
76-
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/courses/${course.id}?success=true`,
77+
success_url: absoluteUrl(`/courses/${course.id}?success=true`),
7778
metadata: {
7879
...details,
7980
courseId: course.id,

app/api/payments/stripe-connect/[userId]/create/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
44
import { getCurrentUser } from '@/actions/auth/get-current-user';
55
import { DEFAULT_COUNTRY_CODE } from '@/constants/locale';
66
import { db } from '@/lib/db';
7+
import { absoluteUrl } from '@/lib/utils';
78
import { stripe } from '@/server/stripe';
89

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

5455
const connectAccountLink = await stripe.accountLinks.create({
5556
account: connectAccountId,
56-
refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/teacher/analytics`,
57-
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/teacher/analytics`,
57+
refresh_url: absoluteUrl('/teacher/analytics'),
58+
return_url: absoluteUrl('/teacher/analytics'),
5859
type: 'account_onboarding',
5960
});
6061

0 commit comments

Comments
 (0)