From b722df10916d4b34bda31fadd5afd712aba64329 Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Wed, 23 Apr 2025 13:12:19 +0200 Subject: [PATCH] Add plan cancellation and re-subscription functionality --- apps/dashboard/src/@/actions/billing.ts | 77 ++++++ .../src/@/components/blocks/pricing-card.tsx | 19 +- .../team-onboarding/InviteTeamMembers.tsx | 20 +- .../components/PlanInfoCard.client.tsx | 19 -- .../components/PlanInfoCard.stories.tsx | 22 +- .../billing/components/PlanInfoCard.tsx | 41 ++- .../CancelPlanModal/CancelPlanModal.tsx | 259 ++++++------------ .../settings/Account/Billing/Pricing.tsx | 122 ++++----- .../renew-subscription-button.tsx | 84 ++++++ apps/dashboard/src/stories/stubs.ts | 1 + apps/dashboard/src/utils/try-catch.ts | 22 ++ packages/service-utils/src/core/api.ts | 1 + packages/service-utils/src/mocks.ts | 1 + 13 files changed, 400 insertions(+), 288 deletions(-) create mode 100644 apps/dashboard/src/components/settings/Account/Billing/renew-subscription/renew-subscription-button.tsx create mode 100644 apps/dashboard/src/utils/try-catch.ts diff --git a/apps/dashboard/src/@/actions/billing.ts b/apps/dashboard/src/@/actions/billing.ts index c77a5754e52..c10fa6d61f7 100644 --- a/apps/dashboard/src/@/actions/billing.ts +++ b/apps/dashboard/src/@/actions/billing.ts @@ -64,6 +64,83 @@ export async function getBillingCheckoutUrl( export type GetBillingCheckoutUrlAction = typeof getBillingCheckoutUrl; +export async function getPlanCancelUrl(options: { + teamId: string; + redirectUrl: string; +}): Promise<{ status: number; url?: string }> { + const token = await getAuthToken(); + if (!token) { + return { + status: 401, + }; + } + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${options.teamId}/checkout/cancel-plan-link`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + redirectTo: options.redirectUrl, + }), + }, + ); + + if (!res.ok) { + return { + status: res.status, + }; + } + + const json = await res.json(); + + if (!json.result) { + return { + status: 500, + }; + } + + return { + status: 200, + url: json.result as string, + }; +} + +export async function reSubscribePlan(options: { + teamId: string; +}): Promise<{ status: number }> { + const token = await getAuthToken(); + if (!token) { + return { + status: 401, + }; + } + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${options.teamId}/checkout/resubscribe-plan`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({}), + }, + ); + + if (!res.ok) { + return { + status: res.status, + }; + } + + return { + status: 200, + }; +} export type GetBillingPortalUrlOptions = { teamSlug: string | undefined; redirectUrl: string; diff --git a/apps/dashboard/src/@/components/blocks/pricing-card.tsx b/apps/dashboard/src/@/components/blocks/pricing-card.tsx index 0ef29a9949f..a10f6fa901a 100644 --- a/apps/dashboard/src/@/components/blocks/pricing-card.tsx +++ b/apps/dashboard/src/@/components/blocks/pricing-card.tsx @@ -8,6 +8,7 @@ import { CheckIcon, CircleDollarSignIcon } from "lucide-react"; import Link from "next/link"; import type React from "react"; import { TEAM_PLANS } from "utils/pricing"; +import { RenewSubscriptionButton } from "../../../components/settings/Account/Billing/renew-subscription/renew-subscription-button"; import { useTrack } from "../../../hooks/analytics/useTrack"; import { remainingDays } from "../../../utils/date-utils"; import type { GetBillingCheckoutUrlAction } from "../../actions/billing"; @@ -16,20 +17,27 @@ import { CheckoutButton } from "../billing"; type PricingCardCta = { hint?: string; - title: string; + onClick?: () => void; } & ( | { type: "link"; href: string; + label: string; } | { type: "checkout"; + label: string; + } + | { + type: "renew"; } ); type PricingCardProps = { + getTeam: () => Promise; teamSlug: string; + teamId: string; billingStatus: Team["billingStatus"]; billingPlan: keyof typeof TEAM_PLANS; cta?: PricingCardCta; @@ -41,7 +49,9 @@ type PricingCardProps = { }; export const PricingCard: React.FC = ({ + getTeam, teamSlug, + teamId, billingStatus, billingPlan, cta, @@ -131,6 +141,9 @@ export const PricingCard: React.FC = ({ {cta && (
+ {cta.type === "renew" && ( + + )} {billingPlanToSkuMap[billingPlan] && cta.type === "checkout" && ( = ({ sku={billingPlanToSkuMap[billingPlan]} getBillingCheckoutUrl={getBillingCheckoutUrl} > - {cta.title} + {cta.label} )} @@ -154,7 +167,7 @@ export const PricingCard: React.FC = ({ asChild > - {cta.title} + {cta.label} )} diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx index e2e5831fe41..8db7b6801b2 100644 --- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx +++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx @@ -81,6 +81,8 @@ export function InviteTeamMembersUI(props: { teamSlug={props.team.slug} getBillingCheckoutUrl={props.getBillingCheckoutUrl} trackEvent={props.trackEvent} + getTeam={props.getTeam} + teamId={props.team.id} /> @@ -148,6 +150,8 @@ function InviteModalContent(props: { billingStatus: Team["billingStatus"]; getBillingCheckoutUrl: GetBillingCheckoutUrlAction; trackEvent: (params: TrackingParams) => void; + getTeam: () => Promise; + teamId: string; }) { const [planToShow, setPlanToShow] = useState< "starter" | "growth" | "accelerate" | "scale" @@ -159,7 +163,7 @@ function InviteModalContent(props: { billingStatus={props.billingStatus} teamSlug={props.teamSlug} cta={{ - title: "Get Started", + label: "Get Started", type: "checkout", onClick() { props.trackEvent({ @@ -171,6 +175,8 @@ function InviteModalContent(props: { }, }} getBillingCheckoutUrl={props.getBillingCheckoutUrl} + getTeam={props.getTeam} + teamId={props.teamId} /> ); @@ -180,7 +186,7 @@ function InviteModalContent(props: { billingStatus={props.billingStatus} teamSlug={props.teamSlug} cta={{ - title: "Get Started", + label: "Get Started", type: "checkout", onClick() { props.trackEvent({ @@ -193,6 +199,8 @@ function InviteModalContent(props: { }} highlighted getBillingCheckoutUrl={props.getBillingCheckoutUrl} + getTeam={props.getTeam} + teamId={props.teamId} /> ); @@ -202,7 +210,7 @@ function InviteModalContent(props: { billingStatus={props.billingStatus} teamSlug={props.teamSlug} cta={{ - title: "Get started", + label: "Get started", type: "checkout", onClick() { props.trackEvent({ @@ -214,6 +222,8 @@ function InviteModalContent(props: { }, }} getBillingCheckoutUrl={props.getBillingCheckoutUrl} + getTeam={props.getTeam} + teamId={props.teamId} /> ); @@ -223,7 +233,7 @@ function InviteModalContent(props: { billingStatus={props.billingStatus} teamSlug={props.teamSlug} cta={{ - title: "Get started", + label: "Get started", type: "checkout", onClick() { props.trackEvent({ @@ -235,6 +245,8 @@ function InviteModalContent(props: { }, }} getBillingCheckoutUrl={props.getBillingCheckoutUrl} + getTeam={props.getTeam} + teamId={props.teamId} /> ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx index 6902dda91c7..2f3f420298c 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx @@ -30,25 +30,6 @@ export function PlanInfoCardClient(props: { return res.data.result; }} - cancelPlan={async (params) => { - const res = await apiServerProxy<{ - data: { - result: "success"; - }; - }>({ - pathname: `/v1/teams/${props.team.id}/checkout/cancel-plan`, - headers: { - "Content-Type": "application/json", - }, - method: "PUT", - body: JSON.stringify(params), - }); - - if (!res.ok) { - console.error(res.error); - throw new Error(res.error); - } - }} /> ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx index 5bac3f3e502..f56b9bf8bb2 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx @@ -115,11 +115,6 @@ function Story(props: { url: "https://example.com", }); - const cancelPlanStub = async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return; - }; - const teamTeamStub = async () => ({ ...team, @@ -134,7 +129,19 @@ function Story(props: { subscriptions={zeroUsageOnDemandSubs} getBillingPortalUrl={getBillingPortalUrlStub} getBillingCheckoutUrl={getBillingCheckoutUrlStub} - cancelPlan={cancelPlanStub} + getTeam={teamTeamStub} + /> + + + + @@ -145,7 +152,6 @@ function Story(props: { subscriptions={trialPlanZeroUsageOnDemandSubs} getBillingPortalUrl={getBillingPortalUrlStub} getBillingCheckoutUrl={getBillingCheckoutUrlStub} - cancelPlan={cancelPlanStub} getTeam={teamTeamStub} /> @@ -156,7 +162,6 @@ function Story(props: { subscriptions={subsWith1Usage} getBillingPortalUrl={getBillingPortalUrlStub} getBillingCheckoutUrl={getBillingCheckoutUrlStub} - cancelPlan={cancelPlanStub} getTeam={teamTeamStub} /> @@ -167,7 +172,6 @@ function Story(props: { subscriptions={subsWith4Usage} getBillingPortalUrl={getBillingPortalUrlStub} getBillingCheckoutUrl={getBillingCheckoutUrlStub} - cancelPlan={cancelPlanStub} getTeam={teamTeamStub} /> diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx index b91745369a5..5c63bdcc8aa 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx @@ -17,10 +17,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { - type CancelPlan, - CancelPlanButton, -} from "components/settings/Account/Billing/CancelPlanModal/CancelPlanModal"; +import { CancelPlanButton } from "components/settings/Account/Billing/CancelPlanModal/CancelPlanModal"; import { BillingPricing } from "components/settings/Account/Billing/Pricing"; import { differenceInDays, isAfter } from "date-fns"; import { format } from "date-fns/format"; @@ -28,6 +25,7 @@ import { CreditCardIcon, FileTextIcon, SquarePenIcon } from "lucide-react"; import { CircleAlertIcon } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { RenewSubscriptionButton } from "../../../../../../../../../components/settings/Account/Billing/renew-subscription/renew-subscription-button"; import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan"; export function PlanInfoCardUI(props: { @@ -35,7 +33,6 @@ export function PlanInfoCardUI(props: { team: Team; getBillingPortalUrl: GetBillingPortalUrlAction; getBillingCheckoutUrl: GetBillingCheckoutUrlAction; - cancelPlan: CancelPlan; getTeam: () => Promise; }) { const { subscriptions, team } = props; @@ -63,6 +60,7 @@ export function PlanInfoCardUI(props: { getBillingCheckoutUrl={props.getBillingCheckoutUrl} isOpen={isPlanSheetOpen} onOpenChange={setIsPlanSheetOpen} + getTeam={props.getTeam} />
@@ -102,6 +100,16 @@ export function PlanInfoCardUI(props: { Your trial ends in {trialEndsAfterDays} days

)} + {props.team.planCancellationDate && ( + + Scheduled to cancel in{" "} + {differenceInDays( + new Date(props.team.planCancellationDate), + new Date(), + )}{" "} + days + + )}
{props.team.billingPlan !== "free" && ( @@ -118,13 +126,20 @@ export function PlanInfoCardUI(props: { Change Plan - + {props.team.planCancellationDate ? ( + + ) : ( + + )}
)} @@ -291,6 +306,7 @@ function ViewPlansSheet(props: { getBillingCheckoutUrl: GetBillingCheckoutUrlAction; isOpen: boolean; onOpenChange: (open: boolean) => void; + getTeam: () => Promise; }) { return ( @@ -302,6 +318,7 @@ function ViewPlansSheet(props: { team={props.team} trialPeriodEndedAt={props.trialPeriodEndedAt} getBillingCheckoutUrl={props.getBillingCheckoutUrl} + getTeam={props.getTeam} /> diff --git a/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx b/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx index dbf288de489..578951ea3d5 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx @@ -1,23 +1,9 @@ "use client"; +import { getPlanCancelUrl } from "@/actions/billing"; import type { Team } from "@/api/team"; import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Sheet, SheetContent, @@ -25,70 +11,34 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { Textarea } from "@/components/ui/textarea"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { CircleXIcon, ExternalLinkIcon } from "lucide-react"; import Link from "next/link"; import { useState, useTransition } from "react"; -import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; +import { useStripeRedirectEvent } from "../../../../../app/(app)/stripe-redirect/stripeRedirectChannel"; import { PRO_CONTACT_US_URL } from "../../../../../constants/pro"; import { pollWithTimeout } from "../../../../../utils/pollWithTimeout"; - -const cancelPlanFormSchema = z - .object({ - comment: z.string().optional(), - feedback: z.enum([ - "customer_service", - "low_quality", - "missing_features", - "other", - "switched_service", - "too_complex", - "too_expensive", - "unused", - ]), - }) - // if feedback is other, comment is required - .refine( - (data) => - !( - data.feedback === "other" && - (!data.comment || data.comment.trim() === "") - ), - { - message: "Required", - path: ["comment"], - }, - ); - -type CancelPlanParams = z.infer; -export type CancelPlan = (params: CancelPlanParams) => Promise; - -const cancelReasons: Array<{ - value: CancelPlanParams["feedback"]; - label: string; -}> = [ - { value: "too_expensive", label: "Too expensive" }, - { value: "too_complex", label: "Too complex to use" }, - { value: "missing_features", label: "Missing features I need" }, - { value: "low_quality", label: "Quality doesn't meet expectations" }, - { value: "unused", label: "Not using it enough" }, - { value: "switched_service", label: "Switched to another service" }, - { value: "customer_service", label: "Unhappy with customer service" }, - { value: "other", label: "Other reason" }, -]; +import { tryCatch } from "../../../../../utils/try-catch"; export function CancelPlanButton(props: { + teamId: string; teamSlug: string; - cancelPlan: CancelPlan; currentPlan: Team["billingPlan"]; billingStatus: Team["billingStatus"]; getTeam: () => Promise; }) { + // shortcut the sheet in case the user is in the default state + if (props.billingStatus !== "invalidPayment" && props.currentPlan !== "pro") { + return ( + + ); + } + return ( @@ -108,12 +58,8 @@ export function CancelPlanButton(props: { ) : props.currentPlan === "pro" ? ( - ) : ( - - )} + ) : // this should never happen + null} ); @@ -160,121 +106,90 @@ function ProPlanCancelPlanSheetContent() { ); } -function CancelPlanSheetContent(props: { - cancelPlan: CancelPlan; +function ImmediateCancelPlanButton(props: { + teamId: string; getTeam: () => Promise; }) { - const [_isPending, startTransition] = useTransition(); - const [isPollingTeam, setIsPollingTeam] = useState(false); const router = useDashboardRouter(); - const isPending = _isPending || isPollingTeam; - - const form = useForm>({ - resolver: zodResolver(cancelPlanFormSchema), - defaultValues: { - comment: "", - feedback: undefined, - }, - }); - - const cancelPlan = useMutation({ - mutationFn: props.cancelPlan, - }); + const [isRoutePending, startTransition] = useTransition(); + const [isPollingTeam, setIsPollingTeam] = useState(false); - function onSubmit(values: z.infer) { - const promise = cancelPlan.mutateAsync(values); - toast.promise(promise, { - success: "Plan cancelled successfully", - error: "Failed to cancel plan", - }); - promise.then(async () => { - setIsPollingTeam(true); - // keep polling until the team plan is free, then refresh the page - await pollWithTimeout({ + useStripeRedirectEvent(async () => { + setIsPollingTeam(true); + const verifyResult = await tryCatch( + pollWithTimeout({ shouldStop: async () => { const team = await props.getTeam(); - return team.billingPlan === "free"; + const isCancelled = + team.billingPlan === "free" || team.planCancellationDate !== null; + return isCancelled; }, - timeoutMs: 7000, + timeoutMs: 5000, + }), + ); + + if (verifyResult.error) { + return; + } + + setIsPollingTeam(false); + toast.success("Plan cancelled successfully"); + startTransition(() => { + router.refresh(); + }); + }); + + const cancelPlan = useMutation({ + mutationFn: async (opts: { teamId: string }) => { + const { url, status } = await getPlanCancelUrl({ + teamId: opts.teamId, + redirectUrl: getAbsoluteUrl("/stripe-redirect"), }); - setIsPollingTeam(false); + if (!url) { + throw new Error("Failed to get cancel plan url"); + } - startTransition(() => { - router.refresh(); - }); - }); - } + if (status !== 200) { + throw new Error("Failed to get cancel plan url"); + } - return ( -
-
-

Cancel Plan

- {isPending && } -
-

- Please let us know why you're cancelling your plan. Your feedback helps - us improve our service. -

+ const tab = window.open(url, "_blank"); + if (!tab) { + throw new Error("Failed to open cancel plan url"); + } + }, + }); -
- - ( - - Why are you cancelling? - - - - )} - /> + async function handleCancelPlan() { + cancelPlan.mutate({ + teamId: props.teamId, + }); + } - ( - - Additional comments - -