Skip to content

Commit

Permalink
feat(saas): Added Pause, Cancel, Resume current subscription on billi…
Browse files Browse the repository at this point in the history
…ng page
  • Loading branch information
alifarooq9 committed Apr 12, 2024
1 parent 2c95e82 commit c49b4a6
Show file tree
Hide file tree
Showing 3 changed files with 247 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import { Button } from "@/components/ui/button";
import {
cancelPlan,
pausePlan,
resumePlan,
} from "@/server/actions/plans/mutations";
import { type getOrgSubscription } from "@/server/actions/plans/query";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useAwaitableTransition } from "@/hooks/use-awaitable-transition";
import { Icons } from "@/components/ui/icons";

type CancelAndPauseBtnProps = {
subscription: Awaited<ReturnType<typeof getOrgSubscription>>;
};

export function CancelPauseResumeBtns({
subscription,
}: CancelAndPauseBtnProps) {
const router = useRouter();

const [, startAwaitableTransition] = useAwaitableTransition();

const { isPending: isCancelling, mutate: cancelMutate } = useMutation({
mutationFn: async () => {
const response = await cancelPlan();
await startAwaitableTransition(() => {
router.refresh();
});
return response;
},
onError: () => {
toast.error("Failed to cancel plan");
},
onSuccess: () => {
toast.success("Plan cancelled successfully");
},
});

const { isPending: isResuming, mutate: resumeMutate } = useMutation({
mutationFn: async () => {
const response = await resumePlan();
await startAwaitableTransition(() => {
router.refresh();
});
return response;
},
onError: () => {
toast.error("Failed to resume plan");
},
onSuccess: () => {
toast.success("Plan resumed successfully");
},
});

const { isPending: isPausing, mutate: pauseMutate } = useMutation({
mutationFn: async () => {
const response = await pausePlan();
await startAwaitableTransition(() => {
router.refresh();
});
return response;
},
onError: () => {
toast.error("Failed to pause plan");
},
onSuccess: () => {
toast.success("Plan paused successfully");
},
});

const isAllActionsPending = isCancelling || isResuming || isPausing;

if (!subscription) return null;

if (subscription.status === "active") {
return (
<div className="flex items-center gap-2">
<Button
disabled={isAllActionsPending}
onClick={() => pauseMutate()}
variant="outline"
>
{isPausing && <Icons.loader className="mr-2 h-4 w-4" />}
Pause Plan
</Button>
<Button
onClick={() => cancelMutate()}
disabled={isAllActionsPending}
variant="destructive"
>
{isCancelling && <Icons.loader className="mr-2 h-4 w-4" />}
Cancel Plan
</Button>
</div>
);
}

return (
<Button
disabled={isAllActionsPending}
onClick={() => resumeMutate()}
variant="outline"
>
{isResuming && <Icons.loader className="mr-2 h-4 w-4" />}
Resume Plan
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CancelPauseResumeBtns } from "@/app/(app)/(user)/org/billing/_components/cancel-pause-resume-btns";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -23,7 +24,7 @@ export async function CurrentPlan() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p>
<span className="font-semibold">Plan:</span>{" "}
Expand Down Expand Up @@ -59,21 +60,25 @@ export async function CurrentPlan() {
"No expiration"
)}
</p>
<p></p>
</div>
<form
action={async () => {
"use server";

if (subscription?.customerPortalUrl) {
redirect(subscription?.customerPortalUrl);
}
}}
>
<Button disabled={!subscription} variant="outline">
Manage your billing settings
</Button>
</form>
<div className="flex items-center justify-between">
<form
action={async () => {
"use server";

if (subscription?.customerPortalUrl) {
redirect(subscription?.customerPortalUrl);
}
}}
>
<Button disabled={!subscription} variant="outline">
Manage your billing settings
</Button>
</form>

<CancelPauseResumeBtns subscription={subscription} />
</div>
</CardContent>
</Card>
);
Expand Down
117 changes: 116 additions & 1 deletion starterkits/saas/src/server/actions/plans/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { db } from "@/server/db";
import { subscriptions, webhookEvents } from "@/server/db/schema";
import { configureLemonSqueezy } from "@/server/lemonsqueezy";
import { webhookHasData, webhookHasMeta } from "@/validations/lemonsqueezy";
import { getPrice, updateSubscription } from "@lemonsqueezy/lemonsqueezy.js";
import {
cancelSubscription,
getPrice,
updateSubscription,
} from "@lemonsqueezy/lemonsqueezy.js";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

Expand Down Expand Up @@ -194,3 +198,114 @@ export async function changePlan(

return updatedSub;
}

export async function cancelPlan() {
configureLemonSqueezy();

const subscription = await getOrgSubscription();

if (!subscription) {
throw new Error("No subscription found.");
}

const cancelSub = await cancelSubscription(subscription.lemonSqueezyId);

// Save in db
try {
await db
.update(subscriptions)
.set({
status: cancelSub.data?.data.attributes.status,
statusFormatted:
cancelSub.data?.data.attributes.status_formatted,
endsAt: cancelSub.data?.data.attributes.ends_at,
})
.where(
eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId),
);
} catch (error) {
throw new Error(
`Failed to update Subscription #${subscription.lemonSqueezyId} in the database.`,
);
}

revalidatePath("/");

return cancelSub;
}

export async function pausePlan() {
configureLemonSqueezy();

const subscription = await getOrgSubscription();

if (!subscription) {
throw new Error("No subscription found.");
}

const returnedSub = await updateSubscription(subscription.lemonSqueezyId, {
pause: {
mode: "void",
},
});

// Update the db
try {
await db
.update(subscriptions)
.set({
status: returnedSub.data?.data.attributes.status,
statusFormatted:
returnedSub.data?.data.attributes.status_formatted,
endsAt: returnedSub.data?.data.attributes.ends_at,
isPaused: returnedSub.data?.data.attributes.pause !== null,
})
.where(eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId));
} catch (error) {
throw new Error(`Failed to pause Subscription #${subscription.lemonSqueezyId} in the database.`);
}

revalidatePath("/");

return returnedSub;
}

export async function resumePlan() {
configureLemonSqueezy();

const subscription = await getOrgSubscription();

if (!subscription) {
throw new Error("No subscription found.");
}

const returnedSub = await updateSubscription(subscription.lemonSqueezyId, {
cancelled: false,
// @ts-expect-error -- null is a valid value for pause
pause: null,
});

// Update the db
try {
await db
.update(subscriptions)
.set({
status: returnedSub.data?.data.attributes.status,
statusFormatted:
returnedSub.data?.data.attributes.status_formatted,
endsAt: returnedSub.data?.data.attributes.ends_at,
isPaused: returnedSub.data?.data.attributes.pause !== null,
})
.where(
eq(subscriptions.lemonSqueezyId, subscription.lemonSqueezyId),
);
} catch (error) {
throw new Error(
`Failed to resume Subscription #${subscription.lemonSqueezyId} in the database.`,
);
}

revalidatePath("/");

return returnedSub;
}

0 comments on commit c49b4a6

Please sign in to comment.