From 3bcaf0ec22d4f9a1a0437127991a44924bd06238 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:30:22 +0200 Subject: [PATCH] fix: move team credits to org on upgarde (#24386) * move credits from team to org * add test --------- Co-authored-by: CarinaWolli Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- .../features/ee/billing/credit-service.ts | 61 +++++++++++++++++++ .../__tests__/createTeams.handler.test.ts | 52 +++++++++++++++- .../organizations/createTeams.handler.ts | 4 ++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 3f80f16d81..d0b9e95bc6 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -689,4 +689,65 @@ export class CreditService { totalCreditsUsedThisMonth, }; } + + async moveCreditsFromTeamToOrg({ teamId, orgId }: { teamId: number; orgId: number }) { + return await prisma.$transaction(async (tx) => { + // Get team's credit balance + const teamCreditBalance = await CreditsRepository.findCreditBalance({ teamId }, tx); + + if (!teamCreditBalance || teamCreditBalance.additionalCredits <= 0) { + log.info("No credits to transfer from team to org", { teamId, orgId }); + return; + } + + // Get or create org's credit balance + let orgCreditBalance = await CreditsRepository.findCreditBalance({ teamId: orgId }, tx); + + if (!orgCreditBalance) { + orgCreditBalance = await CreditsRepository.createCreditBalance( + { + teamId: orgId, + }, + tx + ); + } + + const creditsToTransfer = teamCreditBalance.additionalCredits; + + // Transfer credits from team to org + await CreditsRepository.updateCreditBalance( + { + teamId, + data: { + additionalCredits: 0, + }, + }, + tx + ); + + await CreditsRepository.updateCreditBalance( + { + teamId: orgId, + data: { + additionalCredits: { + increment: creditsToTransfer, + }, + }, + }, + tx + ); + + log.info("Successfully transferred credits from team to org", { + teamId, + orgId, + creditsTransferred: creditsToTransfer, + }); + + return { + creditsTransferred: creditsToTransfer, + teamId, + orgId, + }; + }); + } } diff --git a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts index 0f96c23825..4d826925c8 100644 --- a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts @@ -311,7 +311,6 @@ describe("createTeams handler", () => { // Verify platform team was not moved const platformTeam = await prismock.team.findFirst({ where: { name: "Platform Team" }, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true include: { parent: true }, }); @@ -423,4 +422,55 @@ describe("createTeams handler", () => { const redirectsWithNullFrom = redirects.filter((redirect) => redirect.from === null); expect(redirectsWithNullFrom).toHaveLength(0); }); + + it("should transfer credits from team to organization when moving team", async () => { + const { owner, organization, teams } = await createScenario({ + teams: [{ name: "Team With Credits", slug: "team-with-credits", addToParentId: null }], + }); + + const teamToMove = teams[0]; + + // Add credits to the team before moving + await prismock.creditBalance.create({ + data: { + teamId: teamToMove.id, + additionalCredits: 500, + limitReachedAt: null, + warningSentAt: null, + }, + }); + + const result = await createTeamsHandler({ + ctx: { + user: { + id: owner.id, + organizationId: organization.id, + }, + }, + input: { + teamNames: [], + orgId: organization.id, + moveTeams: [ + { + id: teamToMove.id, + shouldMove: true, + newSlug: "moved-team-with-credits", + }, + ], + creationSource: CreationSource.WEBAPP, + }, + }); + + expect(result).toEqual({ duplicatedSlugs: [] }); + + const teamCreditBalance = await prismock.creditBalance.findFirst({ + where: { teamId: teamToMove.id }, + }); + expect(teamCreditBalance?.additionalCredits).toBe(0); + + const orgCreditBalance = await prismock.creditBalance.findFirst({ + where: { teamId: organization.id }, + }); + expect(orgCreditBalance?.additionalCredits).toBe(500); + }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index a6a93a7792..99b57622ef 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -1,4 +1,5 @@ import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import { CreditService } from "@calcom/features/ee/billing/credit-service"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import logger from "@calcom/lib/logger"; @@ -230,6 +231,9 @@ async function moveTeam({ parentId: org.id, }, }); + + const creditService = new CreditService(); + await creditService.moveCreditsFromTeamToOrg({ teamId, orgId: org.id }); } catch (error) { log.error( "Error while moving team to organization",