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:
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;
|
||||
@@ -710,6 +710,11 @@ const platformNavigation: NavigationItemType[] = [
|
||||
icon: "ellipsis",
|
||||
target: "_blank",
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: "/settings/platform/billing",
|
||||
icon: "chart-bar",
|
||||
},
|
||||
];
|
||||
|
||||
const getDesktopNavigationItems = (isPlatformNavigation = false) => {
|
||||
|
||||
Reference in new issue
Block a user