Files
cal.diy/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts
T
7e4d9e26c6 refactor: implement DI in team billing service and team billing data repository factory (#24803)
* Move TeamBillingRepositories

* WIP refactor team internal billing service

* Remove duplicate billing repository files

* Remove logic check in repository for billing is enabled

* Rename repository to `TeamBillingData`

* Use repository factory in main service

* Fix new import paths

* Rename  to

* Ensure `IS_TEAM_BILLING_ENABLED` is of type boolean

* Rename classes to TeamBillingService and TeamBillingServiceFactory

* Implement DI in `BookingServiceFactory`

* `TeamBillingService` use repository in `getOrgIfNeeded`

* DI `isTeamBillingEnabled` to `TeamBillingServiceFactory`

* Rename files for consistency

* Return stub BillingRepository if billing is not enabled

* Move Stripe billing service to service folder

* Rename file

* `StripeBillingService.getSubscriptionStatus` return `SubscriptionStatus`

* Type fices in StripeBillingService

* Type fix in `stubTeamBillingService`

* DI the `BillingProviderService` into the `TeamBillingService`

* Implement DI in `skipTeamTrials.handler`

* Implement DI for team billing in `inviteMember.handler`

* `skipTeamTrials.handler` use `team.isOrganization`

* Implement DI for billing in `hasActiveTeamPlan.handler`

* Type fixes

* Implement DI in `bulkDeleteUsers.handler`

* Implement `BillingProviderServiceFactory` in `updateProfile.handler`

* Implment `BillingProviderServiceFactory` in `buyCredits.handler`

* Fix import in `stripeCustomer.handler`

* Add a constructor to `teamBillingServiceFactory`

* Add DI to `PrismaTeamBillingRepository`

* Add DI to `StripeBillingService`

* Implement singleton in `BillingProviderServiceFactory`

* Add DI folder and contents to billing folder

* Use `getTeamBillingServiceFactory` in `inviteMember.handler`

* Add `saveTeamBilling` method to `ITeamBillingService`

* Implement DI in new team route

* Implement DI in `teamService`

* Implement DI in `OrganizationPaymentService`

* Implement DI in `credit-service`

* In `StripeBillingService` remove `static` from status methods

* Implemnt DI in `_invoice.paid.org`

* Refactor `hasActiveTeamPlan` to use `getTeamBillingFactory`

* Refactor `skipTeamTrials` to use `getTeamBillingFactory`

* Refactor `skipTeamTrials` to use `getTeamBillingServiceFactory`

* `stripeCustomer.handler` to use `getBillingProviderService`

* Remove old factories

* Type fix

* Remove unused factory

* Refactor `updateProfile.handler` to use `getBillingProviderService`

* Change name to `TeamBillingDataRepositoryFactory`

* Type Prisma return in `prisma.module`

* Type fix

* Refactor `buyCredits.handler` to use `getBillingProviderService`

* Refactor `credit-service` to use billing DI containers

* Type fix

* Add `getTeamBillingDataRepository`

* Refactor `_invoice.paid.org` to use DI container

* Refactor `_customer.subscription.deleted.team-plan` to use DI container

* Refactor `calcomHandler` to use DI container

* Refactor `getCustomerAndCheckoutSession` to use DI container

* Refactor `verify-email` to use DI containers

* Refactor `api/create/route` to use DI container

* Refactor downgradeUsers to use DI container

* Type fix

* Clean up console.logs

* Add await to `this.billingRepository.create` in `saveTeamBilling`

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Fix type errors

* Address comments

* fix: update tests to work with new DI pattern

- Update teamBillingService.test.ts to properly inject DI dependencies
- Remove unused billingModule import and mock
- Fix import naming in teamService.integration-test.ts (remove unused rename)
- Fix import path for TeamBillingPublishResponseStatus

All tests now properly mock IBillingProviderService, ITeamBillingDataRepository,
and IBillingRepository instead of using the old BillingRepositoryFactory pattern.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add compatibility layer and env setup for unit tests

- Add STRIPE_PRIVATE_KEY dummy value to vitest.config.ts to prevent DI module errors
- Fix import paths in credit-service.test.ts (StripeBillingService, TeamBillingService)
- Create compatibility barrel at packages/features/ee/billing/teams/index.ts for test mocking

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: update unit tests to mock DI container properly

- Update teamService.test.ts to mock getTeamBillingServiceFactory() instead of TeamBilling.findAndInit
- Update teamService.alternative.test.ts to mock DI container
- Update credit-service.test.ts to mock getBillingProviderService() and use SubscriptionStatus enum values
- Update OrganizationPaymentService.test.ts to mock DI container instead of direct StripeBillingService import
- Remove all 'as any' type casting to comply with Cal.com coding standards
- Fix unused variable warnings by prefixing with underscore

All 53 tests now passing (16 + 1 + 30 + 6)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: update remaining unit tests to use DI pattern

- Fix StripeBillingService.test.ts to inject mock Stripe client directly
- Fix teamBillingFactory.test.ts to mock getTeamBillingServiceFactory() from DI container
- Fix skipTeamTrials.test.ts to mock DI container and use SubscriptionStatus enum

All 11 previously failing tests now pass (5 + 5 + 1)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Undo changes made to Prisma module

* fix: address test-related PR comments

- Fix OrganizationPaymentService.test.ts mock path from @calcom/ee to @calcom/features/ee
- Refactor teamBillingFactory.test.ts to test real factory logic instead of mocking container
- Remove duplicate teamBillingService.test..ts file with incorrect double-dot filename

All three test files now pass successfully with proper DI patterns.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Address feedback

* fix: update teamService integration test to mock new DI factory pattern

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: remove duplicate imports in credit-service.test.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Remove unused index file

* `getBySubscriptionId` to return team or null

* Address feedback

* Merge fix

* Refactor file names

* fix: correct mockStripe variable name to stripeMock in StripeBillingService.test.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: update internal-team-billing.test.ts to use new DI structure with TeamBillingService

- Replace InternalTeamBilling with TeamBillingService
- Use constructor injection with mock dependencies instead of factory pattern
- Remove BillingRepositoryFactory mock and import
- Update all test cases to use mockBillingProviderService, mockTeamBillingDataRepository, and mockBillingRepository
- Simplify saveTeamBilling tests to focus on repository.create calls
- All 11 tests now pass with the new DI structure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: update createWithPaymentIntent.handler.test.ts to mock DI container's getBillingProviderService

- OrganizationPaymentService now uses getBillingProviderService() from DI container
- Test was mocking @calcom/features/ee/payments/server/stripe directly, which no longer works
- Added mock for @calcom/features/ee/billing/di/containers/Billing module
- Mock returns fake billing provider that delegates to mockSharedStripe
- Preserves all existing test assertions and helpers
- Fixed lint error by prefixing unused lastCreatedSessionId with underscore
- All 11 tests now pass (1 skipped as expected)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-11-19 10:21:22 -05:00

402 lines
12 KiB
TypeScript

import { keyBy } from "lodash";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { getTranslation } from "@calcom/lib/server/i18n";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { getDefaultScheduleId } from "../availability/util";
import { updateUserMetadataAllowedKeys, type TUpdateProfileInputSchema } from "./updateProfile.schema";
const log = logger.getSubLogger({ prefix: ["updateProfile"] });
type UpdateProfileOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
res?: NextApiResponse | GetServerSidePropsContext["res"];
};
input: TUpdateProfileInputSchema;
};
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const billingService = getBillingProviderService();
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const featuresRepository = new FeaturesRepository(prisma);
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
const { travelSchedules, ...rest } = input;
const secondaryEmails = input?.secondaryEmails || [];
delete input.secondaryEmails;
const data: Prisma.UserUpdateInput = {
...rest,
metadata: userMetadata,
secondaryEmails: undefined,
};
let isPremiumUsername = false;
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t(layoutError) });
}
if (input.username && !user.organizationId) {
const username = slugify(input.username);
// Only validate if we're changing usernames
if (username !== user.username) {
data.username = username;
const response = await checkUsername(username);
isPremiumUsername = response.premium;
if (!response.available) {
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t("username_already_taken") });
}
}
} else if (input.username && user.organizationId && user.movedToProfileId) {
// don't change user.username if we have profile.username
delete data.username;
}
if (isPremiumUsername) {
const stripeCustomerId = userMetadata?.stripeCustomerId;
const isPremium = userMetadata?.isPremium;
if (!isPremium || !stripeCustomerId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" });
}
const stripeSubscriptions = await billingService.getSubscriptions(stripeCustomerId);
if (!stripeSubscriptions || !stripeSubscriptions.length) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "No stripeSubscription found",
});
}
// Iterate over subscriptions and look for premium product id and status active
// @TODO: iterate if stripeSubscriptions.hasMore is true
const isPremiumUsernameSubscriptionActive = stripeSubscriptions.some(
(subscription) =>
subscription.items.data[0].price.id === getPremiumMonthlyPlanPriceId() &&
subscription.status === "active"
);
if (!isPremiumUsernameSubscriptionActive) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You need to pay for premium username",
});
}
}
const hasEmailBeenChanged = data.email && user.email !== data.email;
let secondaryEmail:
| {
id: number;
emailVerified: Date | null;
}
| null
| undefined;
const primaryEmailVerified = user.emailVerified;
if (hasEmailBeenChanged) {
secondaryEmail = await prisma.secondaryEmail.findUnique({
where: {
email: input.email,
userId: user.id,
},
select: {
id: true,
emailVerified: true,
},
});
if (emailVerification) {
if (secondaryEmail?.emailVerified) {
data.emailVerified = secondaryEmail.emailVerified;
} else {
// Set metadata of the user so we can set it to this updated email once it is confirmed
data.metadata = {
...userMetadata,
emailChangeWaitingForVerification: input.email?.toLocaleLowerCase(),
};
// Check to ensure this email isn't in use
// Don't include email in the data payload if we need to verify
delete data.email;
}
} else {
log.warn("Profile Update - Email verification is disabled - Skipping");
data.emailVerified = null;
}
}
// if defined AND a base 64 string, upload and update the avatar URL
if (
input.avatarUrl &&
(input.avatarUrl.startsWith("data:image/png;base64,") ||
input.avatarUrl.startsWith("data:image/jpeg;base64,") ||
input.avatarUrl.startsWith("data:image/jpg;base64,"))
) {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
});
}
if (input.completedOnboarding) {
const userTeams = await prisma.user.findUnique({
where: {
id: user.id,
},
select: {
teams: {
select: {
id: true,
},
},
},
});
if (userTeams && userTeams.teams.length > 0) {
await Promise.all(
userTeams.teams.map(async (team) => {
await updateNewTeamMemberEventTypes(user.id, team.id);
})
);
}
}
if (travelSchedules) {
const existingSchedules = await prisma.travelSchedule.findMany({
where: {
userId: user.id,
},
});
const schedulesToDelete = existingSchedules.filter(
(schedule) =>
!travelSchedules || !travelSchedules.find((scheduleInput) => scheduleInput.id === schedule.id)
);
await prisma.travelSchedule.deleteMany({
where: {
userId: user.id,
id: {
in: schedulesToDelete.map((schedule) => schedule.id) as number[],
},
},
});
await prisma.travelSchedule.createMany({
data: travelSchedules
.filter((schedule) => !schedule.id)
.map((schedule) => {
return {
userId: user.id,
startDate: schedule.startDate,
endDate: schedule.endDate,
timeZone: schedule.timeZone,
};
}),
});
}
const updatedUserSelect = {
select: {
id: true,
username: true,
email: true,
identityProvider: true,
identityProviderId: true,
metadata: true,
name: true,
createdDate: true,
avatarUrl: true,
locale: true,
schedules: {
select: {
id: true,
},
},
},
} satisfies Prisma.UserDefaultArgs;
let updatedUser: Prisma.UserGetPayload<typeof updatedUserSelect>;
try {
updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data,
...updatedUserSelect,
});
} catch (e) {
// Catch unique constraint failure on email field.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const meta = e.meta as { target: string[] };
if (meta.target.indexOf("email") !== -1) {
throw new HttpError({ statusCode: 409, message: "email_already_used" });
}
}
throw e; // make sure other errors are rethrown
}
if (user.timeZone !== data.timeZone && updatedUser.schedules.length > 0) {
// on timezone change update timezone of default schedule
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (!user.defaultScheduleId) {
// set default schedule if not already set
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
}
await prisma.schedule.updateMany({
where: {
id: defaultScheduleId,
},
data: {
timeZone: data.timeZone,
},
});
}
// Notify stripe about the change
if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) {
const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`;
await billingService.updateCustomer({
customerId: stripeCustomerId,
email: updatedUser.email,
userId: updatedUser.id,
});
}
if (updatedUser && hasEmailBeenChanged) {
// Skip sending verification email when user tries to change his primary email to a verified secondary email
if (secondaryEmail?.emailVerified) {
secondaryEmails.push({
id: secondaryEmail.id,
email: user.email,
isDeleted: false,
});
} else {
await sendChangeOfEmailVerification({
user: {
username: updatedUser.username ?? "Nameless User",
emailFrom: user.email,
// We know email has been changed here so we can use input
emailTo: input.email!,
},
});
}
}
if (secondaryEmails.length) {
const recordsToDelete = secondaryEmails
.filter((secondaryEmail) => secondaryEmail.isDeleted)
.map((secondaryEmail) => secondaryEmail.id);
if (recordsToDelete.length) {
await prisma.secondaryEmail.deleteMany({
where: {
id: {
in: recordsToDelete,
},
userId: updatedUser.id,
},
});
}
const modifiedRecords = secondaryEmails.filter((secondaryEmail) => !secondaryEmail.isDeleted);
if (modifiedRecords.length) {
const secondaryEmailsFromDB = await prisma.secondaryEmail.findMany({
where: {
id: {
in: secondaryEmails.map((secondaryEmail) => secondaryEmail.id),
},
userId: updatedUser.id,
},
});
const keyedSecondaryEmailsFromDB = keyBy(secondaryEmailsFromDB, "id");
const recordsToModifyQueue = modifiedRecords.map((updated) => {
let emailVerified = keyedSecondaryEmailsFromDB[updated.id].emailVerified;
if (secondaryEmail?.id === updated.id) {
emailVerified = primaryEmailVerified;
} else if (updated.email !== keyedSecondaryEmailsFromDB[updated.id].email) {
emailVerified = null;
}
return prisma.secondaryEmail.update({
where: {
id: updated.id,
userId: updatedUser.id,
},
data: {
email: updated.email,
emailVerified,
},
});
});
await prisma.$transaction(recordsToModifyQueue);
}
}
return {
...input,
email: emailVerification && !secondaryEmail?.emailVerified ? user.email : input.email,
avatarUrl: updatedUser.avatarUrl,
hasEmailBeenChanged,
sendEmailVerification: emailVerification && !secondaryEmail?.emailVerified,
};
};
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
if (!metadata) {
return {};
}
const cleanedMetadata = updateUserMetadataAllowedKeys.safeParse(metadata);
if (!cleanedMetadata.success) {
logger.error("Error cleaning metadata", cleanedMetadata.error);
return {};
}
return cleanedMetadata.data;
};
const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const cleanMetadata = cleanMetadataAllowedUpdateKeys(input.metadata);
const userMetadata = userMetadataSchema.parse(user.metadata);
// Required so we don't override and delete saved values
return { ...userMetadata, ...cleanMetadata };
};