Signup Flow improvements (#4012)

* Get login working

* Update website

* Fixes

* Save

* svae

* Save

* Change translation key

* Various fixes after testing

* Update website

* Add TS Tests

* Upate website

* Fix tests

* Fix linting and other issues

* Fix linting and other issues

* Fix bugs found during recording of demos

* Revert default coookie change

* Self review fixe

* Link fixes

* Removed inline styles, cleanup

* Various fixes

* Added new envs to e2e

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Hariom Balhara 2022-09-08 06:08:37 +05:30 committed by GitHub
parent d36d925788
commit 4856ed9977
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 547 additions and 245 deletions

View File

@ -94,6 +94,8 @@ NEXT_PUBLIC_IS_E2E=
# Used for internal billing system
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PLAN_PRODUCT_ID=

View File

@ -25,6 +25,8 @@ jobs:
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}

View File

@ -26,6 +26,8 @@ jobs:
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}

View File

@ -41,7 +41,12 @@ const UserSettings = (props: IUserSettingsProps) => {
const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: onSuccess,
});
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false;
const onSubmit = handleSubmit((data) => {
if (paymentRequired) {
return;
}
mutation.mutate({
name: data.name,
timeZone: selectedTimeZone,
@ -56,6 +61,7 @@ const UserSettings = (props: IUserSettingsProps) => {
<div className="space-y-6">
{/* Username textfield */}
<UsernameAvailability
readonly={true}
currentUsername={currentUsername}
setCurrentUsername={setCurrentUsername}
inputUsernameValue={inputUsernameValue}

View File

@ -1,17 +1,18 @@
import classNames from "classnames";
import { debounce } from "lodash";
import { useRouter } from "next/router";
import { MutableRefObject, useCallback, useEffect, useState } from "react";
import { getPremiumPlanMode, getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { User } from "@calcom/prisma/client";
import { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { Icon, StarIconSolid } from "@calcom/ui/Icon";
import { Input, Label } from "@calcom/ui/form/fields";
export enum UsernameChangeStatusEnum {
@ -46,8 +47,39 @@ interface ICustomUsernameProps {
| "timeFormat"
| "allowDynamicBooking"
>;
readonly?: boolean;
}
const obtainNewUsernameChangeCondition = ({
userIsPremium,
isNewUsernamePremium,
stripeCustomer,
}: {
userIsPremium: boolean;
isNewUsernamePremium: boolean;
stripeCustomer: inferQueryOutput<"viewer.stripeCustomer"> | undefined;
}) => {
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
return UsernameChangeStatusEnum.UPGRADE;
}
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
return UsernameChangeStatusEnum.DOWNGRADE;
}
return UsernameChangeStatusEnum.NORMAL;
};
const useIsUsernamePremium = (username: string) => {
const [isCurrentUsernamePremium, setIsCurrentUsernamePremium] = useState(false);
useEffect(() => {
(async () => {
if (!username) return;
const { data } = await fetchUsername(username);
setIsCurrentUsernamePremium(data.premium);
})();
}, [username]);
return isCurrentUsernamePremium;
};
const PremiumTextfield = (props: ICustomUsernameProps) => {
const { t } = useLocale();
const {
@ -58,73 +90,38 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
usernameRef,
onSuccessMutation,
onErrorMutation,
user,
readonly: disabled,
} = props;
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
const [markAsError, setMarkAsError] = useState(false);
const router = useRouter();
const { paymentStatus: recentAttemptPaymentStatus } = router.query;
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
const [usernameChangeCondition, setUsernameChangeCondition] = useState<UsernameChangeStatusEnum | null>(
null
);
const userIsPremium =
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const [premiumUsername, setPremiumUsername] = useState(false);
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
const isCurrentUsernamePremium = useIsUsernamePremium(currentUsername || "");
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
const debouncedApiCall = useCallback(
debounce(async (username) => {
const { data } = await fetchUsername(username);
setMarkAsError(!data.available);
setPremiumUsername(data.premium);
setMarkAsError(!data.available && username !== currentUsername);
setIsInputUsernamePremium(data.premium);
setUsernameIsAvailable(data.available);
}, 150),
[]
);
useEffect(() => {
if (currentUsername !== inputUsernameValue) {
debouncedApiCall(inputUsernameValue);
} else if (inputUsernameValue === "") {
setMarkAsError(false);
setPremiumUsername(false);
setUsernameIsAvailable(false);
} else {
setPremiumUsername(userIsPremium);
setUsernameIsAvailable(false);
}
}, [inputUsernameValue]);
// Use the current username or if it's not set, use the one available from stripe
setInputUsernameValue(currentUsername || stripeCustomer?.username || "");
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
useEffect(() => {
if (usernameIsAvailable || premiumUsername) {
const condition = obtainNewUsernameChangeCondition({
userIsPremium,
isNewUsernamePremium: premiumUsername,
});
setUsernameChangeCondition(condition);
}
}, [usernameIsAvailable, premiumUsername]);
const obtainNewUsernameChangeCondition = ({
userIsPremium,
isNewUsernamePremium,
}: {
userIsPremium: boolean;
isNewUsernamePremium: boolean;
}) => {
let resultCondition: UsernameChangeStatusEnum;
if (!userIsPremium && isNewUsernamePremium) {
resultCondition = UsernameChangeStatusEnum.UPGRADE;
} else if (userIsPremium && !isNewUsernamePremium) {
resultCondition = UsernameChangeStatusEnum.DOWNGRADE;
} else {
resultCondition = UsernameChangeStatusEnum.NORMAL;
}
return resultCondition;
};
if (!inputUsernameValue) return;
debouncedApiCall(inputUsernameValue);
}, [debouncedApiCall, inputUsernameValue]);
const utils = trpc.useContext();
const updateUsername = trpc.useMutation("viewer.updateProfile", {
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
@ -139,34 +136,62 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
},
});
const ActionButtons = (props: { index: string }) => {
const { index } = props;
return (usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue ? (
<div className="flex flex-row">
<Button
type="button"
color="primary"
className="mx-2"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid={`update-username-btn-${index}`}>
{t("update")}
</Button>
<Button
type="button"
color="secondary"
className="mx-2"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
usernameRef.current.value = currentUsername;
}
}}>
{t("cancel")}
</Button>
</div>
) : (
<></>
);
// when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for
const paymentRequired = !currentUsername && stripeCustomer?.isPremium && !stripeCustomer?.paidForPremium;
const usernameChangeCondition = obtainNewUsernameChangeCondition({
userIsPremium: isCurrentUsernamePremium,
isNewUsernamePremium: isInputUsernamePremium,
stripeCustomer,
});
const usernameFromStripe = stripeCustomer?.username;
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
inputUsernameValue || usernameFromStripe
}&action=${usernameChangeCondition}&callbackUrl=${router.asPath}`;
const ActionButtons = () => {
if (paymentRequired) {
return (
<div className="flex flex-row">
<Button
type="button"
color="primary"
className="mx-2"
href={paymentLink}
data-testid="reserve-username-btn">
{t("Reserve")}
</Button>
</div>
);
}
if ((usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue) {
return (
<div className="flex flex-row">
<Button
type="button"
color="primary"
className="mx-2"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid="update-username-btn">
{t("update")}
</Button>
<Button
type="button"
color="secondary"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
usernameRef.current.value = currentUsername;
}
}}>
{t("cancel")}
</Button>
</div>
);
}
return <></>;
};
const saveUsername = () => {
@ -179,79 +204,89 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
return (
<div>
<div style={{ display: "flex", justifyItems: "center" }}>
<div className="flex justify-items-center">
<Label htmlFor="username">{t("username")}</Label>
</div>
<div className="mt-2 flex rounded-md">
<span
className={classNames(
"inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
isInputUsernamePremium ? "border-2 border-orange-400 " : "",
"hidden items-center rounded-l-md border border-r-0 border-gray-300 border-r-gray-300 bg-gray-50 px-3 text-sm text-gray-500 md:inline-flex"
)}>
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
</span>
<div style={{ position: "relative", width: "100%" }}>
<div className="relative w-full">
<Input
ref={usernameRef}
name="username"
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
disabled={disabled}
className={classNames(
"mt-0 rounded-md rounded-l-none",
"mt-0 rounded-md rounded-l-none border-l-2 focus:!ring-0",
isInputUsernamePremium
? "border-2 border-orange-400 focus:border-2 focus:border-orange-400"
: "border-2 focus:border-2",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none"
: "border-l-gray-300 "
)}
defaultValue={currentUsername}
value={inputUsernameValue}
onChange={(event) => {
event.preventDefault();
setInputUsernameValue(event.target.value);
}}
data-testid="username-input"
/>
{currentUsername !== inputUsernameValue && (
<div
className="top-0"
style={{
position: "absolute",
right: 2,
display: "flex",
flexDirection: "row",
}}>
<span
className={classNames(
"mx-2 py-1",
premiumUsername ? "text-orange-500" : "",
usernameIsAvailable ? "" : ""
)}>
{premiumUsername ? <Icon.FiStar className="mt-[4px] w-6" /> : <></>}
{!premiumUsername && usernameIsAvailable ? <Icon.FiCheck className="mt-[4px] w-6" /> : <></>}
</span>
</div>
)}
</div>
<div className="xs:hidden">
<ActionButtons index="desktop" />
<div className="absolute top-0 right-2 flex flex-row">
<span
className={classNames(
"mx-2 py-1",
isInputUsernamePremium ? "text-orange-400" : "",
usernameIsAvailable ? "" : ""
)}>
{isInputUsernamePremium ? <StarIconSolid className="mt-[4px] w-6" /> : <></>}
{!isInputUsernamePremium && usernameIsAvailable ? (
<Icon.FiCheck className="mt-[7px] w-6" />
) : (
<></>
)}
</span>
</div>
</div>
{(usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue && (
<div className="flex justify-end">
<ActionButtons />
</div>
)}
</div>
{paymentRequired ? (
recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid" ? (
<span className="text-sm text-red-500">
Your payment could not be completed. Your username is still not reserved
</span>
) : (
<span className="text-xs text-orange-400">
You need to reserve your premium username for {getPremiumPlanPriceValue()}
</span>
)
) : null}
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
{usernameIsAvailable && (
<p className={classNames("mt-1 text-xs text-gray-900")}>
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && (
<>{t("standard_to_premium_username_description")}</>
<>{t("premium_to_standard_username_description")}</>
)}
</p>
)}
{(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && (
<div className="mt-2 flex justify-end sm:hidden">
<ActionButtons index="mobile" />
</div>
)}
<Dialog open={openDialogSaveUsername}>
<DialogContent>
<div style={{ display: "flex", flexDirection: "row" }}>
<div className="flex flex-row">
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Icon.FiEdit2 className="m-auto h-6 w-6" />
</div>
@ -291,7 +326,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
type="button"
loading={updateUsername.isLoading}
data-testid="go-to-billing"
href={`/api/integrations/stripepayment/subscription?intentUsername=${inputUsernameValue}`}>
href={paymentLink}>
<>
{t("go_to_stripe_billing")} <Icon.FiExternalLink className="ml-1 h-4 w-4" />
</>

View File

@ -1,19 +1,30 @@
import { GetStaticPropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import { SkeletonText } from "@calcom/ui/v2";
import AuthContainer from "@components/ui/AuthContainer";
import { ssgInit } from "@server/lib/ssg";
const querySchema = z.object({
error: z.string().optional(),
});
export default function Error() {
const { t } = useLocale();
const router = useRouter();
const { error } = router.query;
const { error } = querySchema.parse(router.query);
const isTokenVerificationError = error?.toLowerCase() === "verification";
let errorMsg = <SkeletonText />;
if (router.isReady) {
errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
}
return (
<AuthContainer title="" description="">
@ -26,7 +37,7 @@ export default function Error() {
{error}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">{t("error_during_login")}</p>
<p className="text-sm text-gray-500">{errorMsg}</p>
</div>
</div>
</div>

View File

@ -1,12 +1,17 @@
import { CheckIcon, ExclamationIcon, MailOpenIcon } from "@heroicons/react/outline";
import { getSession, signIn } from "next-auth/react";
import { CheckIcon, MailOpenIcon, ExclamationIcon } from "@heroicons/react/outline";
import { signIn } from "next-auth/react";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useEffect, useRef, useState } from "react";
import * as React from "react";
import { useEffect, useState, useRef } from "react";
import z from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/v2/";
import Loader from "@components/Loader";
async function sendVerificationLogin(email: string, username: string) {
await signIn("email", {
@ -23,25 +28,43 @@ async function sendVerificationLogin(email: string, username: string) {
});
}
function useSendFirstVerificationLogin() {
const router = useRouter();
const { email, username } = router.query;
function useSendFirstVerificationLogin({
email,
username,
}: {
email: string | undefined;
username: string | undefined;
}) {
const sent = useRef(false);
useEffect(() => {
if (router.isReady && !sent.current) {
(async () => {
await sendVerificationLogin(`${email}`, `${username}`);
sent.current = true;
})();
if (!email || !username || sent.current) {
return;
}
}, [email, router.isReady, username]);
(async () => {
await sendVerificationLogin(email, username);
sent.current = true;
})();
}, [email, username]);
}
const querySchema = z.object({
stripeCustomerId: z.string().optional(),
sessionId: z.string().optional(),
t: z.string().optional(),
});
export default function Verify() {
const router = useRouter();
const { email, username, t, session_id, cancel } = router.query;
const { t, sessionId, stripeCustomerId } = querySchema.parse(router.query);
const [secondsLeft, setSecondsLeft] = useState(30);
const { data } = trpc.useQuery([
"viewer.public.stripeCheckoutSession",
{
stripeCustomerId,
checkoutSessionId: sessionId,
},
]);
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
// to avoid refresh to skip waiting 30 seconds to re-send email
useEffect(() => {
@ -68,35 +91,28 @@ export default function Verify() {
}
}, [secondsLeft]);
// @note: check for session, redirect to webapp if session found
useEffect(() => {
let intervalId: NodeJS.Timer, redirecting: boolean;
// eslint-disable-next-line prefer-const
intervalId = setInterval(async () => {
const session = await getSession();
if (session && !redirecting) {
// User connected using the magic link -> redirect him/her
redirecting = true;
// @note: redirect to webapp /getting-started, user will end up with two tabs open with the onboarding 'getting-started' wizard.
router.push(WEBAPP_URL + "/getting-started");
}
}, 1000);
return () => {
intervalId && clearInterval(intervalId);
};
}, [router]);
if (!router.isReady || !data) {
// Loading state
return <Loader />;
}
const { valid, hasPaymentFailed, customer } = data;
if (!valid) {
throw new Error("Invalid session or customer id");
}
useSendFirstVerificationLogin();
if (!stripeCustomerId && !sessionId) {
return <div>Invalid Link</div>;
}
return (
<div className=" bg-black bg-opacity-90 text-white backdrop-blur-md backdrop-grayscale backdrop-filter">
<div className="bg-black bg-opacity-90 text-white backdrop-blur-md backdrop-grayscale backdrop-filter">
<Head>
<title>
{/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth
it or too hard to read. */}
{cancel
{hasPaymentFailed
? "Your payment failed"
: session_id
: sessionId
? "Payment successful!"
: "Verify your email" + " | Cal.com"}
</title>
@ -104,34 +120,41 @@ export default function Verify() {
<div className="flex min-h-screen flex-col items-center justify-center px-6">
<div className="m-10 flex max-w-2xl flex-col items-start border border-white p-12 text-left">
<div className="rounded-full border border-white p-3">
{cancel ? (
{hasPaymentFailed ? (
<ExclamationIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" />
) : session_id ? (
) : sessionId ? (
<CheckIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" />
) : (
<MailOpenIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" />
)}
</div>
<h3 className="font-cal my-6 text-3xl font-normal">
{cancel ? "Your payment failed" : session_id ? "Payment successful!" : "Check your Inbox"}
{hasPaymentFailed
? "Your payment failed"
: sessionId
? "Payment successful!"
: "Check your Inbox"}
</h3>
{cancel && (
{hasPaymentFailed && (
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
)}
<p>
We have sent an email to <b>{email} </b>with a link to activate your account.{" "}
{cancel &&
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
{hasPaymentFailed &&
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
</p>
<p className="mt-6 text-gray-400">
Don&apos;t see an email? Click the button below to send another email.
</p>
<div className="mt-6 space-x-5 text-center">
<div className="mt-6 flex space-x-5 text-center">
<Button
color="secondary"
disabled={secondsLeft > 0}
onClick={async (e) => {
if (!customer) {
return;
}
e.preventDefault();
setSecondsLeft(30);
// Update query params with t:timestamp, shallow: true doesn't re-render the page
@ -139,14 +162,13 @@ export default function Verify() {
router.asPath,
{
query: {
email: router.query.email,
username: router.query.username,
...router.query,
t: Date.now(),
},
},
{ shallow: true }
);
return await sendVerificationLogin(`${email}`, `${username}`);
return await sendVerificationLogin(customer.email, customer.username);
}}>
{secondsLeft > 0 ? `Resend in ${secondsLeft} seconds` : "Send another mail"}
</Button>

View File

@ -245,7 +245,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
hasGiphyIntegration,
hasRainbowIntegration,
} = props;
const router = useRouter();
const updateMutation = trpc.useMutation("viewer.eventTypes.update", {

View File

@ -10,7 +10,6 @@ test.describe("Change Password Test", () => {
await pro.login();
// Go to http://localhost:3000/settings/security
await page.goto("/settings/security");
if (!pro.username) throw Error("Test user doesn't have a username");
// Fill form

View File

@ -37,7 +37,7 @@ test.describe("Change username on settings", () => {
await usernameInput.fill("demousernamex");
// Click on save button
await page.click("[data-testid=update-username-btn-desktop]");
await page.click("[data-testid=update-username-btn]");
await Promise.all([
page.waitForResponse("**/viewer.updateProfile*"),
@ -84,7 +84,7 @@ test.describe("Change username on settings", () => {
await usernameInput.fill(`xx${testInfo.workerIndex}`);
// Click on save button
await page.click("[data-testid=update-username-btn-desktop]");
await page.click("[data-testid=update-username-btn]");
// Validate modal text fields
const currentUsernameText = page.locator("[data-testid=current-username]").innerText();
@ -130,7 +130,7 @@ test.describe("Change username on settings", () => {
await usernameInput.fill(`xx${testInfo.workerIndex}`);
// Click on save button
await page.click("[data-testid=update-username-btn-desktop]");
await page.click("[data-testid=update-username-btn]");
// Validate modal text fields
const currentUsernameText = page.locator("[data-testid=current-username]").innerText();

View File

@ -955,7 +955,7 @@
"new_workflow_description": "يُمكّنك سير العمل من أتمتة إرسال التذكير والإشعارات.",
"active_on": "إجراء في",
"workflow_updated_successfully": "تم تحديث سير العمل {{workflowName}} بنجاح",
"standard_to_premium_username_description": "هذا اسم مستخدم قياسي، سوف ينقلك هذا التحديث إلى صفحة الفوترة لخفض المستوى.",
"premium_to_standard_username_description": "هذا اسم مستخدم قياسي، سوف ينقلك هذا التحديث إلى صفحة الفوترة لخفض المستوى.",
"current": "الحالي",
"premium": "مميز",
"standard": "قياسي",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Pracovní postupy umožňují automatizovat zasílání upomínek a oznámení.",
"active_on": "Aktivní pro:",
"workflow_updated_successfully": "Pracovní postup {{workflowName}} byl aktualizován",
"standard_to_premium_username_description": "Toto je standardní uživatelské jméno a při aktualizaci přejdete k fakturaci, kde provedete snížení úrovně.",
"premium_to_standard_username_description": "Toto je standardní uživatelské jméno a při aktualizaci přejdete k fakturaci, kde provedete snížení úrovně.",
"current": "Aktuální",
"premium": "prémiové",
"standard": "standardní",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Workflows ermöglichen Ihnen das automatisierte Versenden von Erinnerungen und Benachrichtigungen.",
"active_on": "Aktiv am",
"workflow_updated_successfully": "{{workflowName}} Workflow erfolgreich aktualisiert",
"standard_to_premium_username_description": "Dies ist ein Standard-Benutzername und die Aktualisierung führt Sie zur Rechnungsstellung, um ein Downgrade durchzuführen.",
"premium_to_standard_username_description": "Dies ist ein Standard-Benutzername und die Aktualisierung führt Sie zur Rechnungsstellung, um ein Downgrade durchzuführen.",
"current": "Aktuell",
"premium": "Premium",
"standard": "Standard",

View File

@ -977,7 +977,7 @@
"new_workflow_description": "Workflows enable you to automate sending reminders and notifications.",
"active_on": "Active on",
"workflow_updated_successfully": "{{workflowName}} workflow updated successfully",
"standard_to_premium_username_description": "This is a standard username and updating will take you to billing to downgrade.",
"premium_to_standard_username_description": "This is a standard username and updating will take you to billing to downgrade.",
"current": "Current",
"premium": "premium",
"standard": "standard",
@ -1180,5 +1180,6 @@
"edit_form_later_subtitle": "Youll be able to edit this later.",
"connect_calendar_later": "I'll connect my calendar later",
"set_my_availability_later": "I'll set my availability later",
"problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support."
"problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support.",
"token_invalid_expired": "Token is either invalid or expired."
}

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Los flujos de trabajo le permiten automatizar el envío de recordatorios y notificaciones.",
"active_on": "Activo en",
"workflow_updated_successfully": "Flujo de trabajo {{workflowName}} actualizado correctamente",
"standard_to_premium_username_description": "Este es un nombre de usuario estándar y la actualización te llevará a la facturación para bajar de categoría.",
"premium_to_standard_username_description": "Este es un nombre de usuario estándar y la actualización te llevará a la facturación para bajar de categoría.",
"current": "Actual",
"premium": "premium",
"standard": "estándar",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Les workflows vous permettent d'automatiser l'envoi de rappels et de notifications.",
"active_on": "Actif le",
"workflow_updated_successfully": "Workflow {{workflowName}} mis à jour avec succès",
"standard_to_premium_username_description": "Ceci est un nom d'utilisateur standard et la mise à jour vous mènera à la facturation à downgrader.",
"premium_to_standard_username_description": "Ceci est un nom d'utilisateur standard et la mise à jour vous mènera à la facturation à downgrader.",
"current": "Actuel",
"premium": "premium",
"standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "תהליכי עבודה מאפשרים להפוך את פעולת השליחה של תזכורות ועדכונים לאוטומטית.",
"active_on": "פעיל ב",
"workflow_updated_successfully": "עדכון תהליך העבודה {{workflowName}} בוצע בהצלחה",
"standard_to_premium_username_description": "זהו שם משתמש סטנדרטי ועדכון שלו יגרום להעברה שלך אל דף החיוב לצורך שנמוך.",
"premium_to_standard_username_description": "זהו שם משתמש סטנדרטי ועדכון שלו יגרום להעברה שלך אל דף החיוב לצורך שנמוך.",
"current": "נוכחי",
"premium": "פרימיום",
"standard": "רגיל",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "I flussi di lavoro consentono di automatizzare l'invio di promemoria e notifiche.",
"active_on": "Data attivazione",
"workflow_updated_successfully": "Flusso di lavoro {{workflowName}} aggiornato correttamente",
"standard_to_premium_username_description": "Questo è un nome utente standard e l'aggiornamento ti porterà alla fatturazione per il downgrade.",
"premium_to_standard_username_description": "Questo è un nome utente standard e l'aggiornamento ti porterà alla fatturazione per il downgrade.",
"current": "Corrente",
"premium": "premium",
"standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "ワークフローにより、リマインダーと通知の送信を自動化できます。",
"active_on": "有効日時",
"workflow_updated_successfully": "{{workflowName}} が正常に更新されました",
"standard_to_premium_username_description": "これはスタンダードユーザー名で、更新するとダウングレードするための請求画面に移動します。",
"premium_to_standard_username_description": "これはスタンダードユーザー名で、更新するとダウングレードするための請求画面に移動します。",
"current": "現在",
"premium": "プレミアム",
"standard": "スタンダード",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "워크플로를 사용하면 미리 알림 및 알림 보내기를 자동화할 수 있습니다.",
"active_on": "유효일",
"workflow_updated_successfully": "{{workflowName}} 워크플로 업데이트 완료",
"standard_to_premium_username_description": "이것은 표준 사용자 이름이며 업데이트하면 다운그레이드를 위한 청구로 이동합니다.",
"premium_to_standard_username_description": "이것은 표준 사용자 이름이며 업데이트하면 다운그레이드를 위한 청구로 이동합니다.",
"current": "기존",
"premium": "프리미엄",
"standard": "표준",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Met werkstromen kunt u het verzenden van herinneringen en meldingen automatiseren.",
"active_on": "Actief op",
"workflow_updated_successfully": "Werkstroom {{workflowName}} bijgewerkt",
"standard_to_premium_username_description": "Dit is een standaard gebruikersnaam, door bij te werken ga je naar facturatie om te downgraden.",
"premium_to_standard_username_description": "Dit is een standaard gebruikersnaam, door bij te werken ga je naar facturatie om te downgraden.",
"current": "Huidig",
"premium": "premium",
"standard": "standaard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Przepływy pracy umożliwiają automatyzację wysyłania przypomnień i powiadomień.",
"active_on": "Aktywny dnia",
"workflow_updated_successfully": "Pomyślnie zaktualizowano przepływ pracy {{workflowName}}",
"standard_to_premium_username_description": "To standardowa nazwa użytkownika i jej aktualizacja spowoduje przejście do płatności za zmianę wersji.",
"premium_to_standard_username_description": "To standardowa nazwa użytkownika i jej aktualizacja spowoduje przejście do płatności za zmianę wersji.",
"current": "Bieżąca",
"premium": "Premium",
"standard": "Standardowa",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Fluxos de trabalho permitem que você automatize o envio de lembretes e notificações.",
"active_on": "Ativar em",
"workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} atualizado com sucesso",
"standard_to_premium_username_description": "Este é um nome de usuário padrão. Para atualizá-lo, levaremos você para fazer downgrade na cobrança.",
"premium_to_standard_username_description": "Este é um nome de usuário padrão. Para atualizá-lo, levaremos você para fazer downgrade na cobrança.",
"current": "Atual",
"premium": "premium",
"standard": "padrão",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Os fluxos de trabalho permitem automatizar o envio de lembretes e notificações.",
"active_on": "Activo em",
"workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} actualizado com sucesso",
"standard_to_premium_username_description": "Este é um nome de utilizador padrão, e ao atualizar irá para a faturação para fazer o downgrade.",
"premium_to_standard_username_description": "Este é um nome de utilizador padrão, e ao atualizar irá para a faturação para fazer o downgrade.",
"current": "Atual",
"premium": "premium",
"standard": "padrão",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Fluxurile de lucru vă permit să automatizați trimiterea mementourilor și a notificărilor.",
"active_on": "Activ pe",
"workflow_updated_successfully": "Fluxul de lucru {{workflowName}} a fost actualizat cu succes",
"standard_to_premium_username_description": "Acesta este un nume de utilizator standard, iar actualizarea vă va duce la facturare pentru retrogradare.",
"premium_to_standard_username_description": "Acesta este un nume de utilizator standard, iar actualizarea vă va duce la facturare pentru retrogradare.",
"current": "Actual",
"premium": "premium",
"standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Рабочие процессы позволяют автоматизировать отправку напоминаний и уведомлений.",
"active_on": "Активен с",
"workflow_updated_successfully": "Рабочий процесс {{workflowName}} обновлен",
"standard_to_premium_username_description": "Это стандартное имя пользователя. В случае изменения вы будете перенаправлены на страницу оплаты для смены тарифа.",
"premium_to_standard_username_description": "Это стандартное имя пользователя. В случае изменения вы будете перенаправлены на страницу оплаты для смены тарифа.",
"current": "Текущее",
"premium": "премиум",
"standard": "стандартное",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Radni tokovi vam omogućavaju da automatizujete slanje podsetnika i notifikacija.",
"active_on": "Aktivan uključen",
"workflow_updated_successfully": "Radni tok {{workflowName}} je uspešno ažuriran",
"standard_to_premium_username_description": "Ovo je standardno korisničko ime i ažuriranje će vas poslati na naplatu da biste prešli na nižu verziju.",
"premium_to_standard_username_description": "Ovo je standardno korisničko ime i ažuriranje će vas poslati na naplatu da biste prešli na nižu verziju.",
"current": "Trenutno",
"premium": "premijum",
"standard": "standardno",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Arbetsflöden gör att du kan automatisera att skicka påminnelser och aviseringar.",
"active_on": "Aktiv på",
"workflow_updated_successfully": "{{workflowName}} uppdaterades framgångsrikt",
"standard_to_premium_username_description": "Detta är ett standardanvändarnamn och uppdatering tar dig till fakturering för att nedgradera.",
"premium_to_standard_username_description": "Detta är ett standardanvändarnamn och uppdatering tar dig till fakturering för att nedgradera.",
"current": "Nuvarande",
"premium": "premium",
"standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "İş akışları, hatırlatıcı ve bildirim göndermeyi otomatikleştirmenizi sağlar.",
"active_on": "Aktif",
"workflow_updated_successfully": "{{workflowName}} iş akışı başarıyla güncellendi",
"standard_to_premium_username_description": "Bu, standart bir kullanıcı adıdır ve güncelleme işlemi eski sürüme geçmeniz için sizi faturalandırma sayfasına yönlendirecektir.",
"premium_to_standard_username_description": "Bu, standart bir kullanıcı adıdır ve güncelleme işlemi eski sürüme geçmeniz için sizi faturalandırma sayfasına yönlendirecektir.",
"current": "Mevcut",
"premium": "premium",
"standard": "standart",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Робочі процеси дають змогу автоматизувати надсилання нагадувань і сповіщень.",
"active_on": "Активується",
"workflow_updated_successfully": "Робочий процес «{{workflowName}}» оновлено",
"standard_to_premium_username_description": "Це стандандартне ім’я користувача. Якщо оновити його, ви перейдете до виставлення рахунків для переходу на дешевшу підписку.",
"premium_to_standard_username_description": "Це стандандартне ім’я користувача. Якщо оновити його, ви перейдете до виставлення рахунків для переходу на дешевшу підписку.",
"current": "Поточна",
"premium": "преміум",
"standard": "стандарт",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Dòng công việc giúp bạn tự động hóa việc gửi lời nhắc và thông báo.",
"active_on": "Hoạt động vào",
"workflow_updated_successfully": "Đã cập nhật thành công tiến độ công việc {{workflowName}}",
"standard_to_premium_username_description": "Đây là tên người dùng tiêu chuẩn và việc cập nhật sẽ đưa bạn đến khâu thanh toán để hạ cấp độ.",
"premium_to_standard_username_description": "Đây là tên người dùng tiêu chuẩn và việc cập nhật sẽ đưa bạn đến khâu thanh toán để hạ cấp độ.",
"current": "Hiện tại",
"premium": "cao cấp",
"standard": "tiêu chuẩn",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "工作流程可让您自动发送提醒和通知。",
"active_on": "活跃于",
"workflow_updated_successfully": "{{workflowName}} 工作流程已成功更新",
"standard_to_premium_username_description": "这是一个标准用户名,更新流程会将您引导至账单页面进行降级。",
"premium_to_standard_username_description": "这是一个标准用户名,更新流程会将您引导至账单页面进行降级。",
"current": "当前",
"premium": "高级",
"standard": "标准",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "工作流程可讓您自動傳送提醒和通知。",
"active_on": "啟用類型:",
"workflow_updated_successfully": "已成功更新 {{workflowName}}",
"standard_to_premium_username_description": "此為標準使用者名稱,更新系統會帶您前往付費頁面進行降級。",
"premium_to_standard_username_description": "此為標準使用者名稱,更新系統會帶您前往付費頁面進行降級。",
"current": "目前",
"premium": "高級",
"standard": "標準",

@ -1 +1 @@
Subproject commit f3fba833b8c5a0b61fe3b0a8a08acc67b7fdb099
Subproject commit 81ff552ad98ff4d2b5819c73cde30e53269bda37

View File

@ -2,5 +2,7 @@ export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as portal } from "./portal";
export { default as subscription } from "./subscription";
export { default as paymentCallback } from "./paymentCallback";
// TODO: Figure out how to handle webhook endpoints from App Store
// export { default as webhook } from "./webhook";

View File

@ -0,0 +1,51 @@
import { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { prisma } from "@calcom/prisma";
const querySchema = z.object({
callbackUrl: z.string().transform((url) => {
if (url.search(/^https?:\/\//) === -1) {
url = `${WEBAPP_URL}${url}`;
}
return new URL(url);
}),
checkoutSessionId: z.string(),
});
// It handles premium user payment success/failure. Can be modified to handle other PRO upgrade payment as well.
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { callbackUrl, checkoutSessionId } = querySchema.parse(req.query);
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
if (!stripeCustomer) return { message: "Stripe customer not found or deleted" };
if (checkoutSession.payment_status === "paid") {
console.log("Found payment ");
try {
await prisma.user.update({
data: {
username: stripeCustomer.metadata.username,
},
where: {
email: stripeCustomer.metadata.email,
},
});
} catch (error) {
console.error(error);
return {
message:
"We have received your payment. Your premium username could still not be reserved. Please contact support@cal.com and mention your premium username",
};
}
}
callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status);
return res.redirect(callbackUrl.toString()).end();
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

View File

@ -2,45 +2,32 @@ import { UserPlan } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import {
getPremiumPlanMode,
getPremiumPlanPrice,
getPremiumPlanProductId,
} from "@calcom/app-store/stripepayment/lib/utils";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import {
PREMIUM_PLAN_PRICE,
PREMIUM_PLAN_PRODUCT_ID,
PRO_PLAN_PRICE,
PRO_PLAN_PRODUCT_ID,
} from "../lib/constants";
import { PRO_PLAN_PRICE, PRO_PLAN_PRODUCT_ID } from "../lib/constants";
import { getStripeCustomerIdFromUserId } from "../lib/customer";
import stripe from "../lib/server";
enum UsernameChangeStatusEnum {
export enum UsernameChangeStatusEnum {
NORMAL = "NORMAL",
UPGRADE = "UPGRADE",
DOWNGRADE = "DOWNGRADE",
}
const obtainNewConditionAction = ({
userCurrentPlan,
isNewUsernamePremium,
}: {
userCurrentPlan: UserPlan;
isNewUsernamePremium: boolean;
}) => {
if (userCurrentPlan === UserPlan.PRO) {
if (isNewUsernamePremium) return UsernameChangeStatusEnum.UPGRADE;
return UsernameChangeStatusEnum.DOWNGRADE;
}
return UsernameChangeStatusEnum.NORMAL;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
const userId = req.session?.user.id;
let { intentUsername = null } = req.query;
let { intentUsername = null } = req.query;
const { action, callbackUrl } = req.query;
if (!userId || !intentUsername) {
res.status(404).end();
return;
@ -66,18 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const isCurrentlyPremium = hasKeyInMetadata(userData, "isPremium") && !!userData.metadata.isPremium;
// Save the intentUsername in the metadata
await prisma.user.update({
where: { id: userId },
data: {
metadata: {
...(userData.metadata as Prisma.JsonObject),
intentUsername,
},
},
});
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/profile`;
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=${callbackUrl}`;
const createSessionParams: Stripe.BillingPortal.SessionCreateParams = {
customer: customerId,
return_url,
@ -87,11 +63,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!checkPremiumResult.available) {
return res.status(404).json({ message: "Intent username not available" });
}
const stripeCustomer = await stripe.customers.retrieve(customerId);
if (!stripeCustomer || stripeCustomer.deleted) {
return res.status(400).json({ message: "Stripe customer not found or deleted" });
}
await stripe.customers.update(customerId, {
metadata: {
...stripeCustomer.metadata,
username: intentUsername,
},
});
if (userData && (userData.plan === UserPlan.FREE || userData.plan === UserPlan.TRIAL)) {
const subscriptionPrice = checkPremiumResult.premium ? PREMIUM_PLAN_PRICE : PRO_PLAN_PRICE;
const subscriptionPrice = checkPremiumResult.premium ? getPremiumPlanPrice() : PRO_PLAN_PRICE;
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
mode: getPremiumPlanMode(),
payment_method_types: ["card"],
customer: customerId,
line_items: [
@ -104,24 +90,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
cancel_url: return_url,
allow_promotion_codes: true,
});
// Save the intentUsername in the metadata
await prisma.user.update({
where: { id: userId },
data: {
metadata: {
...(userData.metadata as Prisma.JsonObject),
checkoutSessionId: checkoutSession.id,
intentUsername,
},
},
});
if (checkoutSession && checkoutSession.url) {
return res.redirect(checkoutSession.url).end();
}
return res.status(404).json({ message: "Couldn't redirect to stripe checkout session" });
}
const action = obtainNewConditionAction({
userCurrentPlan: userData?.plan ?? UserPlan.FREE,
isNewUsernamePremium: checkPremiumResult.premium,
});
if (action && userData) {
let actionText = "";
const customProductsSession = [];
if (action === UsernameChangeStatusEnum.UPGRADE) {
actionText = "Upgrade your plan account";
if (checkPremiumResult.premium) {
customProductsSession.push({ prices: [PREMIUM_PLAN_PRICE], product: PREMIUM_PLAN_PRODUCT_ID });
customProductsSession.push({ prices: [getPremiumPlanPrice()], product: getPremiumPlanProductId() });
} else {
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
}
@ -148,6 +140,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
},
});
await prisma.user.update({
where: { id: userId },
data: {
metadata: {
...(userData.metadata as Prisma.JsonObject),
intentUsername,
},
},
});
if (configuration) {
createSessionParams.configuration = configuration.id;
}

View File

@ -1,5 +1,7 @@
export const FREE_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE || "";
export const PREMIUM_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE || "";
export const IS_PREMIUM_NEW_PLAN = process.env.NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN === "1" ? true : false;
export const PREMIUM_NEW_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE || "";
export const PRO_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || "";
export const FREE_PLAN_PRODUCT_ID = process.env.STRIPE_FREE_PLAN_PRODUCT_ID || "";
export const PRO_PLAN_PRODUCT_ID = process.env.STRIPE_PRO_PLAN_PRODUCT_ID || "";

View File

@ -0,0 +1,24 @@
import stripe from "@calcom/app-store/stripepayment/lib/server";
export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutSessionId);
const customerOrCustomerId = checkoutSession.customer;
let customerId = null;
if (!customerOrCustomerId) {
return { checkoutSession, customer: null };
}
if (typeof customerOrCustomerId === "string") {
customerId = customerOrCustomerId;
} else if (customerOrCustomerId.deleted) {
return { checkoutSession, customer: null };
} else {
customerId = customerOrCustomerId.id;
}
const stripeCustomer = await stripe.customers.retrieve(customerId);
if (stripeCustomer.deleted) {
return { checkoutSession, customer: null };
}
return { stripeCustomer, checkoutSession };
}

View File

@ -33,7 +33,6 @@ export const stripeDataSchema = stripeOAuthTokenSchema.extend({
export type StripeData = z.infer<typeof stripeDataSchema>;
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!;
const stripe = new Stripe(stripePrivateKey, {
apiVersion: "2020-08-27",
});

View File

@ -4,15 +4,25 @@ import {
PREMIUM_PLAN_PRICE,
PREMIUM_PLAN_PRODUCT_ID,
PRO_PLAN_PRICE,
PREMIUM_NEW_PLAN_PRICE,
IS_PREMIUM_NEW_PLAN,
PRO_PLAN_PRODUCT_ID,
} from "./constants";
export function getPerSeatProPlanPrice(): string {
return PRO_PLAN_PRICE;
export function getPremiumPlanMode() {
return IS_PREMIUM_NEW_PLAN ? "payment" : "subscription";
}
export function getPremiumPlanPriceValue() {
return IS_PREMIUM_NEW_PLAN ? "$499" : "$29/mo";
}
export function getPremiumPlanPrice(): string {
return PREMIUM_PLAN_PRICE;
return IS_PREMIUM_NEW_PLAN ? PREMIUM_NEW_PLAN_PRICE : PREMIUM_PLAN_PRICE;
}
export function getPerSeatProPlanPrice(): string {
return PRO_PLAN_PRICE;
}
export function getProPlanPrice(): string {

View File

@ -39,7 +39,6 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer
// Get Custom Contact fields ids
const customFieldsIds = await getCustomFieldsIds("contact", calComCustomContactFields, this.service);
this.log.debug("sync:closecom:user:customFieldsIds", { customFieldsIds });
debugger;
// Get shared fields ids
const sharedFieldsIds = await getCustomFieldsIds("shared", calComSharedFields, this.service);
this.log.debug("sync:closecom:user:sharedFieldsIds", { sharedFieldsIds });

View File

@ -128,6 +128,7 @@ export const userMetadata = z
vitalSettings: vitalSettingsUpdateSchema.optional(),
isPremium: z.boolean().optional(),
intentUsername: z.string().optional(),
checkoutSessionId: z.string().nullable().optional(),
})
.nullable();

View File

@ -7,6 +7,7 @@ import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
@ -37,6 +38,7 @@ import {
updateWebUser as syncServicesUpdateWebUser,
} from "@calcom/lib/sync/SyncServiceManager";
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
import { TRPCError } from "@trpc/server";
@ -79,6 +81,71 @@ const publicViewerRouter = createRouter()
return await samlTenantProduct(prisma, email);
},
})
.query("stripeCheckoutSession", {
input: z.object({
stripeCustomerId: z.string().optional(),
checkoutSessionId: z.string().optional(),
}),
async resolve({ input }) {
const { checkoutSessionId, stripeCustomerId } = input;
// TODO: Move the following data checks to superRefine
if (!checkoutSessionId && !stripeCustomerId) {
throw new Error("Missing checkoutSessionId or stripeCustomerId");
}
if (checkoutSessionId && stripeCustomerId) {
throw new Error("Both checkoutSessionId and stripeCustomerId provided");
}
let customerId: string;
let isPremiumUsername = false;
let hasPaymentFailed = false;
if (checkoutSessionId) {
try {
const session = await stripe.checkout.sessions.retrieve(checkoutSessionId);
if (typeof session.customer !== "string") {
return {
valid: false,
};
}
customerId = session.customer;
isPremiumUsername = true;
hasPaymentFailed = session.payment_status !== "paid";
} catch (e) {
return {
valid: false,
};
}
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
customerId = stripeCustomerId!;
}
try {
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) {
return {
valid: false,
};
}
return {
valid: true,
hasPaymentFailed,
isPremiumUsername,
customer: {
username: customer.metadata.username,
email: customer.metadata.email,
stripeCustomerId: customerId,
},
};
} catch (e) {
return {
valid: false,
};
}
},
})
.merge("slots.", slotsRouter);
// routes only available to authenticated users
@ -687,6 +754,43 @@ const loggedInViewerRouter = createProtectedRouter()
return app;
},
})
.query("stripeCustomer", {
async resolve({ ctx }) {
const {
user: { id: userId },
prisma,
} = ctx;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
metadata: true,
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "User not found" });
}
const metadata = userMetadata.parse(user.metadata);
const checkoutSessionId = metadata?.checkoutSessionId;
//TODO: Rename checkoutSessionId to premiumUsernameCheckoutSessionId
if (!checkoutSessionId) return { isPremium: false };
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
if (!stripeCustomer) {
throw new TRPCError({ code: "NOT_FOUND", message: "Stripe User not found" });
}
return {
isPremium: true,
paidForPremium: checkoutSession.payment_status === "paid",
username: stripeCustomer.metadata.username,
};
},
})
.mutation("updateProfile", {
input: z.object({
username: z.string().optional(),
@ -711,12 +815,14 @@ const loggedInViewerRouter = createProtectedRouter()
const data: Prisma.UserUpdateInput = {
...input,
};
let isPremiumUsername = false;
if (input.username) {
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) {
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
}
@ -725,6 +831,30 @@ const loggedInViewerRouter = createProtectedRouter()
if (input.avatar) {
data.avatar = await resizeBase64Image(input.avatar);
}
const userToUpdate = await prisma.user.findUnique({
where: {
id: user.id,
},
});
if (!userToUpdate) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const metadata = userMetadata.parse(userToUpdate.metadata);
// Checking the status of payment directly from stripe allows to avoid the situation where the user has got the refund or maybe something else happened asyncly at stripe but our DB thinks it's still paid for
// TODO: Test the case where one time payment is refunded.
const premiumUsernameCheckoutSessionId = metadata?.checkoutSessionId;
if (premiumUsernameCheckoutSessionId) {
const checkoutSession = await stripe.checkout.sessions.retrieve(premiumUsernameCheckoutSessionId);
const canUserHavePremiumUsername = checkoutSession.payment_status == "paid";
if (isPremiumUsername && !canUserHavePremiumUsername) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You need to pay for premium username",
});
}
}
const updatedUser = await prisma.user.update({
where: {

View File

@ -7,7 +7,7 @@ export { CollectionIcon } from "@heroicons/react/outline";
export { ShieldCheckIcon } from "@heroicons/react/outline";
export { BadgeCheckIcon } from "@heroicons/react/outline";
export { ClipboardCopyIcon } from "@heroicons/react/outline";
export { StarIcon as StarIconSolid } from "@heroicons/react/solid";
// TODO:
// right now: Icon.Sun comes from react-feather
// CollectionIcon comes from "@heroicons/react/outline";

View File

@ -33,7 +33,7 @@ const SkeletonText: React.FC<SkeletonBaseProps> = ({ width = "", height = "", cl
className = width ? `${className} w-${width}` : className;
className = height ? `${className} h-${height}` : className;
return (
<div
<span
className={classNames(
`dark:white-300 animate-pulse rounded-md bg-gray-300 empty:before:inline-block empty:before:content-[''] w-${width} h-${height}`,
className

View File

@ -33,7 +33,9 @@
"$NEXT_PUBLIC_LICENSE_CONSENT",
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
"$NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN",
"$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",
@ -58,7 +60,9 @@
"^build",
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
"$NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN",
"$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",