Skip to content

Commit 109c303

Browse files
feat: active managed user billing (v1) (calcom#20499)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
1 parent a52a0e9 commit 109c303

10 files changed

Lines changed: 325 additions & 9 deletions

File tree

apps/api/v2/src/modules/billing/billing.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
12
import { BillingProcessor } from "@/modules/billing/billing.processor";
23
import { BillingRepository } from "@/modules/billing/billing.repository";
34
import { BillingController } from "@/modules/billing/controllers/billing.controller";
45
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
56
import { BillingService } from "@/modules/billing/services/billing.service";
67
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
78
import { MembershipsModule } from "@/modules/memberships/memberships.module";
9+
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
810
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
911
import { PrismaModule } from "@/modules/prisma/prisma.module";
1012
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -33,6 +35,8 @@ import { Module } from "@nestjs/common";
3335
BillingRepository,
3436
BillingProcessor,
3537
ManagedOrganizationsBillingService,
38+
OAuthClientRepository,
39+
BookingsRepository_2024_08_13,
3640
],
3741
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
3842
controllers: [BillingController],

apps/api/v2/src/modules/billing/billing.repository.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,21 @@ export class BillingRepository {
1515
},
1616
});
1717

18+
async getBillingForTeamBySubscriptionId(subscriptionId: string) {
19+
return this.dbRead.prisma.platformBilling.findFirst({
20+
where: {
21+
subscriptionId,
22+
},
23+
});
24+
}
25+
1826
async updateTeamBilling(
1927
teamId: number,
2028
billingStart: number,
2129
billingEnd: number,
2230
plan: PlatformPlan,
23-
subscriptionId?: string
31+
subscriptionId?: string,
32+
priceId?: string
2433
) {
2534
return this.dbWrite.prisma.platformBilling.update({
2635
where: {
@@ -32,6 +41,7 @@ export class BillingRepository {
3241
subscriptionId,
3342
plan: plan.toString(),
3443
overdue: false,
44+
priceId,
3545
},
3646
});
3747
}

apps/api/v2/src/modules/billing/controllers/billing.controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ export class BillingController {
127127
case "customer.subscription.deleted":
128128
await this.billingService.handleStripeSubscriptionDeleted(event);
129129
break;
130+
case "invoice.created":
131+
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
132+
break;
130133
case "invoice.payment_failed":
131134
await this.billingService.handleStripePaymentFailed(event);
132135
break;

apps/api/v2/src/modules/billing/services/billing.service.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { AppConfig } from "@/config/type";
2+
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
23
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
34
import { BillingRepository } from "@/modules/billing/billing.repository";
45
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
56
import { PlatformPlan } from "@/modules/billing/types";
7+
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
68
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
79
import { StripeService } from "@/modules/stripe/stripe.service";
10+
import { UsersRepository } from "@/modules/users/users.repository";
811
import { InjectQueue } from "@nestjs/bull";
912
import {
1013
BadRequestException,
@@ -30,6 +33,9 @@ export class BillingService implements OnModuleDestroy {
3033
private readonly billingRepository: BillingRepository,
3134
private readonly configService: ConfigService<AppConfig>,
3235
private readonly billingConfigService: BillingConfigService,
36+
private readonly usersRepository: UsersRepository,
37+
private readonly oAuthClientRepository: OAuthClientRepository,
38+
private readonly bookingsRepository: BookingsRepository_2024_08_13,
3339
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
3440
) {
3541
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
@@ -119,7 +125,7 @@ export class BillingService implements OnModuleDestroy {
119125
return url;
120126
}
121127

122-
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
128+
async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
123129
const billingCycleStart = DateTime.now().get("day");
124130
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
125131

@@ -132,6 +138,25 @@ export class BillingService implements OnModuleDestroy {
132138
);
133139
}
134140

141+
async setPerActiveUserSubscriptionForTeam(
142+
teamId: number,
143+
subscriptionId: string,
144+
plan: PlatformPlan,
145+
priceId: string
146+
) {
147+
const billingCycleStart = DateTime.now().get("day");
148+
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
149+
150+
return this.billingRepository.updateTeamBilling(
151+
teamId,
152+
billingCycleStart,
153+
billingCycleEnd,
154+
plan,
155+
subscriptionId,
156+
priceId
157+
);
158+
}
159+
135160
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
136161
const subscription = event.data.object as Stripe.Subscription;
137162
const teamId = subscription?.metadata?.teamId;
@@ -204,9 +229,19 @@ export class BillingService implements OnModuleDestroy {
204229
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
205230
return;
206231
}
232+
const isPriceIdPresent = Boolean(checkoutSession.metadata?.priceId);
207233

208-
if (checkoutSession.mode === "subscription") {
209-
await this.setSubscriptionForTeam(
234+
if (checkoutSession.mode === "subscription" && isPriceIdPresent) {
235+
await this.setPerActiveUserSubscriptionForTeam(
236+
teamId,
237+
checkoutSession.subscription as string,
238+
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan],
239+
checkoutSession.metadata?.priceId
240+
);
241+
}
242+
243+
if (checkoutSession.mode === "subscription" && !isPriceIdPresent) {
244+
await this.setPerBookingSubscriptionForTeam(
210245
teamId,
211246
checkoutSession.subscription as string,
212247
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
@@ -220,6 +255,67 @@ export class BillingService implements OnModuleDestroy {
220255
return;
221256
}
222257

258+
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) {
259+
const invoice = event.data.object as Stripe.Invoice;
260+
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
261+
262+
if (!subscriptionId) {
263+
throw new NotFoundException("No subscription found for team");
264+
}
265+
266+
const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId);
267+
268+
if (teamWithBilling?.plan === "PER_ACTIVE_USER") {
269+
const activeManagedUsersCount = await this.getActiveManagedUsersCount(
270+
subscriptionId,
271+
new Date(invoice.period_start * 1000),
272+
new Date(invoice.period_end * 1000)
273+
);
274+
275+
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
276+
items: [
277+
{
278+
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
279+
},
280+
],
281+
});
282+
}
283+
}
284+
285+
async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) {
286+
const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(
287+
subscriptionId
288+
);
289+
290+
if (!managedUsersEmails) return 0;
291+
292+
if (!invoiceStart || !invoiceEnd) {
293+
this.logger.log("Invoice period start or end date is null");
294+
return 0;
295+
}
296+
297+
const activeManagedUserEmailsAsHost = await this.usersRepository.getActiveManagedUsersAsHost(
298+
subscriptionId,
299+
invoiceStart,
300+
invoiceEnd
301+
);
302+
303+
const activeHostEmails = activeManagedUserEmailsAsHost.map((email) => email.email);
304+
const notActiveHostEmails = managedUsersEmails
305+
.filter((email) => !activeHostEmails.includes(email.email))
306+
.map((email) => email.email);
307+
308+
if (notActiveHostEmails.length === 0) return activeManagedUserEmailsAsHost.length;
309+
310+
const activeManagedUserEmailsAsAttendee = await this.usersRepository.getActiveManagedUsersAsAttendee(
311+
notActiveHostEmails,
312+
invoiceStart,
313+
invoiceEnd
314+
);
315+
316+
return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length;
317+
}
318+
223319
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
224320
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
225321

@@ -263,7 +359,7 @@ export class BillingService implements OnModuleDestroy {
263359
proration_behavior: "create_prorations",
264360
});
265361

266-
await this.setSubscriptionForTeam(
362+
await this.setPerBookingSubscriptionForTeam(
267363
teamId,
268364
teamWithBilling?.platformBilling?.subscriptionId,
269365
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]

apps/api/v2/src/modules/billing/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export enum PlatformPlan {
44
ESSENTIALS = "ESSENTIALS",
55
SCALE = "SCALE",
66
ENTERPRISE = "ENTERPRISE",
7+
PER_ACTIVE_USER = "PER_ACTIVE_USER",
78
}
89

9-
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
10+
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";

apps/api/v2/src/modules/users/users.repository.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,4 +317,73 @@ export class UsersRepository {
317317
},
318318
});
319319
}
320+
321+
async getOrgsManagedUserEmailsBySubscriptionId(subscriptionId: string) {
322+
return await this.dbRead.prisma.user.findMany({
323+
distinct: ["email"],
324+
where: {
325+
isPlatformManaged: true,
326+
profiles: {
327+
some: {
328+
organization: {
329+
platformBilling: {
330+
subscriptionId,
331+
},
332+
},
333+
},
334+
},
335+
},
336+
select: {
337+
email: true,
338+
},
339+
});
340+
}
341+
342+
async getActiveManagedUsersAsHost(subscriptionId: string, startTime: Date, endTime: Date) {
343+
return await this.dbRead.prisma.user.findMany({
344+
distinct: ["email"],
345+
where: {
346+
isPlatformManaged: true,
347+
profiles: {
348+
some: {
349+
organization: {
350+
platformBilling: {
351+
subscriptionId,
352+
},
353+
},
354+
},
355+
},
356+
bookings: {
357+
some: {
358+
userId: { not: null },
359+
startTime: {
360+
gte: startTime,
361+
lte: endTime,
362+
},
363+
},
364+
},
365+
},
366+
select: {
367+
email: true,
368+
},
369+
});
370+
}
371+
372+
async getActiveManagedUsersAsAttendee(managedUsersEmails: string[], startTime: Date, endTime: Date) {
373+
return await this.dbRead.prisma.attendee.findMany({
374+
distinct: ["email"],
375+
where: {
376+
email: { in: managedUsersEmails },
377+
booking: {
378+
startTime: {
379+
gte: startTime,
380+
lte: endTime,
381+
},
382+
},
383+
},
384+
select: {
385+
email: true,
386+
},
387+
});
388+
}
320389
}

packages/platform/examples/base/src/pages/_app.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,18 @@ export default function App({ Component, pageProps }: AppProps) {
5252
useEffect(() => {
5353
const randomEmailOne = generateRandomEmail();
5454
const randomEmailTwo = generateRandomEmail();
55+
const randomEmailThree = generateRandomEmail();
56+
const randomEmailFour = generateRandomEmail();
57+
const randomEmailFive = generateRandomEmail();
58+
5559
if (!seeding) {
5660
seeding = true;
5761
fetch("/api/managed-user", {
5862
method: "POST",
5963

60-
body: JSON.stringify({ emails: [randomEmailOne, randomEmailTwo] }),
64+
body: JSON.stringify({
65+
emails: [randomEmailOne, randomEmailTwo, randomEmailThree, randomEmailFour, randomEmailFive],
66+
}),
6167
}).then(async (res) => {
6268
const data = await res.json();
6369
setAccessToken(data.accessToken);
@@ -129,7 +135,7 @@ export default function App({ Component, pageProps }: AppProps) {
129135
onDisplayBookerEmbed={() => {
130136
console.log("render booker embed");
131137
}}
132-
bookerBannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1"
138+
bannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1"
133139
bookerCustomClassNames={{
134140
bookerWrapper: "dark",
135141
}}

0 commit comments

Comments
 (0)