feat: active managed user billing (v1) (#20499)

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Rajiv Sahal
2025-05-10 16:13:09 +00:00
committed by GitHub
co-authored by Morgan
parent a52a0e90e7
commit 109c303b60
10 changed files with 325 additions and 9 deletions
@@ -1,3 +1,4 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
@@ -5,6 +6,7 @@ import { BillingConfigService } from "@/modules/billing/services/billing.config.
import { BillingService } from "@/modules/billing/services/billing.service";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -33,6 +35,8 @@ import { Module } from "@nestjs/common";
BillingRepository,
BillingProcessor,
ManagedOrganizationsBillingService,
OAuthClientRepository,
BookingsRepository_2024_08_13,
],
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
controllers: [BillingController],
@@ -15,12 +15,21 @@ export class BillingRepository {
},
});
async getBillingForTeamBySubscriptionId(subscriptionId: string) {
return this.dbRead.prisma.platformBilling.findFirst({
where: {
subscriptionId,
},
});
}
async updateTeamBilling(
teamId: number,
billingStart: number,
billingEnd: number,
plan: PlatformPlan,
subscriptionId?: string
subscriptionId?: string,
priceId?: string
) {
return this.dbWrite.prisma.platformBilling.update({
where: {
@@ -32,6 +41,7 @@ export class BillingRepository {
subscriptionId,
plan: plan.toString(),
overdue: false,
priceId,
},
});
}
@@ -127,6 +127,9 @@ export class BillingController {
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.created":
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
@@ -1,10 +1,13 @@
import { AppConfig } from "@/config/type";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { InjectQueue } from "@nestjs/bull";
import {
BadRequestException,
@@ -30,6 +33,9 @@ export class BillingService implements OnModuleDestroy {
private readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService,
private readonly usersRepository: UsersRepository,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
) {
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
@@ -119,7 +125,7 @@ export class BillingService implements OnModuleDestroy {
return url;
}
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
@@ -132,6 +138,25 @@ export class BillingService implements OnModuleDestroy {
);
}
async setPerActiveUserSubscriptionForTeam(
teamId: number,
subscriptionId: string,
plan: PlatformPlan,
priceId: string
) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
return this.billingRepository.updateTeamBilling(
teamId,
billingCycleStart,
billingCycleEnd,
plan,
subscriptionId,
priceId
);
}
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
@@ -204,9 +229,19 @@ export class BillingService implements OnModuleDestroy {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
const isPriceIdPresent = Boolean(checkoutSession.metadata?.priceId);
if (checkoutSession.mode === "subscription") {
await this.setSubscriptionForTeam(
if (checkoutSession.mode === "subscription" && isPriceIdPresent) {
await this.setPerActiveUserSubscriptionForTeam(
teamId,
checkoutSession.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan],
checkoutSession.metadata?.priceId
);
}
if (checkoutSession.mode === "subscription" && !isPriceIdPresent) {
await this.setPerBookingSubscriptionForTeam(
teamId,
checkoutSession.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
@@ -220,6 +255,67 @@ export class BillingService implements OnModuleDestroy {
return;
}
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
if (!subscriptionId) {
throw new NotFoundException("No subscription found for team");
}
const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId);
if (teamWithBilling?.plan === "PER_ACTIVE_USER") {
const activeManagedUsersCount = await this.getActiveManagedUsersCount(
subscriptionId,
new Date(invoice.period_start * 1000),
new Date(invoice.period_end * 1000)
);
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
items: [
{
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
},
],
});
}
}
async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) {
const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(
subscriptionId
);
if (!managedUsersEmails) return 0;
if (!invoiceStart || !invoiceEnd) {
this.logger.log("Invoice period start or end date is null");
return 0;
}
const activeManagedUserEmailsAsHost = await this.usersRepository.getActiveManagedUsersAsHost(
subscriptionId,
invoiceStart,
invoiceEnd
);
const activeHostEmails = activeManagedUserEmailsAsHost.map((email) => email.email);
const notActiveHostEmails = managedUsersEmails
.filter((email) => !activeHostEmails.includes(email.email))
.map((email) => email.email);
if (notActiveHostEmails.length === 0) return activeManagedUserEmailsAsHost.length;
const activeManagedUserEmailsAsAttendee = await this.usersRepository.getActiveManagedUsersAsAttendee(
notActiveHostEmails,
invoiceStart,
invoiceEnd
);
return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length;
}
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
@@ -263,7 +359,7 @@ export class BillingService implements OnModuleDestroy {
proration_behavior: "create_prorations",
});
await this.setSubscriptionForTeam(
await this.setPerBookingSubscriptionForTeam(
teamId,
teamWithBilling?.platformBilling?.subscriptionId,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
+2 -1
View File
@@ -4,6 +4,7 @@ export enum PlatformPlan {
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
PER_ACTIVE_USER = "PER_ACTIVE_USER",
}
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";
@@ -317,4 +317,73 @@ export class UsersRepository {
},
});
}
async getOrgsManagedUserEmailsBySubscriptionId(subscriptionId: string) {
return await this.dbRead.prisma.user.findMany({
distinct: ["email"],
where: {
isPlatformManaged: true,
profiles: {
some: {
organization: {
platformBilling: {
subscriptionId,
},
},
},
},
},
select: {
email: true,
},
});
}
async getActiveManagedUsersAsHost(subscriptionId: string, startTime: Date, endTime: Date) {
return await this.dbRead.prisma.user.findMany({
distinct: ["email"],
where: {
isPlatformManaged: true,
profiles: {
some: {
organization: {
platformBilling: {
subscriptionId,
},
},
},
},
bookings: {
some: {
userId: { not: null },
startTime: {
gte: startTime,
lte: endTime,
},
},
},
},
select: {
email: true,
},
});
}
async getActiveManagedUsersAsAttendee(managedUsersEmails: string[], startTime: Date, endTime: Date) {
return await this.dbRead.prisma.attendee.findMany({
distinct: ["email"],
where: {
email: { in: managedUsersEmails },
booking: {
startTime: {
gte: startTime,
lte: endTime,
},
},
},
select: {
email: true,
},
});
}
}
@@ -52,12 +52,18 @@ export default function App({ Component, pageProps }: AppProps) {
useEffect(() => {
const randomEmailOne = generateRandomEmail();
const randomEmailTwo = generateRandomEmail();
const randomEmailThree = generateRandomEmail();
const randomEmailFour = generateRandomEmail();
const randomEmailFive = generateRandomEmail();
if (!seeding) {
seeding = true;
fetch("/api/managed-user", {
method: "POST",
body: JSON.stringify({ emails: [randomEmailOne, randomEmailTwo] }),
body: JSON.stringify({
emails: [randomEmailOne, randomEmailTwo, randomEmailThree, randomEmailFour, randomEmailFive],
}),
}).then(async (res) => {
const data = await res.json();
setAccessToken(data.accessToken);
@@ -129,7 +135,7 @@ export default function App({ Component, pageProps }: AppProps) {
onDisplayBookerEmbed={() => {
console.log("render booker embed");
}}
bookerBannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1"
bannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1"
bookerCustomClassNames={{
bookerWrapper: "dark",
}}
@@ -17,6 +17,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
const { emails } = JSON.parse(req.body);
const emailOne = emails[0];
const emailTwo = emails[1];
const emailThree = emails[2];
const emailFour = emails[3];
const emailFive = emails[4];
const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } });
if (existingUser && existingUser.calcomUserId) {
@@ -40,6 +43,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
},
});
const localUserThree = await prisma.user.create({
data: {
email: emailThree,
},
});
const localUserFour = await prisma.user.create({
data: {
email: emailFour,
},
});
const localUserFive = await prisma.user.create({
data: {
email: emailFive,
},
});
const response = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
@@ -101,8 +122,104 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
where: { id: localUserTwo.id },
});
const responseThree = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailThree,
name: "Rajiv",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
);
const bodyThree = await responseThree.json();
await prisma.user.update({
data: {
refreshToken: (bodyThree.data?.refreshToken as string) ?? "",
accessToken: (bodyThree.data?.accessToken as string) ?? "",
calcomUserId: bodyThree.data?.user.id,
calcomUsername: (bodyThree.data?.user.username as string) ?? "",
},
where: { id: localUserThree.id },
});
const responseFour = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailFour,
name: "Morgan",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
);
const bodyFour = await responseFour.json();
await prisma.user.update({
data: {
refreshToken: (bodyFour.data?.refreshToken as string) ?? "",
accessToken: (bodyFour.data?.accessToken as string) ?? "",
calcomUserId: bodyFour.data?.user.id,
calcomUsername: (bodyFour.data?.user.username as string) ?? "",
},
where: { id: localUserFour.id },
});
const responseFive = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailFive,
name: "Lauris",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
);
const bodyFive = await responseFive.json();
await prisma.user.update({
data: {
refreshToken: (bodyFive.data?.refreshToken as string) ?? "",
accessToken: (bodyFive.data?.accessToken as string) ?? "",
calcomUserId: bodyFive.data?.user.id,
calcomUsername: (bodyFive.data?.user.username as string) ?? "",
},
where: { id: localUserFive.id },
});
await createDefaultSchedule(body.data?.accessToken as string);
await createDefaultSchedule(bodyTwo.data?.accessToken as string);
await createDefaultSchedule(bodyThree.data?.accessToken as string);
await createDefaultSchedule(bodyFour.data?.accessToken as string);
await createDefaultSchedule(bodyFive.data?.accessToken as string);
// eslint-disable-next-line turbo/no-undeclared-env-vars
const organizationId = process.env.ORGANIZATION_ID;
@@ -117,7 +234,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
await createMembership(+organizationId, team.id, body.data?.user.id);
await createMembership(+organizationId, team.id, bodyTwo.data?.user.id);
await createCollectiveEventType(+organizationId, team.id, [body.data?.user.id, bodyTwo.data?.user.id]);
await createMembership(+organizationId, team.id, bodyThree.data?.user.id);
await createCollectiveEventType(+organizationId, team.id, [
body.data?.user.id,
bodyTwo.data?.user.id,
bodyThree.data?.user.id,
bodyFour.data?.user.id,
bodyFive.data?.user.id,
]);
return res.status(200).json({
id: body?.data?.user?.id,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PlatformBilling" ADD COLUMN "priceId" TEXT;
+1
View File
@@ -1781,6 +1781,7 @@ model PlatformBilling {
customerId String
subscriptionId String?
priceId String?
plan String @default("none")
billingCycleStart Int?