- {hasPaymentFailed ?
: sessionId ?
:
}
+ {hasPaymentFailed ? (
+
+ ) : isPremiumUsername ? (
+
+ ) : (
+
+ )}
{hasPaymentFailed
? t("your_payment_failed")
- : sessionId
+ : isPremiumUsername
? t("payment_successful")
: t("check_your_inbox")}
{hasPaymentFailed &&
{t("account_created_premium_not_reserved")}
}
- {t("email_sent_with_activation_link", { email: customer?.email })}{" "}
+ {t("email_sent_with_activation_link", { email })}{" "}
{hasPaymentFailed && t("activate_account_to_purchase_username")}
@@ -212,7 +207,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
)}
disabled={secondsLeft > 0}
onClick={async (e) => {
- if (!customer) {
+ if (!email || !username) {
return;
}
e.preventDefault();
@@ -221,7 +216,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
const _searchParams = new URLSearchParams(searchParams?.toString());
_searchParams.set("t", `${Date.now()}`);
router.replace(`${pathname}?${_searchParams.toString()}`);
- return await sendVerificationLogin(customer.email, customer.username, t);
+ return await sendVerificationLogin(email, username, t);
}}>
{secondsLeft > 0 ? t("resend_in_seconds", { seconds: secondsLeft }) : t("resend")}
diff --git a/packages/app-store/stripepayment/api/__tests__/paymentCallback.test.ts b/packages/app-store/stripepayment/api/__tests__/paymentCallback.test.ts
new file mode 100644
index 0000000000..b88e2053db
--- /dev/null
+++ b/packages/app-store/stripepayment/api/__tests__/paymentCallback.test.ts
@@ -0,0 +1,266 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import sendVerificationRequest from "@calcom/features/auth/lib/sendVerificationRequest";
+import { HttpError } from "@calcom/lib/http-error";
+import { VerificationTokenService } from "@calcom/lib/server/service/VerificationTokenService";
+import { prisma } from "@calcom/prisma";
+
+import { getCustomerAndCheckoutSession } from "../../lib/getCustomerAndCheckoutSession";
+
+// Mock dependencies
+vi.mock("@calcom/prisma", () => ({
+ prisma: {
+ user: {
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("../../lib/getCustomerAndCheckoutSession");
+vi.mock("@calcom/features/auth/lib/sendVerificationRequest");
+vi.mock("@calcom/lib/server/service/VerificationTokenService");
+
+const mockGetCustomerAndCheckoutSession = vi.mocked(getCustomerAndCheckoutSession);
+const mockSendVerificationRequest = vi.mocked(sendVerificationRequest);
+const mockVerificationTokenService = vi.mocked(VerificationTokenService);
+
+// Type the mocked prisma properly
+const mockPrisma = prisma as unknown as {
+ user: {
+ findFirst: ReturnType;
+ update: ReturnType;
+ };
+};
+
+describe("paymentCallback", () => {
+ let mockReq: Partial;
+ let mockRes: Partial;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockReq = {
+ query: {
+ callbackUrl: "/premium-username-checkout",
+ checkoutSessionId: "cs_test_123",
+ },
+ url: "/api/payment/callback",
+ method: "GET",
+ };
+
+ mockRes = {
+ redirect: vi.fn().mockReturnThis(),
+ end: vi.fn().mockReturnThis(),
+ setHeader: vi.fn().mockReturnThis(),
+ status: vi.fn().mockReturnThis(),
+ json: vi.fn().mockReturnThis(),
+ };
+
+ // Default mock implementations
+ mockGetCustomerAndCheckoutSession.mockResolvedValue({
+ stripeCustomer: {
+ id: "cus_123",
+ email: "test@example.com",
+ metadata: {
+ username: "premium-user",
+ },
+ },
+ checkoutSession: {
+ payment_status: "paid",
+ },
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ mockPrisma.user.findFirst.mockResolvedValue({
+ id: 1,
+ email: "test@example.com",
+ locale: "en",
+ metadata: {},
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ mockPrisma.user.update.mockResolvedValue({
+ id: 1,
+ username: "premium-user",
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ mockVerificationTokenService.create.mockResolvedValue("test-token-123");
+ mockSendVerificationRequest.mockResolvedValue(undefined);
+ });
+
+ describe("VerificationTokenService integration", () => {
+ it("should call VerificationTokenService.create with correct parameters", async () => {
+ const { default: handler } = await import("../paymentCallback");
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
+ identifier: "test@example.com",
+ expires: expect.any(Date),
+ });
+
+ const callArgs = mockVerificationTokenService.create.mock.calls[0][0];
+ const expiresDate = callArgs.expires;
+ const now = Date.now();
+ const oneDayInMs = 86400 * 1000;
+
+ // Verify expires is approximately 1 day from now (within 1 second tolerance)
+ expect(expiresDate.getTime()).toBeGreaterThan(now);
+ expect(expiresDate.getTime()).toBeLessThanOrEqual(now + oneDayInMs + 1000);
+ });
+
+ it("should send verification email with token from service", async () => {
+ const { default: handler } = await import("../paymentCallback");
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ expect(mockSendVerificationRequest).toHaveBeenCalledWith({
+ identifier: "test@example.com",
+ url: expect.stringContaining("token=test-token-123"),
+ });
+ });
+
+ it("should create verification token before sending email", async () => {
+ const { default: handler } = await import("../paymentCallback");
+ const callOrder: string[] = [];
+
+ mockVerificationTokenService.create.mockImplementation(async () => {
+ callOrder.push("create-token");
+ return "test-token";
+ });
+
+ mockSendVerificationRequest.mockImplementation(async () => {
+ callOrder.push("send-email");
+ });
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ expect(callOrder).toEqual(["create-token", "send-email"]);
+ });
+
+ it("should create verification token only after payment is confirmed", async () => {
+ mockGetCustomerAndCheckoutSession.mockResolvedValue({
+ stripeCustomer: {
+ id: "cus_123",
+ email: "test@example.com",
+ metadata: { username: "premium-user" },
+ },
+ checkoutSession: {
+ payment_status: "unpaid",
+ },
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ const { default: handler } = await import("../paymentCallback");
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
+ expect(mockSendVerificationRequest).not.toHaveBeenCalled();
+ });
+
+ it("should handle user found by stripeCustomerId", async () => {
+ mockPrisma.user.findFirst
+ .mockResolvedValueOnce(null) // First call by email returns null
+ .mockResolvedValueOnce({
+ // Second call by stripeCustomerId succeeds
+ id: 2,
+ email: "different@example.com",
+ locale: "en",
+ metadata: { stripeCustomerId: "cus_123" },
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ const { default: handler } = await import("../paymentCallback");
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
+ identifier: "different@example.com", // Should use user.email from found user
+ expires: expect.any(Date),
+ });
+ });
+
+ it("should update user with premium username before creating token", async () => {
+ const { default: handler } = await import("../paymentCallback");
+ const callOrder: string[] = [];
+
+ mockPrisma.user.update.mockImplementation(async () => {
+ callOrder.push("update-user");
+ return {} as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ });
+
+ mockVerificationTokenService.create.mockImplementation(async () => {
+ callOrder.push("create-token");
+ return "token";
+ });
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ expect(callOrder).toEqual(["update-user", "create-token"]);
+ });
+
+ it("should redirect with correct parameters after successful payment", async () => {
+ const { default: handler } = await import("../paymentCallback");
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const redirectUrl = (mockRes.redirect as any).mock.calls[0][0] as string;
+ expect(redirectUrl).toContain("email=test%40example.com");
+ expect(redirectUrl).toContain("username=premium-user");
+ expect(redirectUrl).toContain("paymentStatus=paid");
+ });
+
+ it("should not create verification token when stripe customer is not found", async () => {
+ mockGetCustomerAndCheckoutSession.mockResolvedValue({
+ stripeCustomer: null,
+ checkoutSession: { payment_status: "paid" },
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ const { default: handler } = await import("../paymentCallback");
+
+ try {
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+ } catch (error) {
+ expect(error).toBeInstanceOf(HttpError);
+ expect((error as HttpError).statusCode).toBe(404);
+ }
+
+ expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
+ });
+
+ it("should not create verification token when user is not found", async () => {
+ mockPrisma.user.findFirst.mockResolvedValue(null);
+
+ const { default: handler } = await import("../paymentCallback");
+
+ try {
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+ } catch (error) {
+ expect(error).toBeInstanceOf(HttpError);
+ expect((error as HttpError).statusCode).toBe(404);
+ }
+
+ expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
+ });
+
+ it("should use user email if stripe customer email is missing", async () => {
+ mockGetCustomerAndCheckoutSession.mockResolvedValue({
+ stripeCustomer: {
+ id: "cus_123",
+ email: null,
+ metadata: { username: "premium-user" },
+ },
+ checkoutSession: { payment_status: "paid" },
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ const { default: handler } = await import("../paymentCallback");
+
+ await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
+
+ expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
+ identifier: "test@example.com", // Should use user.email
+ expires: expect.any(Date),
+ });
+ });
+ });
+});
diff --git a/packages/app-store/stripepayment/api/paymentCallback.ts b/packages/app-store/stripepayment/api/paymentCallback.ts
index a6fc7819d2..4c646f2a9f 100644
--- a/packages/app-store/stripepayment/api/paymentCallback.ts
+++ b/packages/app-store/stripepayment/api/paymentCallback.ts
@@ -2,10 +2,13 @@ import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
+import sendVerificationRequest from "@calcom/features/auth/lib/sendVerificationRequest";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
+import logger from "@calcom/lib/logger";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
+import { VerificationTokenService } from "@calcom/lib/server/service/VerificationTokenService";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -22,43 +25,76 @@ const querySchema = z.object({
// It handles premium user payment success/failure
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { callbackUrl, checkoutSessionId } = querySchema.parse(req.query);
+ const log = logger.getSubLogger({ prefix: [`[paymentCallback] checkoutSessionId: ${checkoutSessionId}`] });
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
- if (!stripeCustomer)
+ if (!stripeCustomer) {
+ log.error("Could not find stripeCustomer");
throw new HttpError({
statusCode: 404,
- message: "Stripe customer not found or deleted",
+ message:
+ "Stripe customer not found or deleted. Please contact support@cal.com and mention your premium username",
url: req.url,
method: req.method,
});
+ }
- // first let's try to find user by metadata stripeCustomerId
- let user = await prisma.user.findFirst({
- where: {
- metadata: {
- path: ["stripeCustomerId"],
- equals: stripeCustomer.id,
- },
- },
- });
+ let user;
- if (!user && stripeCustomer.email) {
- // if user not found, let's try to find user by email
+ if (stripeCustomer?.email) {
user = await prisma.user.findFirst({
where: {
email: stripeCustomer.email,
},
+ select: {
+ id: true,
+ email: true,
+ locale: true,
+ metadata: true,
+ },
});
}
- if (!user)
- throw new HttpError({ statusCode: 404, message: "User not found", url: req.url, method: req.method });
+ // If we cannot find the user via email, query via stripeCustomerId
+ if (!user) {
+ user = await prisma.user.findFirst({
+ where: {
+ metadata: {
+ path: ["stripeCustomerId"],
+ equals: stripeCustomer.id,
+ },
+ },
+ select: {
+ id: true,
+ email: true,
+ locale: true,
+ metadata: true,
+ },
+ });
+ }
- if (checkoutSession.payment_status === "paid" && stripeCustomer.metadata.username) {
+ if (!user) {
+ log.error("Could not find user");
+ throw new HttpError({ statusCode: 404, message: "User not found", url: req.url, method: req.method });
+ }
+
+ const username = stripeCustomer.metadata.username;
+ const email = user.email ?? stripeCustomer.email;
+
+ // If payment hasn't been completed, redirect to verify page with failure status
+ if (checkoutSession.payment_status !== "paid") {
+ log.warn("Payment not completed", { paymentStatus: checkoutSession.payment_status });
+ callbackUrl.searchParams.set("email", email || "");
+ callbackUrl.searchParams.set("username", username || "");
+ callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status);
+ return res.redirect(callbackUrl.toString()).end();
+ }
+
+ if (stripeCustomer.metadata.username) {
try {
await prisma.user.update({
data: {
- username: stripeCustomer.metadata.username,
+ username,
metadata: {
...(user.metadata as Prisma.JsonObject),
isPremium: true,
@@ -69,7 +105,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
},
});
} catch (error) {
- console.error(error);
+ log.error(error);
throw new HttpError({
statusCode: 400,
url: req.url,
@@ -79,7 +115,29 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
});
}
}
+
+ const expires = new Date(Date.now() + 86400 * 1000); // 1 day
+
+ const token = await VerificationTokenService.create({ identifier: email, expires });
+
+ // Generate the callback URL with token
+ const params = new URLSearchParams({
+ callbackUrl: WEBAPP_URL || "https://app.cal.com",
+ token,
+ email,
+ });
+ const url = `${WEBAPP_URL}/api/auth/callback/email?${params.toString()}`;
+
+ await sendVerificationRequest({
+ identifier: email,
+ url,
+ });
+
+ // Pass email, username, and payment status in the redirect URL
+ callbackUrl.searchParams.set("email", email || "");
+ callbackUrl.searchParams.set("username", username);
callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status);
+
return res.redirect(callbackUrl.toString()).end();
}
diff --git a/packages/features/auth/lib/sendVerificationRequest.ts b/packages/features/auth/lib/sendVerificationRequest.ts
index 205da0cadf..ffa07a98c3 100644
--- a/packages/features/auth/lib/sendVerificationRequest.ts
+++ b/packages/features/auth/lib/sendVerificationRequest.ts
@@ -12,7 +12,10 @@ const transporter = nodemailer.createTransport({
...(serverConfig.transport as TransportOptions),
} as TransportOptions);
-const sendVerificationRequest = async ({ identifier, url }: SendVerificationRequestParams) => {
+const sendVerificationRequest = async ({
+ identifier,
+ url,
+}: Pick) => {
const emailsDir = path.resolve(process.cwd(), "..", "..", "packages/emails", "templates");
const originalUrl = new URL(url);
const webappUrl = new URL(process.env.NEXTAUTH_URL || WEBAPP_URL);
diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts
index dc204f4a93..dae38c2f95 100644
--- a/packages/features/auth/signup/handlers/calcomHandler.ts
+++ b/packages/features/auth/signup/handlers/calcomHandler.ts
@@ -213,11 +213,15 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => {
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
await prefillAvatar({ email });
}
- sendEmailVerification({
- email,
- language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
- username: username || "",
- });
+ // Only send verification email for non-premium usernames
+ // Premium usernames will get a magic link after payment in paymentCallback
+ if (!checkoutSessionId) {
+ sendEmailVerification({
+ email,
+ language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
+ username: username || "",
+ });
+ }
}
if (checkoutSessionId) {
diff --git a/packages/lib/server/repository/__tests__/verificationToken.test.ts b/packages/lib/server/repository/__tests__/verificationToken.test.ts
new file mode 100644
index 0000000000..9d03f9db61
--- /dev/null
+++ b/packages/lib/server/repository/__tests__/verificationToken.test.ts
@@ -0,0 +1,164 @@
+import { describe, test, expect, vi, beforeEach } from "vitest";
+
+import type { VerificationToken } from "@calcom/prisma/client";
+
+import { VerificationTokenRepository } from "../verificationToken";
+
+vi.mock("@calcom/prisma", () => ({
+ default: {
+ verificationToken: {
+ create: vi.fn(),
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ },
+ },
+}));
+
+const prismaMock = vi.hoisted(() => ({
+ verificationToken: {
+ create: vi.fn(),
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ },
+}));
+
+vi.mock("@calcom/prisma", () => ({
+ default: prismaMock,
+}));
+
+describe("VerificationTokenRepository", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("create", () => {
+ test("should create a verification token", async () => {
+ const identifier = "test@example.com";
+ const token = "hashed-token-value";
+ const expires = new Date(Date.now() + 86400 * 1000);
+
+ const mockVerificationToken: VerificationToken = {
+ identifier,
+ token,
+ expires,
+ };
+
+ prismaMock.verificationToken.create.mockResolvedValue(mockVerificationToken);
+
+ const result = await VerificationTokenRepository.create({
+ identifier,
+ token,
+ expires,
+ });
+
+ expect(prismaMock.verificationToken.create).toHaveBeenCalledWith({
+ data: {
+ identifier,
+ token,
+ expires,
+ },
+ });
+ expect(result).toEqual(mockVerificationToken);
+ });
+
+ test("should create a verification token with correct data types", async () => {
+ const identifier = "user@example.com";
+ const token = "sha256-hashed-token";
+ const expires = new Date("2025-12-31T23:59:59.000Z");
+
+ const mockVerificationToken: VerificationToken = {
+ identifier,
+ token,
+ expires,
+ };
+
+ prismaMock.verificationToken.create.mockResolvedValue(mockVerificationToken);
+
+ await VerificationTokenRepository.create({
+ identifier,
+ token,
+ expires,
+ });
+
+ expect(prismaMock.verificationToken.create).toHaveBeenCalledWith({
+ data: {
+ identifier,
+ token,
+ expires,
+ },
+ });
+ });
+ });
+
+ describe("updateTeamInviteTokenExpirationDate", () => {
+ test("should update team invite token expiration date", async () => {
+ const email = "team@example.com";
+ const teamId = 123;
+ const expiresInDays = 7;
+
+ const existingToken: VerificationToken = {
+ identifier: email,
+ token: "existing-token",
+ expires: new Date(),
+ };
+
+ prismaMock.verificationToken.findFirst.mockResolvedValue(existingToken);
+ prismaMock.verificationToken.update.mockResolvedValue({
+ ...existingToken,
+ expires: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000),
+ });
+
+ const result = await VerificationTokenRepository.updateTeamInviteTokenExpirationDate({
+ email,
+ teamId,
+ expiresInDays,
+ });
+
+ expect(prismaMock.verificationToken.findFirst).toHaveBeenCalledWith({
+ where: {
+ identifier: email,
+ teamId,
+ },
+ });
+
+ expect(prismaMock.verificationToken.update).toHaveBeenCalledWith({
+ where: {
+ identifier: email,
+ teamId,
+ token: existingToken.token,
+ },
+ data: { expires: expect.any(Date) },
+ });
+
+ expect(result.token).toBe(existingToken.token);
+ expect(result.expires).toBeInstanceOf(Date);
+ });
+
+ test("should calculate correct expiration date", async () => {
+ const email = "team@example.com";
+ const teamId = 456;
+ const expiresInDays = 3;
+
+ const existingToken: VerificationToken = {
+ identifier: email,
+ token: "token-123",
+ expires: new Date(),
+ };
+
+ prismaMock.verificationToken.findFirst.mockResolvedValue(existingToken);
+
+ const expectedExpires = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
+
+ prismaMock.verificationToken.update.mockImplementation(({ data }) => {
+ expect(data.expires.getTime()).toBeCloseTo(expectedExpires.getTime(), -2);
+ return Promise.resolve({ ...existingToken, expires: data.expires });
+ });
+
+ await VerificationTokenRepository.updateTeamInviteTokenExpirationDate({
+ email,
+ teamId,
+ expiresInDays,
+ });
+ });
+ });
+});
diff --git a/packages/lib/server/repository/verificationToken.ts b/packages/lib/server/repository/verificationToken.ts
index 0875ad1b8f..2ee2cd192b 100644
--- a/packages/lib/server/repository/verificationToken.ts
+++ b/packages/lib/server/repository/verificationToken.ts
@@ -30,4 +30,22 @@ export class VerificationTokenRepository {
return { ...token, expires };
}
+
+ static async create({
+ identifier,
+ token,
+ expires,
+ }: {
+ identifier: string;
+ token: string;
+ expires: Date;
+ }) {
+ return prisma.verificationToken.create({
+ data: {
+ identifier,
+ token,
+ expires,
+ },
+ });
+ }
}
diff --git a/packages/lib/server/service/VerificationTokenService.ts b/packages/lib/server/service/VerificationTokenService.ts
new file mode 100644
index 0000000000..eb9753589a
--- /dev/null
+++ b/packages/lib/server/service/VerificationTokenService.ts
@@ -0,0 +1,16 @@
+import { randomBytes, createHash } from "crypto";
+
+import { VerificationTokenRepository } from "../repository/verificationToken";
+
+export class VerificationTokenService {
+ static async create({ identifier, expires }: { identifier: string; expires: Date }) {
+ const token = randomBytes(32).toString("hex");
+ const hashedToken = createHash("sha256")
+ .update(`${token}${process.env.NEXTAUTH_SECRET}`)
+ .digest("hex");
+
+ await VerificationTokenRepository.create({ identifier, token: hashedToken, expires });
+
+ return token;
+ }
+}
diff --git a/packages/lib/server/service/__tests__/VerificationTokenService.test.ts b/packages/lib/server/service/__tests__/VerificationTokenService.test.ts
new file mode 100644
index 0000000000..490bb2bbb2
--- /dev/null
+++ b/packages/lib/server/service/__tests__/VerificationTokenService.test.ts
@@ -0,0 +1,134 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { VerificationTokenRepository } from "@calcom/lib/server/repository/verificationToken";
+
+import { VerificationTokenService } from "../VerificationTokenService";
+
+vi.mock("@calcom/lib/server/repository/verificationToken", () => ({
+ VerificationTokenRepository: {
+ create: vi.fn(),
+ },
+}));
+
+describe("VerificationTokenService", () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env = { ...originalEnv };
+ process.env.NEXTAUTH_SECRET = "test-secret";
+
+ vi.mocked(VerificationTokenRepository.create).mockResolvedValue({
+ identifier: "test@example.com",
+ token: "hashed-token",
+ expires: new Date(),
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ describe("create", () => {
+ test("should create a verification token and return unhashed token", async () => {
+ const identifier = "test@example.com";
+ const expires = new Date(Date.now() + 86400 * 1000);
+
+ const result = await VerificationTokenService.create({
+ identifier,
+ expires,
+ });
+
+ expect(result).toBeTruthy();
+ expect(typeof result).toBe("string");
+ expect(result.length).toBeGreaterThan(0);
+ expect(VerificationTokenRepository.create).toHaveBeenCalled();
+ });
+
+ test("should call repository with correct identifier and expires", async () => {
+ const identifier = "user@example.com";
+ const expires = new Date("2025-12-31T23:59:59.000Z");
+
+ await VerificationTokenService.create({
+ identifier,
+ expires,
+ });
+
+ expect(VerificationTokenRepository.create).toHaveBeenCalledWith({
+ identifier,
+ token: expect.any(String),
+ expires,
+ });
+
+ const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0];
+ expect(callArgs.identifier).toBe(identifier);
+ expect(callArgs.expires).toBe(expires);
+ expect(callArgs.token).toBeTruthy();
+ });
+
+ test("should hash token with NEXTAUTH_SECRET", async () => {
+ const identifier = "test@example.com";
+ const expires = new Date(Date.now() + 86400 * 1000);
+
+ await VerificationTokenService.create({
+ identifier,
+ expires,
+ });
+
+ const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0];
+ // The hashed token should be a 64-character hex string (SHA-256)
+ expect(callArgs.token).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+ test("should generate unique tokens on multiple calls", async () => {
+ const identifier = "test@example.com";
+ const expires = new Date(Date.now() + 86400 * 1000);
+
+ const result1 = await VerificationTokenService.create({
+ identifier,
+ expires,
+ });
+
+ const result2 = await VerificationTokenService.create({
+ identifier,
+ expires,
+ });
+
+ expect(result1).not.toBe(result2);
+ expect(VerificationTokenRepository.create).toHaveBeenCalledTimes(2);
+ });
+
+ test("should handle Date objects for expires", async () => {
+ const identifier = "test@example.com";
+ const expires = new Date(2025, 11, 31, 23, 59, 59);
+
+ await VerificationTokenService.create({
+ identifier,
+ expires,
+ });
+
+ expect(VerificationTokenRepository.create).toHaveBeenCalledWith({
+ identifier,
+ token: expect.any(String),
+ expires: expect.any(Date),
+ });
+
+ const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0];
+ expect(callArgs.expires).toBeInstanceOf(Date);
+ expect(callArgs.expires.getTime()).toBe(expires.getTime());
+ });
+
+ test("should generate 32-byte random token in hex format", async () => {
+ const identifier = "test@example.com";
+ const expires = new Date(Date.now() + 86400 * 1000);
+
+ const token = await VerificationTokenService.create({
+ identifier,
+ expires,
+ });
+
+ // 32 bytes = 64 hex characters
+ expect(token).toMatch(/^[a-f0-9]{64}$/);
+ });
+ });
+});
diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx
index 1113149b7b..598ee8fa43 100644
--- a/packages/trpc/server/routers/publicViewer/_router.tsx
+++ b/packages/trpc/server/routers/publicViewer/_router.tsx
@@ -4,13 +4,8 @@ import { ZUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerifica
import { ZMarkHostAsNoShowInputSchema } from "./markHostAsNoShow.schema";
import { event } from "./procedures/event";
import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema";
-import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
import { ZSubmitRatingInputSchema } from "./submitRating.schema";
-const NAMESPACE = "publicViewer";
-
-const namespaced = (s: string) => `${NAMESPACE}.${s}`;
-
// things that unauthenticated users can query about themselves
export const publicViewerRouter = router({
countryCode: publicProcedure.query(async (opts) => {
@@ -29,10 +24,6 @@ export const publicViewerRouter = router({
const { default: handler } = await import("./samlTenantProduct.handler");
return handler(opts);
}),
- stripeCheckoutSession: publicProcedure.input(ZStripeCheckoutSessionInputSchema).query(async (opts) => {
- const { default: handler } = await import("./stripeCheckoutSession.handler");
- return handler(opts);
- }),
event,
ssoConnections: publicProcedure.query(async () => {
const { default: handler } = await import("./ssoConnections.handler");
diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts
deleted file mode 100644
index 9f1a0375b2..0000000000
--- a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { StripeService } from "@calcom/features/ee/payments/server/stripe-service";
-
-import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
-import type { TStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
-
-type StripeCheckoutSessionOptions = {
- input: TStripeCheckoutSessionInputSchema;
-};
-
-export const stripeCheckoutSessionHandler = async ({ input }: StripeCheckoutSessionOptions) => {
- const parsedInput = ZStripeCheckoutSessionInputSchema.parse(input);
-
- return await StripeService.getCheckoutSession(parsedInput);
-};
-
-export default stripeCheckoutSessionHandler;
diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts
deleted file mode 100644
index db054fe2ab..0000000000
--- a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { z } from "zod";
-
-export const ZStripeCheckoutSessionInputSchema = z
- .object({
- stripeCustomerId: z.string().optional(),
- checkoutSessionId: z.string().optional(),
- })
- .superRefine((arg, ctx) => {
- if (!arg.checkoutSessionId && !arg.stripeCustomerId) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Missing checkoutSessionId or stripeCustomerId",
- });
- }
- if (arg.checkoutSessionId && arg.stripeCustomerId) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Both checkoutSessionId and stripeCustomerId provided",
- });
- }
- });
-
-export type TStripeCheckoutSessionInputSchema = z.infer;