From 79dc76239b9e11150ad80dc30ded9dca7b2c05bf Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 20 May 2025 18:20:45 +0200 Subject: [PATCH] fix: apply transaction to public methods of CreditService (#21356) --- .../ee/billing/credit-service.test.ts | 86 +++- .../features/ee/billing/credit-service.ts | 452 ++++++++++++------ packages/lib/server/repository/credits.ts | 85 ++-- packages/lib/server/repository/membership.ts | 6 +- 4 files changed, 439 insertions(+), 190 deletions(-) diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index 6bd8fb7df2..3e79225250 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -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[0] + ) { + return super._getTeamWithAvailableCredits(params); + } + + public async _getUserOrTeamToCharge( + params: Parameters[0] + ) { + return super._getUserOrTeamToCharge(params); + } + + public async _createExpenseLog(props: Parameters[0]) { + return super._createExpenseLog(props); + } + + public async _handleLowCreditBalance( + params: Parameters[0] + ) { + return super._handleLowCreditBalance(params); + } + + public async _getAllCreditsForTeam(params: Parameters[0]) { + return super._getAllCreditsForTeam(params); + } + + public async _getAllCredits(params: Parameters[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, diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index b9196a85c4..4853e232ce 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -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 { 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 = diff --git a/packages/lib/server/repository/credits.ts b/packages/lib/server/repository/credits.ts index ab8e8be8c5..102dbafed6 100644 --- a/packages/lib/server/repository/credits.ts +++ b/packages/lib/server/repository/credits.ts @@ -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, }); } diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 8b607b153d..6f7ab12b6a 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -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,