Skip to content

Commit e5ebf7f

Browse files
authored
fix: signup (#25334)
* Handle Stripe logic in `paymentCallback` * Remove endpoint * Do not send email verification email if premium username * Remove logic from verify-view * Callback send verification email * Add `create` method to `VerificationToken` repository * Create `VerificationTokenService` * Early return if payment failed * Refactor token generation * Add tests * Type fixes * Type fixes
1 parent 0634898 commit e5ebf7f

File tree

12 files changed

+713
-103
lines changed

12 files changed

+713
-103
lines changed

apps/web/modules/auth/verify-view.tsx

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,11 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
1010
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
1111
import { useLocale } from "@calcom/lib/hooks/useLocale";
1212
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
13-
import { trpc } from "@calcom/trpc/react";
1413
import classNames from "@calcom/ui/classNames";
1514
import { Button } from "@calcom/ui/components/button";
1615
import { Icon } from "@calcom/ui/components/icon";
1716
import { showToast } from "@calcom/ui/components/toast";
1817

19-
import Loader from "@components/Loader";
20-
2118
async function sendVerificationLogin(email: string, username: string, t: (key: string) => string) {
2219
await signIn("email", {
2320
email: email.toLowerCase(),
@@ -54,8 +51,9 @@ function useSendFirstVerificationLogin({
5451
}
5552

5653
const querySchema = z.object({
57-
stripeCustomerId: z.string().optional(),
58-
sessionId: z.string().optional(),
54+
email: z.string().optional(),
55+
username: z.string().optional(),
56+
paymentStatus: z.string().optional(),
5957
t: z.string().optional(),
6058
});
6159

@@ -119,20 +117,20 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
119117
const pathname = usePathname();
120118
const router = useRouter();
121119
const routerQuery = useRouterQuery();
122-
const { t: tParam, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
120+
const { email, username, paymentStatus } = querySchema.parse(routerQuery);
123121
const { t } = useLocale();
124122
const [secondsLeft, setSecondsLeft] = useState(30);
125-
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
126-
{
127-
stripeCustomerId,
128-
checkoutSessionId: sessionId,
129-
},
130-
{
131-
enabled: !!stripeCustomerId || !!sessionId,
132-
staleTime: Infinity,
133-
}
134-
);
135-
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
123+
124+
// Derive payment failed status from payment status
125+
const hasPaymentFailed = paymentStatus !== undefined && paymentStatus !== "paid";
126+
const isPremiumUsername = !!paymentStatus; // If paymentStatus exists, it's from premium username flow
127+
128+
// Only send verification login if we DON'T have the email yet (for resend button)
129+
// The email is already sent server-side in paymentCallback
130+
useSendFirstVerificationLogin({
131+
email: !isPremiumUsername ? email : undefined,
132+
username: !isPremiumUsername ? username : undefined,
133+
});
136134
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
137135
// to avoid refresh to skip waiting 30 seconds to re-send email
138136
useEffect(() => {
@@ -159,34 +157,31 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
159157
}
160158
}, [secondsLeft]);
161159

162-
if (!data) {
163-
// Loading state
164-
return <Loader />;
165-
}
166-
const { valid, hasPaymentFailed, customer } = data;
167-
if (!valid) {
168-
throw new Error("Invalid session or customer id");
169-
}
170-
171-
if (!stripeCustomerId && !sessionId) {
160+
if (!email) {
172161
return <div>{t("invalid_link")}</div>;
173162
}
174163

175164
return (
176165
<div className="text-default bg-muted bg-opacity-90 backdrop-blur-md backdrop-grayscale backdrop-filter">
177166
<div className="flex min-h-screen flex-col items-center justify-center px-6">
178167
<div className="border-subtle bg-default m-10 flex max-w-2xl flex-col items-center rounded-xl border px-8 py-14 text-left">
179-
{hasPaymentFailed ? <PaymentFailedIcon /> : sessionId ? <PaymentSuccess /> : <MailOpenIcon />}
168+
{hasPaymentFailed ? (
169+
<PaymentFailedIcon />
170+
) : isPremiumUsername ? (
171+
<PaymentSuccess />
172+
) : (
173+
<MailOpenIcon />
174+
)}
180175
<h3 className="font-cal text-emphasis my-6 text-2xl font-normal leading-none">
181176
{hasPaymentFailed
182177
? t("your_payment_failed")
183-
: sessionId
178+
: isPremiumUsername
184179
? t("payment_successful")
185180
: t("check_your_inbox")}
186181
</h3>
187182
{hasPaymentFailed && <p className="my-6">{t("account_created_premium_not_reserved")}</p>}
188183
<p className="text-muted dark:text-subtle text-base font-normal">
189-
{t("email_sent_with_activation_link", { email: customer?.email })}{" "}
184+
{t("email_sent_with_activation_link", { email })}{" "}
190185
{hasPaymentFailed && t("activate_account_to_purchase_username")}
191186
</p>
192187
<div className="mt-7">
@@ -212,7 +207,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
212207
)}
213208
disabled={secondsLeft > 0}
214209
onClick={async (e) => {
215-
if (!customer) {
210+
if (!email || !username) {
216211
return;
217212
}
218213
e.preventDefault();
@@ -221,7 +216,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
221216
const _searchParams = new URLSearchParams(searchParams?.toString());
222217
_searchParams.set("t", `${Date.now()}`);
223218
router.replace(`${pathname}?${_searchParams.toString()}`);
224-
return await sendVerificationLogin(customer.email, customer.username, t);
219+
return await sendVerificationLogin(email, username, t);
225220
}}>
226221
{secondsLeft > 0 ? t("resend_in_seconds", { seconds: secondsLeft }) : t("resend")}
227222
</button>
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import type { NextApiRequest, NextApiResponse } from "next";
2+
import { describe, it, expect, vi, beforeEach } from "vitest";
3+
4+
import sendVerificationRequest from "@calcom/features/auth/lib/sendVerificationRequest";
5+
import { HttpError } from "@calcom/lib/http-error";
6+
import { VerificationTokenService } from "@calcom/lib/server/service/VerificationTokenService";
7+
import { prisma } from "@calcom/prisma";
8+
9+
import { getCustomerAndCheckoutSession } from "../../lib/getCustomerAndCheckoutSession";
10+
11+
// Mock dependencies
12+
vi.mock("@calcom/prisma", () => ({
13+
prisma: {
14+
user: {
15+
findFirst: vi.fn(),
16+
update: vi.fn(),
17+
},
18+
},
19+
}));
20+
21+
vi.mock("../../lib/getCustomerAndCheckoutSession");
22+
vi.mock("@calcom/features/auth/lib/sendVerificationRequest");
23+
vi.mock("@calcom/lib/server/service/VerificationTokenService");
24+
25+
const mockGetCustomerAndCheckoutSession = vi.mocked(getCustomerAndCheckoutSession);
26+
const mockSendVerificationRequest = vi.mocked(sendVerificationRequest);
27+
const mockVerificationTokenService = vi.mocked(VerificationTokenService);
28+
29+
// Type the mocked prisma properly
30+
const mockPrisma = prisma as unknown as {
31+
user: {
32+
findFirst: ReturnType<typeof vi.fn>;
33+
update: ReturnType<typeof vi.fn>;
34+
};
35+
};
36+
37+
describe("paymentCallback", () => {
38+
let mockReq: Partial<NextApiRequest>;
39+
let mockRes: Partial<NextApiResponse>;
40+
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
44+
mockReq = {
45+
query: {
46+
callbackUrl: "/premium-username-checkout",
47+
checkoutSessionId: "cs_test_123",
48+
},
49+
url: "/api/payment/callback",
50+
method: "GET",
51+
};
52+
53+
mockRes = {
54+
redirect: vi.fn().mockReturnThis(),
55+
end: vi.fn().mockReturnThis(),
56+
setHeader: vi.fn().mockReturnThis(),
57+
status: vi.fn().mockReturnThis(),
58+
json: vi.fn().mockReturnThis(),
59+
};
60+
61+
// Default mock implementations
62+
mockGetCustomerAndCheckoutSession.mockResolvedValue({
63+
stripeCustomer: {
64+
id: "cus_123",
65+
email: "test@example.com",
66+
metadata: {
67+
username: "premium-user",
68+
},
69+
},
70+
checkoutSession: {
71+
payment_status: "paid",
72+
},
73+
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
74+
75+
mockPrisma.user.findFirst.mockResolvedValue({
76+
id: 1,
77+
email: "test@example.com",
78+
locale: "en",
79+
metadata: {},
80+
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
81+
82+
mockPrisma.user.update.mockResolvedValue({
83+
id: 1,
84+
username: "premium-user",
85+
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
86+
87+
mockVerificationTokenService.create.mockResolvedValue("test-token-123");
88+
mockSendVerificationRequest.mockResolvedValue(undefined);
89+
});
90+
91+
describe("VerificationTokenService integration", () => {
92+
it("should call VerificationTokenService.create with correct parameters", async () => {
93+
const { default: handler } = await import("../paymentCallback");
94+
95+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
96+
97+
expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
98+
identifier: "test@example.com",
99+
expires: expect.any(Date),
100+
});
101+
102+
const callArgs = mockVerificationTokenService.create.mock.calls[0][0];
103+
const expiresDate = callArgs.expires;
104+
const now = Date.now();
105+
const oneDayInMs = 86400 * 1000;
106+
107+
// Verify expires is approximately 1 day from now (within 1 second tolerance)
108+
expect(expiresDate.getTime()).toBeGreaterThan(now);
109+
expect(expiresDate.getTime()).toBeLessThanOrEqual(now + oneDayInMs + 1000);
110+
});
111+
112+
it("should send verification email with token from service", async () => {
113+
const { default: handler } = await import("../paymentCallback");
114+
115+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
116+
117+
expect(mockSendVerificationRequest).toHaveBeenCalledWith({
118+
identifier: "test@example.com",
119+
url: expect.stringContaining("token=test-token-123"),
120+
});
121+
});
122+
123+
it("should create verification token before sending email", async () => {
124+
const { default: handler } = await import("../paymentCallback");
125+
const callOrder: string[] = [];
126+
127+
mockVerificationTokenService.create.mockImplementation(async () => {
128+
callOrder.push("create-token");
129+
return "test-token";
130+
});
131+
132+
mockSendVerificationRequest.mockImplementation(async () => {
133+
callOrder.push("send-email");
134+
});
135+
136+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
137+
138+
expect(callOrder).toEqual(["create-token", "send-email"]);
139+
});
140+
141+
it("should create verification token only after payment is confirmed", async () => {
142+
mockGetCustomerAndCheckoutSession.mockResolvedValue({
143+
stripeCustomer: {
144+
id: "cus_123",
145+
email: "test@example.com",
146+
metadata: { username: "premium-user" },
147+
},
148+
checkoutSession: {
149+
payment_status: "unpaid",
150+
},
151+
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
152+
153+
const { default: handler } = await import("../paymentCallback");
154+
155+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
156+
157+
expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
158+
expect(mockSendVerificationRequest).not.toHaveBeenCalled();
159+
});
160+
161+
it("should handle user found by stripeCustomerId", async () => {
162+
mockPrisma.user.findFirst
163+
.mockResolvedValueOnce(null) // First call by email returns null
164+
.mockResolvedValueOnce({
165+
// Second call by stripeCustomerId succeeds
166+
id: 2,
167+
email: "different@example.com",
168+
locale: "en",
169+
metadata: { stripeCustomerId: "cus_123" },
170+
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
171+
172+
const { default: handler } = await import("../paymentCallback");
173+
174+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
175+
176+
expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
177+
identifier: "different@example.com", // Should use user.email from found user
178+
expires: expect.any(Date),
179+
});
180+
});
181+
182+
it("should update user with premium username before creating token", async () => {
183+
const { default: handler } = await import("../paymentCallback");
184+
const callOrder: string[] = [];
185+
186+
mockPrisma.user.update.mockImplementation(async () => {
187+
callOrder.push("update-user");
188+
return {} as any; // eslint-disable-line @typescript-eslint/no-explicit-any
189+
});
190+
191+
mockVerificationTokenService.create.mockImplementation(async () => {
192+
callOrder.push("create-token");
193+
return "token";
194+
});
195+
196+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
197+
198+
expect(callOrder).toEqual(["update-user", "create-token"]);
199+
});
200+
201+
it("should redirect with correct parameters after successful payment", async () => {
202+
const { default: handler } = await import("../paymentCallback");
203+
204+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
205+
206+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
207+
const redirectUrl = (mockRes.redirect as any).mock.calls[0][0] as string;
208+
expect(redirectUrl).toContain("email=test%40example.com");
209+
expect(redirectUrl).toContain("username=premium-user");
210+
expect(redirectUrl).toContain("paymentStatus=paid");
211+
});
212+
213+
it("should not create verification token when stripe customer is not found", async () => {
214+
mockGetCustomerAndCheckoutSession.mockResolvedValue({
215+
stripeCustomer: null,
216+
checkoutSession: { payment_status: "paid" },
217+
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
218+
219+
const { default: handler } = await import("../paymentCallback");
220+
221+
try {
222+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
223+
} catch (error) {
224+
expect(error).toBeInstanceOf(HttpError);
225+
expect((error as HttpError).statusCode).toBe(404);
226+
}
227+
228+
expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
229+
});
230+
231+
it("should not create verification token when user is not found", async () => {
232+
mockPrisma.user.findFirst.mockResolvedValue(null);
233+
234+
const { default: handler } = await import("../paymentCallback");
235+
236+
try {
237+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
238+
} catch (error) {
239+
expect(error).toBeInstanceOf(HttpError);
240+
expect((error as HttpError).statusCode).toBe(404);
241+
}
242+
243+
expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
244+
});
245+
246+
it("should use user email if stripe customer email is missing", async () => {
247+
mockGetCustomerAndCheckoutSession.mockResolvedValue({
248+
stripeCustomer: {
249+
id: "cus_123",
250+
email: null,
251+
metadata: { username: "premium-user" },
252+
},
253+
checkoutSession: { payment_status: "paid" },
254+
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
255+
256+
const { default: handler } = await import("../paymentCallback");
257+
258+
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
259+
260+
expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
261+
identifier: "test@example.com", // Should use user.email
262+
expires: expect.any(Date),
263+
});
264+
});
265+
});
266+
});

0 commit comments

Comments
 (0)