diff --git a/apps/api/v2/src/modules/billing/billing.repository.ts b/apps/api/v2/src/modules/billing/billing.repository.ts index 54b8cf0b2a6129..656e07b61a0588 100644 --- a/apps/api/v2/src/modules/billing/billing.repository.ts +++ b/apps/api/v2/src/modules/billing/billing.repository.ts @@ -19,7 +19,7 @@ export class BillingRepository { billingStart: number, billingEnd: number, plan: PlatformPlan, - subscription?: string + subscriptionId?: string ) { return this.dbWrite.prisma.platformBilling.update({ where: { @@ -28,7 +28,7 @@ export class BillingRepository { data: { billingCycleStart: billingStart, billingCycleEnd: billingEnd, - subscriptionId: subscription, + subscriptionId, plan: plan.toString(), }, }); diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts index 3317d0d1e31d16..7e899b967d716f 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -7,9 +7,8 @@ import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subsc import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto"; import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto"; import { BillingService } from "@/modules/billing/services/billing.service"; -import { PlatformPlan } from "@/modules/billing/types"; +import { StripeService } from "@/modules/stripe/stripe.service"; import { - BadRequestException, Body, Controller, Get, @@ -25,7 +24,6 @@ import { import { ConfigService } from "@nestjs/config"; import { ApiExcludeController } from "@nestjs/swagger"; import { Request } from "express"; -import { Stripe } from "stripe"; import { ApiResponse } from "@calcom/platform-types"; @@ -40,6 +38,7 @@ export class BillingController { constructor( private readonly billingService: BillingService, + public readonly stripeService: StripeService, private readonly configService: ConfigService ) { this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? ""; @@ -69,13 +68,32 @@ export class BillingController { @Param("teamId") teamId: number, @Body() input: SubscribeToPlanInput ): Promise> { - const { status } = await this.billingService.getBillingData(teamId); + const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan); - if (status === "valid") { - throw new BadRequestException("This team is already subscribed to a plan."); + if (action === "redirect") { + return { + status: "success", + data: { + action: "redirect", + url, + }, + }; } - const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan); + return { + status: "success", + }; + } + + @Post("/:teamId/upgrade") + @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @MembershipRoles(["OWNER", "ADMIN"]) + async upgradeTeamBillingInStripe( + @Param("teamId") teamId: number, + @Body() input: SubscribeToPlanInput + ): Promise> { + const { action, url } = await this.billingService.updateSubscriptionForTeam(teamId, input.plan); + if (action === "redirect") { return { status: "success", @@ -103,33 +121,7 @@ export class BillingController { this.stripeWhSecret ); - if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") { - const subscription = event.data.object as Stripe.Subscription; - if (!subscription.metadata?.teamId) { - return { - status: "success", - }; - } - - const teamId = Number.parseInt(subscription.metadata.teamId); - const plan = subscription.metadata.plan; - if (!plan || !teamId) { - this.logger.log("Webhook received but not pertaining to Platform, discarding."); - return { - status: "success", - }; - } - - await this.billingService.setSubscriptionForTeam( - teamId, - subscription, - PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] - ); - - return { - status: "success", - }; - } + await this.billingService.createOrUpdateStripeSubscription(event); return { status: "success", diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 2cc24e2c7475b2..cb6bd4cbc63c6f 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -6,7 +6,13 @@ import { PlatformPlan } from "@/modules/billing/types"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { StripeService } from "@/modules/stripe/stripe.service"; import { InjectQueue } from "@nestjs/bull"; -import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common"; +import { + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + OnModuleDestroy, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Queue } from "bull"; import { DateTime } from "luxon"; @@ -43,12 +49,9 @@ export class BillingService implements OnModuleDestroy { async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) { const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); - let brandNewBilling = false; - let customerId = teamWithBilling?.platformBilling?.customerId; if (!teamWithBilling?.platformBilling) { - brandNewBilling = true; customerId = await this.teamsRepository.createNewBillingRelation(teamId); this.logger.log("Team had no Stripe Customer ID, created one for them.", { @@ -57,43 +60,61 @@ export class BillingService implements OnModuleDestroy { }); } - if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) { - const { url } = await this.stripeService.stripe.checkout.sessions.create({ - customer: customerId, - line_items: [ - { - price: this.billingConfigService.get(plan)?.overage, - }, - { - price: this.billingConfigService.get(plan)?.base, - quantity: 1, - }, - ], - success_url: `${this.webAppUrl}/settings/platform/`, - cancel_url: `${this.webAppUrl}/settings/platform/`, - mode: "subscription", + const { url } = await this.stripeService.stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: this.billingConfigService.get(plan)?.base, + quantity: 1, + }, + { + price: this.billingConfigService.get(plan)?.overage, + }, + ], + success_url: `${this.webAppUrl}/settings/platform/`, + cancel_url: `${this.webAppUrl}/settings/platform/`, + mode: "subscription", + metadata: { + teamId: teamId.toString(), + plan: plan.toString(), + }, + currency: "usd", + subscription_data: { metadata: { teamId: teamId.toString(), plan: plan.toString(), }, - subscription_data: { - metadata: { - teamId: teamId.toString(), - plan: plan.toString(), - }, - }, - allow_promotion_codes: true, - }); + }, + allow_promotion_codes: true, + }); - if (!url) throw new InternalServerErrorException("Failed to create Stripe session."); + if (!url) throw new InternalServerErrorException("Failed to create Stripe session."); - return { action: "redirect", url }; - } + return { action: "redirect", url }; + } + + async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + const customerId = teamWithBilling?.platformBilling?.customerId; + + const { url } = await this.stripeService.stripe.checkout.sessions.create({ + customer: customerId, + success_url: `${this.webAppUrl}/settings/platform/`, + cancel_url: `${this.webAppUrl}/settings/platform/plans`, + mode: "setup", + metadata: { + teamId: teamId.toString(), + plan: plan.toString(), + }, + currency: "usd", + }); + + if (!url) throw new InternalServerErrorException("Failed to create Stripe session."); - return { action: "none" }; + return { action: "redirect", url }; } - async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) { + async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) { const billingCycleStart = DateTime.now().get("day"); const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day"); @@ -102,10 +123,94 @@ export class BillingService implements OnModuleDestroy { billingCycleStart, billingCycleEnd, plan, - subscription.id + subscriptionId ); } + async createOrUpdateStripeSubscription(event: Stripe.Event) { + if (event.type === "checkout.session.completed") { + const subscription = event.data.object as Stripe.Checkout.Session; + + if (!subscription.metadata?.teamId) { + return { + status: "success", + }; + } + + const teamId = Number.parseInt(subscription.metadata.teamId); + const plan = subscription.metadata.plan; + if (!plan || !teamId) { + this.logger.log("Webhook received but not pertaining to Platform, discarding."); + return { + status: "success", + }; + } + + if (subscription.mode === "subscription") { + await this.setSubscriptionForTeam( + teamId, + subscription.subscription as string, + PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] + ); + } + + if (subscription.mode === "setup") { + await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan); + } + + return { + status: "success", + }; + } + } + + async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + + if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) { + throw new NotFoundException("Team plan not found"); + } + + const existingUserSubscription = await this.stripeService.stripe.subscriptions.retrieve( + teamWithBilling?.platformBilling?.subscriptionId + ); + const currentLicensedItem = existingUserSubscription.items.data.find( + (item) => item.price?.recurring?.usage_type === "licensed" + ); + const currentOverageItem = existingUserSubscription.items.data.find( + (item) => item.price?.recurring?.usage_type === "metered" + ); + + if (!currentLicensedItem) { + throw new NotFoundException("There is no licensed item present in the subscription"); + } + + if (!currentOverageItem) { + throw new NotFoundException("There is no overage item present in the subscription"); + } + + await this.stripeService.stripe.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, { + items: [ + { + id: currentLicensedItem.id, + price: this.billingConfigService.get(plan)?.base, + }, + { + id: currentOverageItem.id, + price: this.billingConfigService.get(plan)?.overage, + clear_usage: false, + }, + ], + billing_cycle_anchor: "now", + proration_behavior: "create_prorations", + }); + + await this.setSubscriptionForTeam( + teamId, + teamWithBilling?.platformBilling?.subscriptionId, + PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] + ); + } /** * * Adds a job to the queue to increment usage of a stripe subscription. diff --git a/apps/web/components/settings/platform/pricing/billing-card/index.tsx b/apps/web/components/settings/platform/pricing/billing-card/index.tsx index e277bdd2636689..5552af4ac9b0c4 100644 --- a/apps/web/components/settings/platform/pricing/billing-card/index.tsx +++ b/apps/web/components/settings/platform/pricing/billing-card/index.tsx @@ -6,6 +6,7 @@ type PlatformBillingCardProps = { pricing?: number; includes: string[]; isLoading?: boolean; + currentPlan?: boolean; handleSubscribe?: () => void; }; @@ -16,12 +17,25 @@ export const PlatformBillingCard = ({ includes, isLoading, handleSubscribe, + currentPlan, }: PlatformBillingCardProps) => { return ( -
+
-

{plan}

-

{description}

+

+ {plan} + {currentPlan && ( + <> +

+

{description}

{pricing && ( <> @@ -30,14 +44,16 @@ export const PlatformBillingCard = ({ )}

-
- -
+ {!currentPlan && ( +
+ +
+ )}

This includes:

{includes.map((feature) => { diff --git a/apps/web/components/settings/platform/pricing/platform-pricing/index.tsx b/apps/web/components/settings/platform/pricing/platform-pricing/index.tsx index ee27bd50ffbae6..3acf1a326eda15 100644 --- a/apps/web/components/settings/platform/pricing/platform-pricing/index.tsx +++ b/apps/web/components/settings/platform/pricing/platform-pricing/index.tsx @@ -1,49 +1,78 @@ import { useRouter } from "next/navigation"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { showToast } from "@calcom/ui"; -import { useSubscribeTeamToStripe } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient"; +import { + useSubscribeTeamToStripe, + useUpgradeTeamSubscriptionInStripe, +} from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient"; import { platformPlans } from "@components/settings/platform/platformUtils"; import { PlatformBillingCard } from "@components/settings/platform/pricing/billing-card"; -type PlatformPricingProps = { teamId?: number | null }; +type PlatformPricingProps = { teamId?: number | null; teamPlan?: string; heading?: ReactNode }; -export const PlatformPricing = ({ teamId }: PlatformPricingProps) => { +export const PlatformPricing = ({ teamId, teamPlan, heading }: PlatformPricingProps) => { + const pathname = usePathname(); + const currentPage = pathname?.split("/").pop(); const router = useRouter(); - const { mutateAsync, isPending } = useSubscribeTeamToStripe({ - onSuccess: (redirectUrl: string) => { - router.push(redirectUrl); - }, - onError: () => { - showToast(ErrorCode.UnableToSubscribeToThePlatform, "error"); - }, - teamId, - }); + const { mutateAsync: createTeamSubscription, isPending: isCreateTeamSubscriptionLoading } = + useSubscribeTeamToStripe({ + onSuccess: (redirectUrl: string) => { + router.push(redirectUrl); + }, + onError: () => { + showToast(ErrorCode.UnableToSubscribeToThePlatform, "error"); + }, + teamId, + }); + + const { mutateAsync: upgradeTeamSubscription, isPending: isUpgradeTeamSubscriptionLoading } = + useUpgradeTeamSubscriptionInStripe({ + onSuccess: (redirectUrl: string) => { + router.push(redirectUrl); + }, + onError: () => { + showToast(ErrorCode.UnableToSubscribeToThePlatform, "error"); + }, + teamId, + }); + + const handleStripeSubscription = async (plan: string) => { + if (plan === "Enterprise") { + router.push("https://i.cal.com/sales/exploration"); + } + + if (currentPage === "platform") { + createTeamSubscription({ plan: plan.toLocaleUpperCase() }); + } else { + upgradeTeamSubscription({ plan: plan.toLocaleUpperCase() }); + } + }; + + if (!teamId) { + return
Platform team not present, you need to create a team first.
; + } return (
-
-

Subscribe to Platform

-
-
+ {heading} +
{platformPlans.map((plan) => { return ( -
+
{ - !!teamId && - (plan.plan === "Enterprise" - ? router.push("https://i.cal.com/sales/exploration") - : mutateAsync({ plan: plan.plan.toLocaleUpperCase() })); - }} + isLoading={isCreateTeamSubscriptionLoading || isUpgradeTeamSubscriptionLoading} + currentPlan={plan.plan.toLocaleLowerCase() === teamPlan} + handleSubscribe={() => handleStripeSubscription(plan.plan)} />
); diff --git a/apps/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient.ts b/apps/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient.ts index 9d286201100a9e..d22f64b8f1c087 100644 --- a/apps/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient.ts +++ b/apps/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient.ts @@ -174,3 +174,40 @@ export const useSubscribeTeamToStripe = ( return mutation; }; + +export const useUpgradeTeamSubscriptionInStripe = ( + { + onSuccess, + onError, + teamId, + }: { teamId?: number | null; onSuccess: (redirectUrl: string) => void; onError: () => void } = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const mutation = useMutation, unknown, SubscribeTeamInput>({ + mutationFn: (data) => { + return fetch(`/api/v2/billing/${teamId}/upgrade`, { + method: "post", + headers: { "Content-type": "application/json" }, + body: JSON.stringify(data), + }).then((res) => res?.json()); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(data.data?.url); + } else { + onError?.(); + } + }, + onError: () => { + onError?.(); + }, + }); + + return mutation; +}; diff --git a/apps/web/modules/settings/billing/billing-view.tsx b/apps/web/modules/settings/billing/billing-view.tsx index 5a32adecf82528..880541e36103ae 100644 --- a/apps/web/modules/settings/billing/billing-view.tsx +++ b/apps/web/modules/settings/billing/billing-view.tsx @@ -15,7 +15,7 @@ interface CtaRowProps { className?: string; } -const CtaRow = ({ title, description, className, children }: CtaRowProps) => { +export const CtaRow = ({ title, description, className, children }: CtaRowProps) => { return ( <>
diff --git a/apps/web/modules/settings/platform/billing/billing-view.tsx b/apps/web/modules/settings/platform/billing/billing-view.tsx new file mode 100644 index 00000000000000..8882ede63a1973 --- /dev/null +++ b/apps/web/modules/settings/platform/billing/billing-view.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +import { useIntercom } from "@calcom/features/ee/support/lib/intercom/useIntercom"; +import Shell from "@calcom/features/shell/Shell"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui"; + +import NoPlatformPlan from "@components/settings/platform/dashboard/NoPlatformPlan"; +import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes"; + +import { CtaRow } from "~/settings/billing/billing-view"; + +export default function PlatformBillingUpgrade() { + const pathname = usePathname(); + const { t } = useLocale(); + const { open } = useIntercom(); + const returnTo = pathname; + const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`; + + const onContactSupportClick = async () => { + await open(); + }; + const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData } = useGetUserAttributes(); + + if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) { + return
Loading...
; + } + + if (!isPlatformUser) return ; + + return ( +
+ + <> +
+ + + + +
+ + + + + +
+ + + + +
+ +
+
+ ); +} diff --git a/apps/web/modules/settings/platform/plans/platform-plans-view.tsx b/apps/web/modules/settings/platform/plans/platform-plans-view.tsx new file mode 100644 index 00000000000000..4f08fcf037f420 --- /dev/null +++ b/apps/web/modules/settings/platform/plans/platform-plans-view.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Shell from "@calcom/features/shell/Shell"; + +import NoPlatformPlan from "@components/settings/platform/dashboard/NoPlatformPlan"; +import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes"; +import { PlatformPricing } from "@components/settings/platform/pricing/platform-pricing"; + +export default function PlatformPlans() { + const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } = + useGetUserAttributes(); + + if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) { + return
Loading...
; + } + + if (!isPlatformUser) return ; + + return ( +
+ + You are currently subscribed to {userBillingData?.plan[0]} + {userBillingData?.plan.slice(1).toLocaleLowerCase()} plan + + } + withoutMain={false} + SidebarContainer={<>}> + + +
+ ); +} diff --git a/apps/web/modules/settings/platform/platform-view.tsx b/apps/web/modules/settings/platform/platform-view.tsx index 304d7912329753..afe7247cce5ae2 100644 --- a/apps/web/modules/settings/platform/platform-view.tsx +++ b/apps/web/modules/settings/platform/platform-view.tsx @@ -58,7 +58,17 @@ export default function Platform() { return
Loading...
; } - if (isPlatformUser && !isPaidUser) return ; + if (isPlatformUser && !isPaidUser) + return ( + +

Subscribe to Platform

+
+ } + /> + ); if (isPlatformUser) { return ( diff --git a/apps/web/pages/settings/platform/billing/index.tsx b/apps/web/pages/settings/platform/billing/index.tsx new file mode 100644 index 00000000000000..4bdf02297b9bcf --- /dev/null +++ b/apps/web/pages/settings/platform/billing/index.tsx @@ -0,0 +1,12 @@ +import PageWrapper from "@components/PageWrapper"; + +import PlatformBillingUpgrade from "~/settings/platform/billing/billing-view"; + +const Page = new Proxy<{ + (): JSX.Element; + PageWrapper?: typeof PageWrapper; +}>(PlatformBillingUpgrade, {}); + +Page.PageWrapper = PageWrapper; + +export default Page; diff --git a/apps/web/pages/settings/platform/plans/index.tsx b/apps/web/pages/settings/platform/plans/index.tsx new file mode 100644 index 00000000000000..8aa52be3e527ef --- /dev/null +++ b/apps/web/pages/settings/platform/plans/index.tsx @@ -0,0 +1,12 @@ +import PageWrapper from "@components/PageWrapper"; + +import PlatformPlansView from "~/settings/platform/plans/platform-plans-view"; + +const Page = new Proxy<{ + (): JSX.Element; + PageWrapper?: typeof PageWrapper; +}>(PlatformPlansView, {}); + +Page.PageWrapper = PageWrapper; + +export default Page; diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 247f443f9805e2..60f4dc5c77c6f0 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -710,6 +710,11 @@ const platformNavigation: NavigationItemType[] = [ icon: "ellipsis", target: "_blank", }, + { + name: "Billing", + href: "/settings/platform/billing", + icon: "chart-bar", + }, ]; const getDesktopNavigationItems = (isPlatformNavigation = false) => {