Skip to content

Commit 37b1186

Browse files
committed
feat(saas): Added Pause, Cancel, Resume current subscription on billing page
1 parent 938375a commit 37b1186

File tree

3 files changed

+247
-15
lines changed

3 files changed

+247
-15
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
cancelPlan,
6+
pausePlan,
7+
resumePlan,
8+
} from "@/server/actions/plans/mutations";
9+
import { type getOrgSubscription } from "@/server/actions/plans/query";
10+
import { useMutation } from "@tanstack/react-query";
11+
import { useRouter } from "next/navigation";
12+
import { toast } from "sonner";
13+
import { useAwaitableTransition } from "@/hooks/use-awaitable-transition";
14+
import { Icons } from "@/components/ui/icons";
15+
16+
type CancelAndPauseBtnProps = {
17+
subscription: Awaited<ReturnType<typeof getOrgSubscription>>;
18+
};
19+
20+
export function CancelPauseResumeBtns({
21+
subscription,
22+
}: CancelAndPauseBtnProps) {
23+
const router = useRouter();
24+
25+
const [, startAwaitableTransition] = useAwaitableTransition();
26+
27+
const { isPending: isCancelling, mutate: cancelMutate } = useMutation({
28+
mutationFn: async () => {
29+
const response = await cancelPlan();
30+
await startAwaitableTransition(() => {
31+
router.refresh();
32+
});
33+
return response;
34+
},
35+
onError: () => {
36+
toast.error("Failed to cancel plan");
37+
},
38+
onSuccess: () => {
39+
toast.success("Plan cancelled successfully");
40+
},
41+
});
42+
43+
const { isPending: isResuming, mutate: resumeMutate } = useMutation({
44+
mutationFn: async () => {
45+
const response = await resumePlan();
46+
await startAwaitableTransition(() => {
47+
router.refresh();
48+
});
49+
return response;
50+
},
51+
onError: () => {
52+
toast.error("Failed to resume plan");
53+
},
54+
onSuccess: () => {
55+
toast.success("Plan resumed successfully");
56+
},
57+
});
58+
59+
const { isPending: isPausing, mutate: pauseMutate } = useMutation({
60+
mutationFn: async () => {
61+
const response = await pausePlan();
62+
await startAwaitableTransition(() => {
63+
router.refresh();
64+
});
65+
return response;
66+
},
67+
onError: () => {
68+
toast.error("Failed to pause plan");
69+
},
70+
onSuccess: () => {
71+
toast.success("Plan paused successfully");
72+
},
73+
});
74+
75+
const isAllActionsPending = isCancelling || isResuming || isPausing;
76+
77+
if (!subscription) return null;
78+
79+
if (subscription.status === "active") {
80+
return (
81+
<div className="flex items-center gap-2">
82+
<Button
83+
disabled={isAllActionsPending}
84+
onClick={() => pauseMutate()}
85+
variant="outline"
86+
>
87+
{isPausing && <Icons.loader className="mr-2 h-4 w-4" />}
88+
Pause Plan
89+
</Button>
90+
<Button
91+
onClick={() => cancelMutate()}
92+
disabled={isAllActionsPending}
93+
variant="destructive"
94+
>
95+
{isCancelling && <Icons.loader className="mr-2 h-4 w-4" />}
96+
Cancel Plan
97+
</Button>
98+
</div>
99+
);
100+
}
101+
102+
return (
103+
<Button
104+
disabled={isAllActionsPending}
105+
onClick={() => resumeMutate()}
106+
variant="outline"
107+
>
108+
{isResuming && <Icons.loader className="mr-2 h-4 w-4" />}
109+
Resume Plan
110+
</Button>
111+
);
112+
}

starterkits/saas/src/app/(app)/(user)/org/billing/_components/current-plan.tsx

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CancelPauseResumeBtns } from "@/app/(app)/(user)/org/billing/_components/cancel-pause-resume-btns";
12
import { Badge } from "@/components/ui/badge";
23
import { Button } from "@/components/ui/button";
34
import {
@@ -23,7 +24,7 @@ export async function CurrentPlan() {
2324
</CardDescription>
2425
</CardHeader>
2526
<CardContent className="space-y-3">
26-
<div>
27+
<div className="space-y-1">
2728
<div className="flex items-center gap-2">
2829
<p>
2930
<span className="font-semibold">Plan:</span>{" "}
@@ -59,21 +60,25 @@ export async function CurrentPlan() {
5960
"No expiration"
6061
)}
6162
</p>
62-
<p></p>
6363
</div>
64-
<form
65-
action={async () => {
66-
"use server";
6764

68-
if (subscription?.customerPortalUrl) {
69-
redirect(subscription?.customerPortalUrl);
70-
}
71-
}}
72-
>
73-
<Button disabled={!subscription} variant="outline">
74-
Manage your billing settings
75-
</Button>
76-
</form>
65+
<div className="flex items-center justify-between">
66+
<form
67+
action={async () => {
68+
"use server";
69+
70+
if (subscription?.customerPortalUrl) {
71+
redirect(subscription?.customerPortalUrl);
72+
}
73+
}}
74+
>
75+
<Button disabled={!subscription} variant="outline">
76+
Manage your billing settings
77+
</Button>
78+
</form>
79+
80+
<CancelPauseResumeBtns subscription={subscription} />
81+
</div>
7782
</CardContent>
7883
</Card>
7984
);

starterkits/saas/src/server/actions/plans/mutations.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { db } from "@/server/db";
66
import { subscriptions, webhookEvents } from "@/server/db/schema";
77
import { configureLemonSqueezy } from "@/server/lemonsqueezy";
88
import { webhookHasData, webhookHasMeta } from "@/validations/lemonsqueezy";
9-
import { getPrice, updateSubscription } from "@lemonsqueezy/lemonsqueezy.js";
9+
import {
10+
cancelSubscription,
11+
getPrice,
12+
updateSubscription,
13+
} from "@lemonsqueezy/lemonsqueezy.js";
1014
import { eq } from "drizzle-orm";
1115
import { revalidatePath } from "next/cache";
1216

@@ -194,3 +198,114 @@ export async function changePlan(
194198

195199
return updatedSub;
196200
}
201+
202+
export async function cancelPlan() {
203+
configureLemonSqueezy();
204+
205+
const subscription = await getOrgSubscription();
206+
207+
if (!subscription) {
208+
throw new Error("No subscription found.");
209+
}
210+
211+
const cancelSub = await cancelSubscription(subscription.lemonSqueezyId);
212+
213+
// Save in db
214+
try {
215+
await db
216+
.update(subscriptions)
217+
.set({
218+
status: cancelSub.data?.data.attributes.status,
219+
statusFormatted:
220+
cancelSub.data?.data.attributes.status_formatted,
221+
endsAt: cancelSub.data?.data.attributes.ends_at,
222+
})
223+
.where(
224+
eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId),
225+
);
226+
} catch (error) {
227+
throw new Error(
228+
`Failed to update Subscription #${subscription.lemonSqueezyId} in the database.`,
229+
);
230+
}
231+
232+
revalidatePath("/");
233+
234+
return cancelSub;
235+
}
236+
237+
export async function pausePlan() {
238+
configureLemonSqueezy();
239+
240+
const subscription = await getOrgSubscription();
241+
242+
if (!subscription) {
243+
throw new Error("No subscription found.");
244+
}
245+
246+
const returnedSub = await updateSubscription(subscription.lemonSqueezyId, {
247+
pause: {
248+
mode: "void",
249+
},
250+
});
251+
252+
// Update the db
253+
try {
254+
await db
255+
.update(subscriptions)
256+
.set({
257+
status: returnedSub.data?.data.attributes.status,
258+
statusFormatted:
259+
returnedSub.data?.data.attributes.status_formatted,
260+
endsAt: returnedSub.data?.data.attributes.ends_at,
261+
isPaused: returnedSub.data?.data.attributes.pause !== null,
262+
})
263+
.where(eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId));
264+
} catch (error) {
265+
throw new Error(`Failed to pause Subscription #${subscription.lemonSqueezyId} in the database.`);
266+
}
267+
268+
revalidatePath("/");
269+
270+
return returnedSub;
271+
}
272+
273+
export async function resumePlan() {
274+
configureLemonSqueezy();
275+
276+
const subscription = await getOrgSubscription();
277+
278+
if (!subscription) {
279+
throw new Error("No subscription found.");
280+
}
281+
282+
const returnedSub = await updateSubscription(subscription.lemonSqueezyId, {
283+
cancelled: false,
284+
// @ts-expect-error -- null is a valid value for pause
285+
pause: null,
286+
});
287+
288+
// Update the db
289+
try {
290+
await db
291+
.update(subscriptions)
292+
.set({
293+
status: returnedSub.data?.data.attributes.status,
294+
statusFormatted:
295+
returnedSub.data?.data.attributes.status_formatted,
296+
endsAt: returnedSub.data?.data.attributes.ends_at,
297+
isPaused: returnedSub.data?.data.attributes.pause !== null,
298+
})
299+
.where(
300+
eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId),
301+
);
302+
} catch (error) {
303+
throw new Error(
304+
`Failed to resume Subscription #${subscription.lemonSqueezyId} in the database.`,
305+
);
306+
}
307+
308+
revalidatePath("/");
309+
310+
return returnedSub;
311+
}

0 commit comments

Comments
 (0)