Skip to content

Commit

Permalink
feat: billing tab for platform (calcom#16755)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
Ryukemeister and PeerRich authored Sep 23, 2024
1 parent f642a6e commit 3371602
Show file tree
Hide file tree
Showing 13 changed files with 435 additions and 106 deletions.
4 changes: 2 additions & 2 deletions apps/api/v2/src/modules/billing/billing.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class BillingRepository {
billingStart: number,
billingEnd: number,
plan: PlatformPlan,
subscription?: string
subscriptionId?: string
) {
return this.dbWrite.prisma.platformBilling.update({
where: {
Expand All @@ -28,7 +28,7 @@ export class BillingRepository {
data: {
billingCycleStart: billingStart,
billingCycleEnd: billingEnd,
subscriptionId: subscription,
subscriptionId,
plan: plan.toString(),
},
});
Expand Down
60 changes: 26 additions & 34 deletions apps/api/v2/src/modules/billing/controllers/billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand All @@ -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 }) ?? "";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
171 changes: 138 additions & 33 deletions apps/api/v2/src/modules/billing/services/billing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.", {
Expand All @@ -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");

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type PlatformBillingCardProps = {
pricing?: number;
includes: string[];
isLoading?: boolean;
currentPlan?: boolean;
handleSubscribe?: () => void;
};

Expand All @@ -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 && (
<>
Expand All @@ -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) => {
Expand Down
Loading

0 comments on commit 3371602

Please sign in to comment.