feat: billing tab for platform (#16755)

* modules for platform plans and billing pages

* add platform billing and plans related pages

* update platform navigation to include billing tab

* custom hook to upgrade team subscription

* refactors

* export cta row component

* fixup

* only pass in subscription id instead of whole subscription

* refactor :teamId/subscribe endpoint logic, add endpoint for upgrading stripe and shift all webhooks logic into services

* refactor team subscription logic, add logic for upgrading stripe and  webhooks logic from billing controller

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Rajiv SahalandPeer Richelsen authored and GitHub committed 2024-09-23 13:36:03 +03:00
1 parent f642a6ebeb
commit 3371602e0f
13 files changed
+436 -107

No files matched your search

@@ -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(),
},
});
@@ -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<AppConfig>
) {
this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
@@ -69,13 +68,32 @@ export class BillingController {
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
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<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
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",
@@ -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: "none" };
return { action: "redirect", url };
}
async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) {
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: "redirect", url };
}
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.
@@ -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 (
<div className="border-subtle mx-4 w-auto rounded-md border p-5 ">
<div className="border-subtle max-w-[450px] rounded-2xl border p-5 md:mx-4">
<div className="pb-5">
<h1 className="pb-3 pt-3 text-xl font-semibold">{plan}</h1>
<p className="pb-5 text-base">{description}</p>
<h1 className="border-b-[1px] pb-2 pt-1 text-center text-2xl font-bold">
{plan}
{currentPlan && (
<>
<Button
type="button"
StartIcon="circle-check"
className="bg-default hover:bg-default cursor-none text-green-500 hover:cursor-pointer"
tooltip="This is your current plan"
/>
</>
)}
</h1>
<p className="pb-5 pt-3 text-base">{description}</p>
<h1 className="text-3xl font-semibold">
{pricing && (
<>
@@ -30,14 +44,16 @@ export const PlatformBillingCard = ({
)}
</h1>
</div>
<div>
<Button
loading={isLoading}
onClick={handleSubscribe}
className="flex w-[100%] items-center justify-center">
{pricing ? "Subscribe" : "Schedule a time"}
</Button>
</div>
{!currentPlan && (
<div>
<Button
loading={isLoading}
onClick={handleSubscribe}
className="flex w-[100%] items-center justify-center">
{pricing ? "Subscribe" : "Schedule a time"}
</Button>
</div>
)}
<div className="mt-5">
<p>This includes:</p>
{includes.map((feature) => {
@@ -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 <div className="m-5">Platform team not present, you need to create a team first.</div>;
}
return (
<div className="flex h-auto flex-col items-center justify-center px-5 py-10 md:px-10 lg:h-[100%]">
<div className="mb-5 text-center text-2xl font-semibold">
<h1>Subscribe to Platform</h1>
</div>
<div className="container mx-auto p-4">
{heading}
<div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-4">
{platformPlans.map((plan) => {
return (
<div key={plan.plan}>
<div key={plan.plan} className="mx-10 my-4 md:mx-0 md:my-0">
<PlatformBillingCard
plan={plan.plan}
description={plan.description}
pricing={plan.pricing}
includes={plan.includes}
isLoading={isPending}
handleSubscribe={() => {
!!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)}
/>
</div>
);
@@ -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<ApiResponse<{ action: string; url: string }>, 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;
};
@@ -15,7 +15,7 @@ interface CtaRowProps {
className?: string;
}
const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
export const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
return (
<>
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
@@ -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 <div className="m-5">Loading...</div>;
}
if (!isPlatformUser) return <NoPlatformPlan />;
return (
<div>
<Shell
heading="Platform billing"
title="Platform billing"
hideHeadingOnMobile
withoutMain={false}
subtitle="Manage all things billing"
isPlatformUser={true}>
<>
<div className="border-subtle space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
<CtaRow
title={t("view_and_manage_billing_details")}
description={t("view_and_edit_billing_details")}>
<Button color="primary" href={billingHref} target="_blank" EndIcon="external-link">
{t("billing_portal")}
</Button>
</CtaRow>
<hr className="border-subtle" />
<CtaRow
title="Change plan"
description={t("Want to change your existing plan or check out other plans?")}>
<Button href="/settings/platform/plans" color="secondary">
Plans
</Button>
</CtaRow>
<hr className="border-subtle" />
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
<Button color="secondary" onClick={onContactSupportClick}>
{t("contact_support")}
</Button>
</CtaRow>
</div>
</>
</Shell>
</div>
);
}
@@ -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 <div className="m-5">Loading...</div>;
}
if (!isPlatformUser) return <NoPlatformPlan />;
return (
<div>
<Shell
backPath
isPlatformUser={true}
heading={
<h1 className="mx-2 mt-4 text-center text-xl md:text-2xl">
You are currently subscribed to {userBillingData?.plan[0]}
{userBillingData?.plan.slice(1).toLocaleLowerCase()} plan
</h1>
}
withoutMain={false}
SidebarContainer={<></>}>
<PlatformPricing teamId={userOrgId} teamPlan={userBillingData?.plan.toLocaleLowerCase()} />
</Shell>
</div>
);
}
@@ -58,7 +58,17 @@ export default function Platform() {
return <div className="m-5">Loading...</div>;
}
if (isPlatformUser && !isPaidUser) return <PlatformPricing teamId={userOrgId} />;
if (isPlatformUser && !isPaidUser)
return (
<PlatformPricing
teamId={userOrgId}
heading={
<div className="mb-5 text-center text-2xl font-semibold">
<h1>Subscribe to Platform</h1>
</div>
}
/>
);
if (isPlatformUser) {
return (
@@ -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;
@@ -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;
+5
View File
@@ -710,6 +710,11 @@ const platformNavigation: NavigationItemType[] = [
icon: "ellipsis",
target: "_blank",
},
{
name: "Billing",
href: "/settings/platform/billing",
icon: "chart-bar",
},
];
const getDesktopNavigationItems = (isPlatformNavigation = false) => {