feat: active managed user billing (v1) (#20499)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
@@ -1781,6 +1781,7 @@ model PlatformBilling {
|
||||
|
||||
customerId String
|
||||
subscriptionId String?
|
||||
priceId String?
|
||||
plan String @default("none")
|
||||
|
||||
billingCycleStart Int?
|
||||
|
||||
Reference in New Issue
Block a user