fix: apply transaction to public methods of CreditService (#21356)

This commit is contained in:
Eunjae Lee
2025-05-20 18:20:45 +02:00
committed by GitHub
parent 95d62354d9
commit 79dc76239b
4 changed files with 439 additions and 190 deletions
@@ -6,12 +6,23 @@ import * as EmailManager from "@calcom/emails/email-manager";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import { CreditType } from "@calcom/prisma/enums";
import { CreditService } from "./credit-service";
import { CreditService as OriginalCreditService } from "./credit-service";
import { StripeBillingService } from "./stripe-billling-service";
import { InternalTeamBilling } from "./teams/internal-team-billing";
const MOCK_TX = prisma;
vi.mock("@calcom/prisma", () => {
return {
default: {
$transaction: vi.fn((fn) => fn(MOCK_TX)),
},
};
});
vi.mock("stripe");
vi.mock("@calcom/lib/constants", async () => {
@@ -41,6 +52,39 @@ vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({
cancelScheduledMessagesAndScheduleEmails: vi.fn(),
}));
// Create a testable version of CreditService that exposes protected methods
class CreditService extends OriginalCreditService {
public async _getTeamWithAvailableCredits(
params: Parameters<OriginalCreditService["_getTeamWithAvailableCredits"]>[0]
) {
return super._getTeamWithAvailableCredits(params);
}
public async _getUserOrTeamToCharge(
params: Parameters<OriginalCreditService["_getUserOrTeamToCharge"]>[0]
) {
return super._getUserOrTeamToCharge(params);
}
public async _createExpenseLog(props: Parameters<OriginalCreditService["_createExpenseLog"]>[0]) {
return super._createExpenseLog(props);
}
public async _handleLowCreditBalance(
params: Parameters<OriginalCreditService["_handleLowCreditBalance"]>[0]
) {
return super._handleLowCreditBalance(params);
}
public async _getAllCreditsForTeam(params: Parameters<OriginalCreditService["_getAllCreditsForTeam"]>[0]) {
return super._getAllCreditsForTeam(params);
}
public async _getAllCredits(params: Parameters<OriginalCreditService["_getAllCredits"]>[0]) {
return super._getAllCredits(params);
}
}
describe("CreditService", () => {
let creditService: CreditService;
let stripeMock: any;
@@ -182,11 +226,11 @@ describe("CreditService", () => {
user: null,
});
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue();
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 20,
additionalCredits: 60,
@@ -206,7 +250,8 @@ describe("CreditService", () => {
creditType: CreditType.MONTHLY,
credits: 5,
smsSid: "sms-123",
})
}),
MOCK_TX
);
expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled();
@@ -243,7 +288,7 @@ describe("CreditService", () => {
vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue();
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: -1,
additionalCredits: 0,
@@ -263,7 +308,8 @@ describe("CreditService", () => {
creditType: CreditType.ADDITIONAL,
credits: 5,
smsSid: "sms-123",
})
}),
MOCK_TX
);
expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled();
@@ -272,7 +318,7 @@ describe("CreditService", () => {
describe("getUserOrTeamToCharge", () => {
it("should return team with remaining credits when teamId is provided", async () => {
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 100,
additionalCredits: 50,
@@ -291,7 +337,7 @@ describe("CreditService", () => {
});
it("should use additional credits when monthly credits are out", async () => {
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 0,
additionalCredits: 50,
@@ -319,7 +365,7 @@ describe("CreditService", () => {
warningSentAt: null,
});
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue({
teamId: 1,
availableCredits: 150,
creditType: CreditType.MONTHLY,
@@ -429,7 +475,7 @@ describe("CreditService", () => {
limitReachedAt: null,
warningSentAt: null,
});
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
const hasAvailableCredits = await creditService.hasAvailableCredits({ userId: 1 });
expect(hasAvailableCredits).toBe(true);
@@ -445,7 +491,7 @@ describe("CreditService", () => {
warningSentAt: null,
});
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
const result = await creditService.hasAvailableCredits({ userId: 1 });
expect(result).toBe(false);
@@ -475,11 +521,11 @@ describe("CreditService", () => {
team: null,
});
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue();
vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
totalMonthlyCredits: 0,
totalRemainingMonthlyCredits: 0,
additionalCredits: 10,
@@ -499,7 +545,8 @@ describe("CreditService", () => {
creditType: CreditType.ADDITIONAL,
credits: 5,
smsSid: "sms-123",
})
}),
MOCK_TX
);
expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled();
@@ -537,9 +584,9 @@ describe("CreditService", () => {
vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue();
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
totalMonthlyCredits: 0,
totalRemainingMonthlyCredits: 0,
additionalCredits: 2,
@@ -559,7 +606,8 @@ describe("CreditService", () => {
creditType: CreditType.ADDITIONAL,
credits: 5,
smsSid: "sms-123",
})
}),
MOCK_TX
);
expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled();
@@ -568,9 +616,9 @@ describe("CreditService", () => {
describe("getUserOrTeamToCharge", () => {
it("should return user with remaining credits when userId is provided", async () => {
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
totalMonthlyCredits: 0,
totalRemainingMonthlyCredits: 0,
additionalCredits: 10,
+317 -135
View File
@@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
import dayjs from "@calcom/dayjs";
import {
sendCreditBalanceLimitReachedEmails,
@@ -12,10 +14,43 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma, { type PrismaTransaction } from "@calcom/prisma";
import { CreditType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[CreditService]"] });
type LowCreditBalanceResultBase = {
team?: {
id: number;
name: string;
adminAndOwners: {
id: number;
name: string | null;
email: string;
t: TFunction;
}[];
};
user?: {
id: number;
name: string | null;
email: string;
t: TFunction;
};
};
type LowCreditBalanceLimitReachedResult = LowCreditBalanceResultBase & {
type: "LIMIT_REACHED";
teamId?: number | null;
userId?: number | null;
};
type LowCreditBalanceWarningResult = LowCreditBalanceResultBase & {
type: "WARNING";
balance: number;
};
type LowCreditBalanceResult = LowCreditBalanceLimitReachedResult | LowCreditBalanceWarningResult | null;
export class CreditService {
async chargeCredits({
userId,
@@ -30,90 +65,122 @@ export class CreditService {
bookingUid?: string;
smsSid?: string;
}) {
let teamIdToCharge = credits === 0 && teamId ? teamId : undefined;
let creditType: CreditType = CreditType.ADDITIONAL;
let remainingCredits;
let userIdToCharge;
if (!teamIdToCharge) {
const result = await this.getUserOrTeamToCharge({
credits: credits ?? 1, // if we don't have exact credits, we check for at east 1 credit available
userId,
teamId,
return await prisma
.$transaction(async (tx) => {
let teamIdToCharge = credits === 0 && teamId ? teamId : undefined;
let creditType: CreditType = CreditType.ADDITIONAL;
let remainingCredits;
let userIdToCharge;
if (!teamIdToCharge) {
const result = await this._getUserOrTeamToCharge({
credits: credits ?? 1, // if we don't have exact credits, we check for at east 1 credit available
userId,
teamId,
tx,
});
teamIdToCharge = result?.teamId;
userIdToCharge = result?.userId;
creditType = result?.creditType ?? creditType;
remainingCredits = result?.remainingCredits;
}
if (!teamIdToCharge && !userIdToCharge) {
log.error("No team or user found to charge. No credit expense log created");
return null;
}
await this._createExpenseLog({
bookingUid,
smsSid,
teamId: teamIdToCharge,
userId: userIdToCharge,
credits,
creditType,
tx,
});
let lowCreditBalanceResult = null;
if (credits) {
lowCreditBalanceResult = await this._handleLowCreditBalance({
teamId: teamIdToCharge,
userId: userIdToCharge,
remainingCredits: remainingCredits ?? 0,
tx,
});
}
return {
teamId: teamIdToCharge,
userId: userIdToCharge,
lowCreditBalanceResult,
};
})
.then(async (result) => {
if (result?.lowCreditBalanceResult) {
// send emails after transaction is successfully committed
await this._handleLowCreditBalanceResult(result.lowCreditBalanceResult);
}
return {
teamId: result?.teamId,
userId: result?.userId,
};
});
teamIdToCharge = result?.teamId;
userIdToCharge = result?.userId;
creditType = result?.creditType ?? creditType;
remainingCredits = result?.remainingCredits;
}
if (!teamIdToCharge && !userIdToCharge) {
log.error("No team or user found to charge. No credit expense log created");
return null;
}
await this.createExpenseLog({
bookingUid,
smsSid,
teamId: teamIdToCharge,
userId: userIdToCharge,
credits,
creditType,
});
if (credits) {
await this.handleLowCreditBalance({
teamId: teamIdToCharge,
userId: userIdToCharge,
remainingCredits: remainingCredits ?? 0,
});
}
return { teamId: teamIdToCharge, userId: userIdToCharge };
}
async hasAvailableCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
if (!IS_SMS_CREDITS_ENABLED) return true;
return await prisma.$transaction(async (tx) => {
if (!IS_SMS_CREDITS_ENABLED) return true;
if (teamId) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId });
if (teamId) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId }, tx);
const limitReached =
creditBalance?.limitReachedAt &&
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
const limitReached =
creditBalance?.limitReachedAt &&
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
if (!limitReached) return true;
if (!limitReached) return true;
// check if team is still out of credits
const teamCredits = await this.getAllCreditsForTeam(teamId);
const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits;
// check if team is still out of credits
const teamCredits = await this._getAllCreditsForTeam({ teamId, tx });
const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits;
if (availableCredits > 0) {
await CreditsRepository.updateCreditBalance({
teamId,
data: {
limitReachedAt: null,
warningSentAt: null,
},
});
return true;
if (availableCredits > 0) {
await CreditsRepository.updateCreditBalance(
{
teamId,
data: {
limitReachedAt: null,
warningSentAt: null,
},
},
tx
);
return true;
}
}
}
if (userId) {
const teamWithAvailableCredits = await this.getTeamWithAvailableCredits(userId);
if (userId) {
const teamWithAvailableCredits = await this._getTeamWithAvailableCredits({ userId, tx });
if (teamWithAvailableCredits && teamWithAvailableCredits?.availableCredits > 0) return true;
if (teamWithAvailableCredits && teamWithAvailableCredits?.availableCredits > 0) return true;
const userCredits = await this.getAllCredits({ userId });
const userCredits = await this._getAllCredits({ userId, tx });
return userCredits.additionalCredits > 0;
}
return userCredits.additionalCredits > 0;
}
return false;
return false;
});
}
async getTeamWithAvailableCredits(userId: number) {
const memberships = await MembershipRepository.findAllAcceptedMemberships(userId);
return prisma.$transaction(async (tx) => {
return this._getTeamWithAvailableCredits({ userId, tx });
});
}
protected async _getTeamWithAvailableCredits({ userId, tx }: { userId: number; tx: PrismaTransaction }) {
const memberships = await MembershipRepository.findAllAcceptedMemberships(userId, tx);
if (memberships.length === 0) {
return null;
@@ -121,9 +188,9 @@ export class CreditService {
//check if user is member of team that has available credits
for (const membership of memberships) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId });
const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }, tx);
const allCredits = await this.getAllCreditsForTeam(membership.teamId);
const allCredits = await this._getAllCreditsForTeam({ teamId: membership.teamId, tx });
const limitReached =
creditBalance?.limitReachedAt &&
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
@@ -132,13 +199,16 @@ export class CreditService {
if (!limitReached || availableCredits > 0) {
if (limitReached) {
await CreditsRepository.updateCreditBalance({
teamId: membership.teamId,
data: {
limitReachedAt: null,
warningSentAt: null,
await CreditsRepository.updateCreditBalance(
{
teamId: membership.teamId,
data: {
limitReachedAt: null,
warningSentAt: null,
},
},
});
tx
);
}
return {
teamId: membership.teamId,
@@ -167,9 +237,25 @@ export class CreditService {
credits: number;
userId?: number | null;
teamId?: number | null;
}) {
return prisma.$transaction(async (tx) => {
return this._getUserOrTeamToCharge({ credits, userId, teamId, tx });
});
}
protected async _getUserOrTeamToCharge({
credits,
userId,
teamId,
tx,
}: {
credits: number;
userId?: number | null;
teamId?: number | null;
tx: PrismaTransaction;
}) {
if (teamId) {
const teamCredits = await this.getAllCreditsForTeam(teamId);
const teamCredits = await this._getAllCreditsForTeam({ teamId, tx });
const remaningMonthlyCredits =
teamCredits.totalRemainingMonthlyCredits > 0 ? teamCredits.totalRemainingMonthlyCredits : 0;
return {
@@ -180,12 +266,12 @@ export class CreditService {
}
if (userId) {
const team = await this.getTeamWithAvailableCredits(userId);
const team = await this._getTeamWithAvailableCredits({ userId, tx });
if (team) {
return { ...team, remainingCredits: team.availableCredits - credits };
}
const userCredits = await this.getAllCredits({ userId });
const userCredits = await this._getAllCredits({ userId, tx });
return {
userId,
@@ -196,48 +282,58 @@ export class CreditService {
return null;
}
private async createExpenseLog(props: {
protected async _createExpenseLog(props: {
bookingUid?: string;
smsSid?: string;
teamId?: number;
userId?: number;
credits: number | null;
creditType: CreditType;
tx: PrismaTransaction;
}) {
const { credits, creditType, bookingUid, smsSid, teamId, userId } = props;
const { credits, creditType, bookingUid, smsSid, teamId, userId, tx } = props;
let creditBalance: { id: string; additionalCredits: number } | null | undefined =
await CreditsRepository.findCreditBalance({ teamId, userId });
await CreditsRepository.findCreditBalance({ teamId, userId }, tx);
if (!creditBalance) {
creditBalance = await CreditsRepository.createCreditBalance({
teamId,
userId,
});
creditBalance = await CreditsRepository.createCreditBalance(
{
teamId,
userId,
},
tx
);
}
if (credits && creditType === CreditType.ADDITIONAL) {
const decrementValue =
credits <= creditBalance.additionalCredits ? credits : creditBalance.additionalCredits;
await CreditsRepository.updateCreditBalance({
id: creditBalance.id,
data: {
additionalCredits: {
decrement: decrementValue,
await CreditsRepository.updateCreditBalance(
{
id: creditBalance.id,
data: {
additionalCredits: {
decrement: decrementValue,
},
},
},
});
tx
);
}
if (creditBalance) {
// also track logs with undefined credits (will be set on the cron job)
await CreditsRepository.createCreditExpenseLog({
creditBalanceId: creditBalance.id,
credits,
creditType,
date: new Date(),
bookingUid,
smsSid,
});
await CreditsRepository.createCreditExpenseLog(
{
creditBalanceId: creditBalance.id,
credits,
creditType,
date: new Date(),
bookingUid,
smsSid,
},
tx
);
}
}
@@ -248,18 +344,20 @@ export class CreditService {
- Sends limit reached email
- cancels all already scheduled SMS (from the next two hours)
*/
async handleLowCreditBalance({
protected async _handleLowCreditBalance({
teamId,
userId,
remainingCredits,
tx,
}: {
teamId?: number | null;
userId?: number | null;
remainingCredits: number;
}) {
tx: PrismaTransaction;
}): Promise<LowCreditBalanceResult> {
let warningLimit = 0;
if (teamId) {
const { totalMonthlyCredits } = await this.getAllCreditsForTeam(teamId);
const { totalMonthlyCredits } = await this._getAllCreditsForTeam({ teamId, tx });
warningLimit = totalMonthlyCredits * 0.2;
} else if (userId) {
const billingService = new StripeBillingService();
@@ -269,13 +367,13 @@ export class CreditService {
}
if (remainingCredits < warningLimit) {
const creditBalance = await CreditsRepository.findCreditBalanceWithTeamOrUser({ teamId, userId });
const creditBalance = await CreditsRepository.findCreditBalanceWithTeamOrUser({ teamId, userId }, tx);
if (
creditBalance?.limitReachedAt &&
(!teamId || dayjs(creditBalance?.limitReachedAt).isAfter(dayjs().startOf("month")))
) {
return; // user has limit already reached or team has already reached limit this month
return null; // user has limit already reached or team has already reached limit this month
}
const teamWithAdmins = creditBalance?.team
@@ -301,58 +399,122 @@ export class CreditService {
if ((!teamWithAdmins || !teamWithAdmins.adminAndOwners?.length) && !user) {
log.error("Team or user not found to send warning email");
return;
return null;
}
if (remainingCredits <= 0) {
await sendCreditBalanceLimitReachedEmails({
await CreditsRepository.updateCreditBalance(
{
teamId,
userId,
data: {
limitReachedAt: new Date(),
warningSentAt: null,
},
},
tx
);
return {
type: "LIMIT_REACHED" as const,
team: teamWithAdmins,
user,
});
await CreditsRepository.updateCreditBalance({
teamId,
userId,
data: {
limitReachedAt: new Date(),
warningSentAt: null,
},
});
await cancelScheduledMessagesAndScheduleEmails({ teamId, userId });
return;
};
}
if (
creditBalance?.warningSentAt &&
(!teamId || dayjs(creditBalance?.warningSentAt).isAfter(dayjs().startOf("month")))
) {
return; // user has already received a warning or team has already sent warning email this month
return null; // user has already received a warning or team has already sent warning email this month
}
await sendCreditBalanceLowWarningEmails({
await CreditsRepository.updateCreditBalance(
{
teamId,
userId,
data: {
warningSentAt: new Date(),
},
},
tx
);
return {
type: "WARNING" as const,
balance: remainingCredits,
team: teamWithAdmins,
user,
});
};
}
await CreditsRepository.updateCreditBalance({
await CreditsRepository.updateCreditBalance(
{
teamId,
userId,
data: {
warningSentAt: new Date(),
warningSentAt: null,
limitReachedAt: null,
},
});
return;
}
await CreditsRepository.updateCreditBalance({
teamId,
userId,
data: {
warningSentAt: null,
limitReachedAt: null,
},
});
tx
);
return null;
}
private async _handleLowCreditBalanceResult(result: LowCreditBalanceResult) {
if (!result) return;
try {
if (result.type === "LIMIT_REACHED") {
await Promise.all([
sendCreditBalanceLimitReachedEmails({
team: result.team,
user: result.user,
}).catch((error) => {
log.error("Failed to send credit limit reached email", error, { result });
}),
cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch(
(error) => {
log.error("Failed to cancel scheduled messages", error, { result });
}
),
]);
} else if (result.type === "WARNING") {
await sendCreditBalanceLowWarningEmails({
balance: result.balance,
team: result.team,
user: result.user,
}).catch((error) => {
log.error("Failed to send credit warning email", error, { result });
});
}
} catch (error) {
// Catch any other unexpected errors
log.error("Unexpected error handling low credit balance result", error, { result });
}
}
async handleLowCreditBalance({
teamId,
userId,
remainingCredits,
}: {
teamId?: number | null;
userId?: number | null;
remainingCredits: number;
}) {
return prisma
.$transaction(async (tx) => {
const result = await this._handleLowCreditBalance({ teamId, userId, remainingCredits, tx });
return result;
})
.then(async (result) => {
await this._handleLowCreditBalanceResult(result);
});
}
async getMonthlyCredits(teamId: number) {
@@ -388,12 +550,26 @@ export class CreditService {
}
async getAllCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
return prisma.$transaction(async (tx) => {
return this._getAllCredits({ userId, teamId, tx });
});
}
protected async _getAllCredits({
userId,
teamId,
tx,
}: {
userId?: number | null;
teamId?: number | null;
tx: PrismaTransaction;
}) {
if (teamId) {
return this.getAllCreditsForTeam(teamId);
return this._getAllCreditsForTeam({ teamId, tx });
}
if (userId) {
const creditBalance = await CreditsRepository.findCreditBalance({ userId });
const creditBalance = await CreditsRepository.findCreditBalance({ userId }, tx);
return {
totalMonthlyCredits: 0,
@@ -410,7 +586,13 @@ export class CreditService {
}
async getAllCreditsForTeam(teamId: number) {
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs({ teamId });
return prisma.$transaction(async (tx) => {
return this._getAllCreditsForTeam({ teamId, tx });
});
}
protected async _getAllCreditsForTeam({ teamId, tx }: { teamId: number; tx: PrismaTransaction }) {
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs({ teamId }, tx);
const totalMonthlyCredits = await this.getMonthlyCredits(teamId);
const totalMonthlyCreditsUsed =
+52 -33
View File
@@ -1,10 +1,15 @@
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import prisma, { type PrismaTransaction } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { CreditType } from "@calcom/prisma/enums";
export class CreditsRepository {
static async findCreditBalance({ teamId, userId }: { teamId?: number; userId?: number }) {
static async findCreditBalance(
{ teamId, userId }: { teamId?: number; userId?: number },
tx?: PrismaTransaction
) {
const prismaClient = tx ?? prisma;
const select = {
id: true,
additionalCredits: true,
@@ -13,7 +18,7 @@ export class CreditsRepository {
};
if (teamId) {
return await prisma.creditBalance.findUnique({
return await prismaClient.creditBalance.findUnique({
where: {
teamId,
},
@@ -22,20 +27,25 @@ export class CreditsRepository {
}
if (userId) {
return await prisma.creditBalance.findUnique({
return await prismaClient.creditBalance.findUnique({
where: { userId },
select,
});
}
}
static async findCreditBalanceWithTeamOrUser({
teamId,
userId,
}: {
teamId?: number | null;
userId?: number | null;
}) {
static async findCreditBalanceWithTeamOrUser(
{
teamId,
userId,
}: {
teamId?: number | null;
userId?: number | null;
},
tx?: PrismaTransaction
) {
const prismaClient = tx ?? prisma;
const select = {
id: true,
additionalCredits: true,
@@ -69,7 +79,7 @@ export class CreditsRepository {
},
};
if (teamId) {
return await prisma.creditBalance.findUnique({
return await prismaClient.creditBalance.findUnique({
where: {
teamId,
},
@@ -78,15 +88,17 @@ export class CreditsRepository {
}
if (userId) {
return await prisma.creditBalance.findUnique({
return await prismaClient.creditBalance.findUnique({
where: { userId },
select,
});
}
}
static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId: number }) {
return await prisma.creditBalance.findUnique({
static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId: number }, tx?: PrismaTransaction) {
const prismaClient = tx ?? prisma;
return await prismaClient.creditBalance.findUnique({
where: {
teamId,
},
@@ -109,33 +121,37 @@ export class CreditsRepository {
});
}
static async updateCreditBalance({
id,
teamId,
userId,
data,
}: {
id?: string;
teamId?: number | null;
userId?: number | null;
data: Prisma.CreditBalanceUncheckedUpdateInput;
}) {
static async updateCreditBalance(
{
id,
teamId,
userId,
data,
}: {
id?: string;
teamId?: number | null;
userId?: number | null;
data: Prisma.CreditBalanceUncheckedUpdateInput;
},
tx?: PrismaTransaction
) {
const prismaClient = tx ?? prisma;
if (id) {
return prisma.creditBalance.update({
return prismaClient.creditBalance.update({
where: { id },
data,
});
}
if (teamId) {
return prisma.creditBalance.update({
return prismaClient.creditBalance.update({
where: { teamId },
data,
});
}
if (userId) {
return prisma.creditBalance.update({
return prismaClient.creditBalance.update({
where: { userId },
data,
});
@@ -144,14 +160,17 @@ export class CreditsRepository {
return null;
}
static async createCreditBalance(data: Prisma.CreditBalanceUncheckedCreateInput) {
return prisma.creditBalance.create({
static async createCreditBalance(data: Prisma.CreditBalanceUncheckedCreateInput, tx?: PrismaTransaction) {
return (tx ?? prisma).creditBalance.create({
data,
});
}
static async createCreditExpenseLog(data: Prisma.CreditExpenseLogUncheckedCreateInput) {
return prisma.creditExpenseLog.create({
static async createCreditExpenseLog(
data: Prisma.CreditExpenseLogUncheckedCreateInput,
tx?: PrismaTransaction
) {
return (tx ?? prisma).creditExpenseLog.create({
data,
});
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { availabilityUserSelect, prisma } from "@calcom/prisma";
import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -310,8 +310,8 @@ export class MembershipRepository {
},
});
}
static async findAllAcceptedMemberships(userId: number) {
return prisma.membership.findMany({
static async findAllAcceptedMemberships(userId: number, tx?: PrismaTransaction) {
return (tx ?? prisma).membership.findMany({
where: {
userId,
accepted: true,