Files
calendar/apps/web/lib/pages/auth/verify-email.ts
T
Joe Au-YeungGitHubjoe@cal.com <j.auyeung419@gmail.com>cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Syed Ali Shahbaz
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

190 lines
5.5 KiB
TypeScript

import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { CreationSource } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
const verifySchema = z.object({
token: z.string(),
});
const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
// TODO: To be unit tested
export async function moveUserToMatchingOrg({ email }: { email: string }) {
const organizationRepository = getOrganizationRepository();
const org = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
if (!org) {
return;
}
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: org.id,
language: "en",
creationSource: CreationSource.WEBAPP,
invitations: [
{
usernameOrEmail: email,
role: MembershipRole.MEMBER,
},
],
orgSlug: org.slug || org.requestedSlug,
});
}
export async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = verifySchema.parse(req.query);
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
});
if (!foundToken) {
return res.status(401).json({ message: "No token found" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
// The user is verifying the secondary email
if (foundToken?.secondaryEmailId) {
await prisma.secondaryEmail.update({
where: {
id: foundToken.secondaryEmailId,
email: foundToken?.identifier,
},
data: {
emailVerified: new Date(),
},
});
await cleanUpVerificationTokens(foundToken.id);
return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`);
}
const user = await prisma.user.findFirst({
where: {
email: foundToken?.identifier,
},
});
if (!user) {
return res.status(401).json({ message: "Cannot find a user attached to this token" });
}
const userMetadataParsed = userMetadata.parse(user.metadata);
// Attach the new email and verify
if (userMetadataParsed?.emailChangeWaitingForVerification) {
// Ensure this email isn't in use
const existingUser = await prisma.user.findUnique({
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
select: {
id: true,
},
});
if (existingUser) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
// Ensure this email isn't being added by another user as secondary email
const existingSecondaryUser = await prisma.secondaryEmail.findUnique({
where: {
email: userMetadataParsed?.emailChangeWaitingForVerification,
},
select: {
id: true,
userId: true,
},
});
if (existingSecondaryUser && existingSecondaryUser.userId !== user.id) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
const oldEmail = user.email;
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
delete userMetadataParsed.emailChangeWaitingForVerification;
// Update and re-verify
await prisma.user.update({
where: {
id: user.id,
},
data: {
email: updatedEmail,
metadata: userMetadataParsed,
},
});
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
const billingService = getBillingProviderService();
await billingService.updateCustomer({
customerId: userMetadataParsed.stripeCustomerId,
email: updatedEmail,
});
}
// The user is trying to update the email to an already existing unverified secondary email of his
// so we swap the emails and its verified status
if (existingSecondaryUser?.userId === user.id) {
await prisma.secondaryEmail.update({
where: {
id: existingSecondaryUser.id,
userId: user.id,
},
data: {
email: oldEmail,
emailVerified: user.emailVerified,
},
});
}
await cleanUpVerificationTokens(foundToken.id);
return res.status(200).json({
updatedEmail,
});
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
},
});
const hasCompletedOnboarding = user.completedOnboarding;
await moveUserToMatchingOrg({ email: user.email });
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
return res.redirect(`${WEBAPP_URL}${hasCompletedOnboarding ? "/event-types" : gettingStartedPath}`);
}
export async function cleanUpVerificationTokens(id: number) {
// Delete token from DB after it has been used
await prisma.verificationToken.delete({
where: {
id,
},
});
}