chore: platform api usage based billing using queue (#15085)
* chore: platform api usage based billing using queue * fixup! chore: platform api usage based billing using queue * fixup! fixup! chore: platform api usage based billing using queue * fixup! Merge branch 'chore-platformapi-billing-queue' of github.com:calcom/cal.com into chore-platformapi-billing-queue * fixup! fixup! Merge branch 'chore-platformapi-billing-queue' of github.com:calcom/cal.com into chore-platformapi-billing-queue * fixup! Merge branch 'main' into chore-platformapi-billing-queue * chore: platform billing by userId * fix: fix tests requirement team subscirption * fixup! Merge branch 'main' into chore-platformapi-billing-queue * fixup! fixup! Merge branch 'main' into chore-platformapi-billing-queue * fixup! Merge branch 'main' into chore-platformapi-billing-queue * improve tests logs * fix: silently close redis and queue * fixup! Merge branch 'main' into chore-platformapi-billing-queue * fixup! Merge branch 'main' into chore-platformapi-billing-queue * fixup! fixup! Merge branch 'main' into chore-platformapi-billing-queue * fix: upgrade libraries version
This commit is contained in:
@@ -14,8 +14,11 @@ NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Stripe Billing
|
||||
STRIPE_PRICE_ID_STARTER=
|
||||
STRIPE_PRICE_ID_STARTER_OVERAGE=
|
||||
STRIPE_PRICE_ID_ESSENTIALS=
|
||||
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
|
||||
STRIPE_PRICE_ID_ENTERPRISE=
|
||||
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
|
||||
"reporters": ["default", "jest-summarizing-reporter"]
|
||||
}
|
||||
|
||||
@@ -26,12 +26,13 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-libraries-0.0.20": "npm:@calcom/platform-libraries@0.0.20",
|
||||
"@calcom/platform-libraries-0.0.21": "npm:@calcom/platform-libraries@0.0.21",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.12.4",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
||||
@@ -26,7 +27,18 @@ import { AppController } from "./app.controller";
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
|
||||
RedisModule,
|
||||
BullModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
useFactory: async (redisService: RedisService) => ({
|
||||
redis: {
|
||||
host: redisService.redis.options.host,
|
||||
port: redisService.redis.options.port,
|
||||
},
|
||||
}),
|
||||
inject: [RedisService],
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
|
||||
@@ -19,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.20";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.21";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -150,17 +150,21 @@ export class BookingsController {
|
||||
@Req() req: BookingRequest,
|
||||
@Body() body: CreateBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
|
||||
const { orgSlug, locationUrl } = body;
|
||||
req.headers["x-cal-force-slug"] = orgSlug;
|
||||
try {
|
||||
const booking = await handleNewBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
fromReschedule: booking.fromReschedule,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
@@ -177,14 +181,22 @@ export class BookingsController {
|
||||
@Param("bookingId") bookingId: string,
|
||||
@Body() _: CancelBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse> {
|
||||
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
if (bookingId) {
|
||||
try {
|
||||
req.body.id = parseInt(bookingId);
|
||||
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
||||
const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
bookingId: res.bookingId,
|
||||
bookingUid: res.bookingUid,
|
||||
onlyRemovedAttendee: res.onlyRemovedAttendee,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
this.handleBookingErrors(err);
|
||||
@@ -228,7 +240,14 @@ export class BookingsController {
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
createdBookings.forEach(async (booking) => {
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -253,7 +272,15 @@ export class BookingsController {
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
if (instantMeeting.userId && instantMeeting.bookingUid) {
|
||||
const now = new Date();
|
||||
// add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed
|
||||
now.setSeconds(now.getSeconds() + 10);
|
||||
void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, {
|
||||
uid: instantMeeting.bookingUid,
|
||||
startTime: now,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.20";
|
||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsBoolean()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.20";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.20";
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import {
|
||||
EventTypesPublic,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.20";
|
||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.21";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getEventTypeById,
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
|
||||
+3
-3
@@ -10,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.20";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.20";
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.21";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||
type DatabaseEventType = EventType & EventTypeRelations;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
transformApiScheduleAvailability,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries-0.0.20";
|
||||
import { TRPCError } from "@calcom/platform-libraries-0.0.21";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
@Catch(TRPCError)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
@@ -6,11 +7,24 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
|
||||
providers: [BillingConfigService, BillingService, BillingRepository],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
StripeModule,
|
||||
MembershipsModule,
|
||||
OrganizationsModule,
|
||||
BullModule.registerQueue({
|
||||
name: "billing",
|
||||
limiter: {
|
||||
max: 1,
|
||||
duration: 1000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
|
||||
exports: [BillingService, BillingRepository],
|
||||
controllers: [BillingController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Process, Processor } from "@nestjs/bull";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Job } from "bull";
|
||||
|
||||
export const INCREMENT_JOB = "increment";
|
||||
export const BILLING_QUEUE = "billing";
|
||||
export type IncrementJobDataType = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export type DecrementJobDataType = IncrementJobDataType;
|
||||
|
||||
@Processor(BILLING_QUEUE)
|
||||
export class BillingProcessor {
|
||||
private readonly logger = new Logger(BillingProcessor.name);
|
||||
|
||||
constructor(
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@Process(INCREMENT_JOB)
|
||||
async handleIncrement(job: Job<IncrementJobDataType>) {
|
||||
const { userId } = job.data;
|
||||
try {
|
||||
// get the platform organization of the managed user
|
||||
const team = await this.teamsRepository.findPlatformOrgFromUserId(userId);
|
||||
const teamId = team.id;
|
||||
if (!team.id) {
|
||||
this.logger.error(`User (${userId}) is not part of the platform organization (${teamId}) `, {
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
||||
this.logger.error(`Team ${teamId} did not have stripe subscription associated to it`, {
|
||||
teamId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
if (!stripeSubscription?.id) {
|
||||
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
|
||||
teamId,
|
||||
subscriptionId: billingSubscription.subscriptionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const meteredItem = stripeSubscription.items.data.find(
|
||||
(item) => item.price?.recurring?.usage_type === "metered"
|
||||
);
|
||||
// no metered item found to increase usage, return early
|
||||
if (!meteredItem) {
|
||||
this.logger.error(`Stripe subscription (${stripeSubscription.id} is not usage based`, {
|
||||
teamId,
|
||||
subscriptionId: stripeSubscription.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
});
|
||||
this.logger.log("Increased organization usage for subscription", {
|
||||
subscriptionId: billingSubscription.subscriptionId,
|
||||
teamId,
|
||||
userId,
|
||||
itemId: meteredItem.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to increase usage for Organization", {
|
||||
userId,
|
||||
err,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,38 @@ import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BillingConfigService {
|
||||
private readonly config: Map<PlatformPlan, string>;
|
||||
private readonly config: Map<
|
||||
PlatformPlan,
|
||||
{
|
||||
base: string;
|
||||
overage: string;
|
||||
}
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.config = new Map<PlatformPlan, string>();
|
||||
this.config = new Map<
|
||||
PlatformPlan,
|
||||
{
|
||||
base: string;
|
||||
overage: string;
|
||||
}
|
||||
>();
|
||||
|
||||
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
|
||||
for (const key of planKeys) {
|
||||
this.config.set(
|
||||
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
|
||||
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
|
||||
);
|
||||
this.config.set(PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], {
|
||||
base: process.env[`STRIPE_PRICE_ID_${key}`] ?? "",
|
||||
overage: process.env[`STRIPE_PRICE_ID_${key}_OVERAGE`] ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(plan: PlatformPlan): string | undefined {
|
||||
get(plan: PlatformPlan):
|
||||
| {
|
||||
base: string;
|
||||
overage: string;
|
||||
}
|
||||
| undefined {
|
||||
return this.config.get(plan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
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 { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Queue } from "bull";
|
||||
import { DateTime } from "luxon";
|
||||
import Stripe from "stripe";
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
export class BillingService implements OnModuleDestroy {
|
||||
private logger = new Logger("BillingService");
|
||||
private readonly webAppUrl: string;
|
||||
|
||||
@@ -19,9 +22,10 @@ export class BillingService {
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
private readonly billingConfigService: BillingConfigService
|
||||
private readonly billingConfigService: BillingConfigService,
|
||||
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
|
||||
) {
|
||||
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
}
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
@@ -58,7 +62,10 @@ export class BillingService {
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: this.billingConfigService.get(plan),
|
||||
price: this.billingConfigService.get(plan)?.overage,
|
||||
},
|
||||
{
|
||||
price: this.billingConfigService.get(plan)?.base,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
@@ -99,46 +106,56 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
async increaseUsageForTeam(teamId: number) {
|
||||
try {
|
||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
||||
this.logger.error("Team did not have stripe subscription associated to it", {
|
||||
teamId,
|
||||
});
|
||||
return void 0;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* Adds a job to the queue to increment usage of a stripe subscription.
|
||||
* we delay the job until the booking starts.
|
||||
* the delay ensure we can adapt to cancel / reschedule.
|
||||
*/
|
||||
async increaseUsageByUserId(
|
||||
userId: number,
|
||||
booking: {
|
||||
uid: string;
|
||||
startTime: Date;
|
||||
fromReschedule?: string | null;
|
||||
}
|
||||
) {
|
||||
const { uid, startTime, fromReschedule } = booking;
|
||||
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
const item = stripeSubscription.items.data[0];
|
||||
// legacy plans are licensed, we cannot create usage records against them
|
||||
if (item.price?.recurring?.usage_type === "licensed") {
|
||||
return void 0;
|
||||
}
|
||||
const delay = startTime.getTime() - Date.now();
|
||||
if (fromReschedule) {
|
||||
// cancel the usage increment job for the booking that is being rescheduled
|
||||
await this.cancelUsageByBookingUid(fromReschedule);
|
||||
this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`);
|
||||
}
|
||||
await this.billingQueue.add(
|
||||
INCREMENT_JOB,
|
||||
{
|
||||
userId,
|
||||
} satisfies IncrementJobDataType,
|
||||
{ delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true }
|
||||
);
|
||||
this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`);
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
});
|
||||
} catch (error) {
|
||||
// don't fail the request, log it.
|
||||
this.logger.error("Failed to increase usage for team", {
|
||||
teamId: teamId,
|
||||
error,
|
||||
});
|
||||
/**
|
||||
*
|
||||
* Cancels the usage increment job for a booking when it is cancelled.
|
||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||
*/
|
||||
async cancelUsageByBookingUid(bookingUid: string) {
|
||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`);
|
||||
}
|
||||
}
|
||||
|
||||
async increaseUsageByClientId(clientId: string) {
|
||||
if (this.configService.get("e2e")) {
|
||||
return void 0;
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.billingQueue.close();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
|
||||
if (!team.id) return Promise.resolve(); // noop resolution.
|
||||
|
||||
return this.increaseUsageForTeam(team?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.20";
|
||||
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
|
||||
+2
-1
@@ -163,7 +163,7 @@ describe("OAuth Clients Endpoints", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("User is not in an organization", () => {
|
||||
describe("User is not part of an organization", () => {
|
||||
it(`/GET`, () => {
|
||||
return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403);
|
||||
});
|
||||
@@ -371,6 +371,7 @@ describe("OAuth Clients Endpoints", () => {
|
||||
afterAll(async () => {
|
||||
await teamFixtures.delete(org.id);
|
||||
await usersFixtures.delete(user.id);
|
||||
await platformBillingRepositoryFixture.deleteSubscriptionForTeam(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.20";
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
|
||||
@@ -70,6 +70,26 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findPlatformOrgFromUserId(userId: number) {
|
||||
return this.dbRead.prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
orgProfiles: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
isPlatform: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isPlatform: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgUser(organizationId: number, userId: number) {
|
||||
return this.dbRead.prisma.user.findUnique({
|
||||
where: {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20";
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||
import { Injectable, ConflictException } from "@nestjs/common";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.20";
|
||||
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.21";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import { Injectable, OnModuleDestroy, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
public redis: Redis;
|
||||
private readonly logger = new Logger("RedisService");
|
||||
|
||||
constructor(readonly configService: ConfigService<AppConfig>) {
|
||||
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
||||
@@ -15,6 +16,10 @@ export class RedisService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.redis.disconnect();
|
||||
try {
|
||||
await this.redis.quit();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.20";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.20";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.21";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.21";
|
||||
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.20";
|
||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.21";
|
||||
import { ReserveSlotInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.20";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.20";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.20";
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.21";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class TimezonesService {
|
||||
|
||||
@@ -22,4 +22,17 @@ export class PlatformBillingRepositoryFixture {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSubscriptionForTeam(teamId: number) {
|
||||
// silently try to delete the subscription
|
||||
try {
|
||||
await this.prismaWriteClient.platformBilling.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"husky": "^8.0.0",
|
||||
"i18n-unused": "^0.13.0",
|
||||
"jest-diff": "^29.5.0",
|
||||
"jest-summarizing-reporter": "^1.1.4",
|
||||
"jsdom": "^22.0.0",
|
||||
"lint-staged": "^12.5.0",
|
||||
"mailhog": "^4.16.0",
|
||||
|
||||
@@ -144,6 +144,14 @@ export type CustomRequest = NextApiRequest & {
|
||||
arePlatformEmailsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type HandleCancelBookingResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
onlyRemovedAttendee: boolean;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
async function handler(req: CustomRequest) {
|
||||
const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid } =
|
||||
schemaBookingCancelParams.parse(req.body);
|
||||
@@ -312,7 +320,14 @@ async function handler(req: CustomRequest) {
|
||||
|
||||
// If it's just an attendee of a booking then just remove them from that booking
|
||||
const result = await cancelAttendeeSeat(req, dataForWebhooks);
|
||||
if (result) return { success: true };
|
||||
if (result)
|
||||
return {
|
||||
success: true,
|
||||
onlyRemovedAttendee: true,
|
||||
bookingId: bookingToDelete.id,
|
||||
bookingUid: bookingToDelete.uid,
|
||||
message: "Attendee successfully removed.",
|
||||
} satisfies HandleCancelBookingResponse;
|
||||
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
|
||||
@@ -492,7 +507,13 @@ async function handler(req: CustomRequest) {
|
||||
console.error("Error deleting event", error);
|
||||
}
|
||||
req.statusCode = 200;
|
||||
return { message: "Booking successfully cancelled." };
|
||||
return {
|
||||
success: true,
|
||||
message: "Booking successfully cancelled.",
|
||||
onlyRemovedAttendee: false,
|
||||
bookingId: bookingToDelete.id,
|
||||
bookingUid: bookingToDelete.uid,
|
||||
} satisfies HandleCancelBookingResponse;
|
||||
}
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -86,6 +86,15 @@ const handleInstantMeetingWebhookTrigger = async (args: {
|
||||
}
|
||||
};
|
||||
|
||||
export type HandleInstantMeetingResponse = {
|
||||
message: string;
|
||||
meetingTokenId: number;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
expires: Date;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
let eventType = await getEventTypesFromDB(req.body.eventTypeId);
|
||||
eventType = {
|
||||
@@ -247,8 +256,10 @@ async function handler(req: NextApiRequest) {
|
||||
message: "Success",
|
||||
meetingTokenId: instantMeetingToken.id,
|
||||
bookingId: newBooking.id,
|
||||
bookingUid: newBooking.uid,
|
||||
expires: instantMeetingToken.expires,
|
||||
};
|
||||
userId: newBooking.userId,
|
||||
} satisfies HandleInstantMeetingResponse;
|
||||
}
|
||||
|
||||
export default handler;
|
||||
|
||||
Reference in New Issue
Block a user