feature/settings-username-update (#2306)

* WIP feature/settings-username-update

* WIP username change

* WIP downgrade stripe

* stripe downgrade and prorate preview

* new UI for username premium component

* Fix server side props

* Remove migration, changed field to metadata user

* WIP for update subscriptions

* WIP intent username table

* WIP saving and updating username via hooks

* WIP saving working username sub update

* WIP, update html to work with tests

* Added stripe test for username update go to stripe

* WIP username change test

* Working test for username change

* Fix timeout for flaky test

* Review changes, remove logs

* Move input username as a self contained component

* Self review changes

* Removing unnecesary arrow function

* Removed intentUsername table and now using user metadata

* Update website

* Update turbo.json

* Update e2e.yml

* Update yarn.lock

* Fixes for self host username update

* Revert yarn lock from main branch

* E2E fixes

* Centralizes username check

* Improvements

* WIP separate logic between premium and save username button

* WIP refactor username premium update

* Saving WIP

* WIP redo of username check

* WIP obtain action normal, update or downgrade

* Update username change components

* Fix test for change-username self host or cal server

* Fix user type for premiumTextfield

* Using now a global unique const to know if is selfhosted, css fixes

* Remove unused import

* Using dynamic import for username textfield, prevent submit on enter

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
pull/3244/head
alannnc 2022-07-06 13:31:07 -06:00 committed by GitHub
parent 7d6a6bf812
commit c890e8d06d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1405 additions and 336 deletions

View File

@ -82,11 +82,13 @@ SEND_FEEDBACK_EMAIL=
NEXT_PUBLIC_IS_E2E=
# Used for internal billing system
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT=
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PLAN_PRODUCT_ID=
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
STRIPE_FREE_PLAN_PRODUCT_ID=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_

View File

@ -27,6 +27,9 @@ jobs:
# CRON_API_KEY: xxx
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
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 }}
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,9 +26,15 @@ jobs:
# CRON_API_KEY: xxx
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
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 }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
PAYMENT_FEE_PERCENTAGE: 0.005
PAYMENT_FEE_FIXED: 10
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso

View File

@ -0,0 +1,332 @@
import { CheckIcon, ExternalLinkIcon, PencilAltIcon, StarIcon, XIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { debounce } from "lodash";
import { MutableRefObject, useCallback, useEffect, useState } from "react";
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 Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
import { Input, Label } from "@calcom/ui/form/fields";
import { trpc } from "@lib/trpc";
import { AppRouter } from "@server/routers/_app";
import { TRPCClientErrorLike } from "@trpc/client";
export enum UsernameChangeStatusEnum {
NORMAL = "NORMAL",
UPGRADE = "UPGRADE",
DOWNGRADE = "DOWNGRADE",
}
interface ICustomUsernameProps {
currentUsername: string | undefined;
setCurrentUsername: (value: string | undefined) => void;
inputUsernameValue: string | undefined;
usernameRef: MutableRefObject<HTMLInputElement>;
setInputUsernameValue: (value: string) => void;
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
user: Pick<
User,
| "username"
| "name"
| "email"
| "bio"
| "avatar"
| "timeZone"
| "weekStart"
| "hideBranding"
| "theme"
| "plan"
| "brandColor"
| "darkBrandColor"
| "metadata"
| "timeFormat"
| "allowDynamicBooking"
>;
}
const PremiumTextfield = (props: ICustomUsernameProps) => {
const { t } = useLocale();
const {
currentUsername,
setCurrentUsername,
inputUsernameValue,
setInputUsernameValue,
usernameRef,
onSuccessMutation,
onErrorMutation,
user,
} = props;
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
const [markAsError, setMarkAsError] = useState(false);
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 debouncedApiCall = useCallback(
debounce(async (username) => {
const { data } = await fetchUsername(username);
setMarkAsError(!data.available);
setPremiumUsername(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]);
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;
};
const utils = trpc.useContext();
const updateUsername = trpc.useMutation("viewer.updateProfile", {
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
setCurrentUsername(inputUsernameValue);
setOpenDialogSaveUsername(false);
},
onError: (error) => {
onErrorMutation && onErrorMutation(error);
},
async onSettled() {
await utils.invalidateQueries(["viewer.public.i18n"]);
},
});
const ActionButtons = (props: { index: string }) => {
const { index } = props;
return (usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue ? (
<div className="flex flex-row">
<Button
type="button"
className="mx-2"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid={`update-username-btn-${index}`}>
{t("update")}
</Button>
<Button
type="button"
color="minimal"
className="mx-2"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
usernameRef.current.value = currentUsername;
}
}}>
{t("cancel")}
</Button>
</div>
) : (
<></>
);
};
const saveUsername = () => {
if (usernameChangeCondition === UsernameChangeStatusEnum.NORMAL) {
updateUsername.mutate({
username: inputUsernameValue,
});
}
};
return (
<>
<div style={{ display: "flex", justifyItems: "center" }}>
<Label htmlFor={"username"}>{t("username")}</Label>
</div>
<div className="mt-1 flex rounded-md shadow-sm">
<span
className={classNames(
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
)}>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
<div style={{ position: "relative", width: "100%" }}>
<Input
ref={usernameRef}
name={"username"}
autoComplete={"none"}
autoCapitalize={"none"}
autoCorrect={"none"}
className={classNames(
"mt-0 rounded-l-none",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
)}
defaultValue={currentUsername}
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 ? <StarIcon className="mt-[4px] w-6" /> : <></>}
{!premiumUsername && usernameIsAvailable ? <CheckIcon className="mt-[4px] w-6" /> : <></>}
</span>
</div>
)}
</div>
<div className="xs:hidden">
<ActionButtons index="desktop" />
</div>
</div>
{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")}</>
)}
</p>
)}
{(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && (
<div className="mt-2 flex justify-end md:hidden">
<ActionButtons index="mobile" />
</div>
)}
<Dialog open={openDialogSaveUsername}>
<DialogContent>
<DialogClose asChild>
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
<XIcon className="w-4" />
</div>
</DialogClose>
<div style={{ display: "flex", flexDirection: "row" }}>
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<PencilAltIcon className="m-auto h-6 w-6"></PencilAltIcon>
</div>
<div className="mb-4 w-full px-4 pt-1">
<DialogHeader title={"Confirm username change"} />
{usernameChangeCondition && usernameChangeCondition !== UsernameChangeStatusEnum.NORMAL && (
<p className="-mt-4 mb-4 text-sm text-gray-800">
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE &&
t("change_username_standard_to_premium")}
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE &&
t("change_username_premium_to_standard")}
</p>
)}
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
<div className="flex-1 px-2">
<p className="text-gray-500">
{t("current")} {t("username")}
</p>
<p className="mt-1" data-testid="current-username">
{currentUsername}
</p>
</div>
<div className="ml-6 flex-1">
<p className="text-gray-500" data-testid="new-username">
{t("new")} {t("username")}
</p>
<p>{inputUsernameValue}</p>
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-row-reverse gap-x-2">
{/* redirect to checkout */}
{(usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE ||
usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE) && (
<Button
type="button"
loading={updateUsername.isLoading}
data-testid="go-to-billing"
href={`/api/integrations/stripepayment/subscription?intentUsername=${inputUsernameValue}`}>
<>
{t("go_to_stripe_billing")} <ExternalLinkIcon className="ml-1 h-4 w-4" />
</>
</Button>
)}
{/* Normal save */}
{usernameChangeCondition === UsernameChangeStatusEnum.NORMAL && (
<Button
type="button"
loading={updateUsername.isLoading}
data-testid="save-username"
onClick={() => {
saveUsername();
}}>
{t("save")}
</Button>
)}
<DialogClose asChild>
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
{t("cancel")}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
</>
);
};
export { PremiumTextfield };

View File

@ -0,0 +1,216 @@
import { CheckIcon, PencilAltIcon, XIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { debounce } from "lodash";
import { MutableRefObject, useCallback, useEffect, useState } from "react";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
import { Input, Label } from "@calcom/ui/form/fields";
import { trpc } from "@lib/trpc";
import { AppRouter } from "@server/routers/_app";
import { TRPCClientErrorLike } from "@trpc/client";
interface ICustomUsernameProps {
currentUsername: string | undefined;
setCurrentUsername: (value: string | undefined) => void;
inputUsernameValue: string | undefined;
usernameRef: MutableRefObject<HTMLInputElement>;
setInputUsernameValue: (value: string) => void;
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
}
const UsernameTextfield = (props: ICustomUsernameProps) => {
const { t } = useLocale();
const {
currentUsername,
setCurrentUsername,
inputUsernameValue,
setInputUsernameValue,
usernameRef,
onSuccessMutation,
onErrorMutation,
} = props;
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
const [markAsError, setMarkAsError] = useState(false);
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
const debouncedApiCall = useCallback(
debounce(async (username) => {
const { data } = await fetchUsername(username);
setMarkAsError(!data.available);
setUsernameIsAvailable(data.available);
}, 150),
[]
);
useEffect(() => {
if (currentUsername !== inputUsernameValue) {
debouncedApiCall(inputUsernameValue);
} else if (inputUsernameValue === "") {
setMarkAsError(false);
setUsernameIsAvailable(false);
} else {
setUsernameIsAvailable(false);
}
}, [inputUsernameValue]);
const utils = trpc.useContext();
const updateUsername = trpc.useMutation("viewer.updateProfile", {
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
setCurrentUsername(inputUsernameValue);
setOpenDialogSaveUsername(false);
},
onError: (error) => {
onErrorMutation && onErrorMutation(error);
},
async onSettled() {
await utils.invalidateQueries(["viewer.public.i18n"]);
},
});
const ActionButtons = (props: { index: string }) => {
const { index } = props;
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
<div className="flex flex-row">
<Button
type="button"
className="mx-2"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid={`update-username-btn-${index}`}>
{t("update")}
</Button>
<Button
type="button"
color="minimal"
className="mx-2"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
usernameRef.current.value = currentUsername;
}
}}>
{t("cancel")}
</Button>
</div>
) : (
<></>
);
};
return (
<>
<div>
<Label htmlFor={"username"}>{t("username")}</Label>
</div>
<div className="mt-1 flex rounded-md shadow-sm">
<span
className={classNames(
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
)}>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
<div className="relative w-full">
<Input
ref={usernameRef}
name={"username"}
autoComplete={"none"}
autoCapitalize={"none"}
autoCorrect={"none"}
className={classNames(
"mt-0 rounded-l-none",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
)}
defaultValue={currentUsername}
onChange={(event) => {
event.preventDefault();
setInputUsernameValue(event.target.value);
}}
data-testid="username-input"
/>
{currentUsername !== inputUsernameValue && (
<div className="absolute right-[2px] top-0 flex flex-row">
<span className={classNames("mx-2 py-1")}>
{usernameIsAvailable ? <CheckIcon className="mt-[4px] w-6" /> : <></>}
</span>
</div>
)}
</div>
<div className="xs:hidden">
<ActionButtons index="desktop" />
</div>
</div>
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
<div className="mt-2 flex justify-end md:hidden">
<ActionButtons index="mobile" />
</div>
)}
<Dialog open={openDialogSaveUsername}>
<DialogContent>
<DialogClose asChild>
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
<XIcon className="w-4" />
</div>
</DialogClose>
<div style={{ display: "flex", flexDirection: "row" }}>
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<PencilAltIcon className="m-auto h-6 w-6"></PencilAltIcon>
</div>
<div className="mb-4 w-full px-4 pt-1">
<DialogHeader title={t("confirm_username_change_dialog_title")} />
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
<div className="flex-1 px-2">
<p className="text-gray-500">
{t("current")} {t("username").toLocaleLowerCase()}
</p>
<p className="mt-1" data-testid="current-username">
{currentUsername}
</p>
</div>
<div className="flex-1">
<p className="text-gray-500" data-testid="new-username">
{t("new")} {t("username").toLocaleLowerCase()}
</p>
<p>{inputUsernameValue}</p>
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-row-reverse gap-x-2">
<Button
type="button"
loading={updateUsername.isLoading}
data-testid="save-username"
onClick={() => {
updateUsername.mutate({
username: inputUsernameValue,
});
}}>
{t("save")}
</Button>
<DialogClose asChild>
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
{t("cancel")}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
</>
);
};
export { UsernameTextfield };

View File

@ -0,0 +1,6 @@
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { PremiumTextfield } from "./PremiumTextfield";
import { UsernameTextfield } from "./UsernameTextfield";
export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : PremiumTextfield;

View File

@ -3,6 +3,7 @@ import slugify from "@lib/slugify";
export async function checkRegularUsername(_username: string) {
const username = slugify(_username);
const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5;
const user = await prisma.user.findUnique({
where: { username },
@ -14,10 +15,12 @@ export async function checkRegularUsername(_username: string) {
if (user) {
return {
available: false as const,
premium,
message: "A user exists with that username",
};
}
return {
available: true as const,
premium,
};
}

View File

@ -0,0 +1,6 @@
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { checkRegularUsername } from "@lib/core/server/checkRegularUsername";
export const checkUsername = IS_SELF_HOSTED ? checkRegularUsername : checkPremiumUsername;

View File

@ -0,0 +1,50 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { userMetadata as zodUserMetadata } from "@calcom/prisma/zod-utils";
import { checkUsername } from "@lib/core/server/checkUsername";
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { intentUsername } = req.body;
// Check that user is authenticated
try {
const session = await getSession({ req });
const userId = session?.user?.id;
const user = await prisma.user.findFirst({
select: {
id: true,
metadata: true,
},
where: { id: userId },
rejectOnNotFound: true,
});
const checkPremiumUsernameResult = await checkUsername(intentUsername);
if (userId && user) {
const userMetadata = zodUserMetadata.parse(user.metadata);
await prisma.user.update({
where: {
id: userId,
},
data: {
metadata: {
...userMetadata,
intentUsername,
isIntentPremium: checkPremiumUsernameResult.premium,
},
},
});
}
} catch (error) {
res.status(501).send({ message: "intent-username.save.error" });
}
res.end();
}
export default defaultHandler({
GET: Promise.resolve({ default: getHandler }),
});

View File

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
import { checkUsername } from "@lib/core/server/checkUsername";
type Response = {
available: boolean;
@ -8,6 +8,6 @@ type Response = {
};
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
const result = await checkPremiumUsername(req.body.username);
const result = await checkUsername(req.body.username);
return res.status(200).json(result);
}

View File

@ -3,14 +3,14 @@ import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
import stripe from "@calcom/stripe/server";
import { getPremiumPlanPrice } from "@calcom/stripe/utils";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { checkUsername } from "@lib/core/server/checkUsername";
import prisma from "@lib/prisma";
import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID, samlTenantProduct } from "@lib/saml";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID, samlTenantProduct } from "@lib/saml";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { ssrInit } from "@server/lib/ssr";
@ -62,7 +62,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (session) {
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
if (usernameParam && session.user.email) {
const availability = await checkPremiumUsername(usernameParam);
const availability = await checkUsername(usernameParam);
if (availability.available && availability.premium) {
const stripePremiumUrl = await getStripePremiumUsernameUrl({
userEmail: session.user.email,

View File

@ -15,8 +15,8 @@ import * as z from "zod";
import getApps from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import dayjs from "@calcom/dayjs";
import { ResponseUsernameApi } from "@calcom/ee/lib/core/checkPremiumUsername";
import { DOCS_URL } from "@calcom/lib/constants";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Form } from "@calcom/ui/form/fields";
@ -230,20 +230,6 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
token: string;
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
const fetchUsername = async (username: string) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_WEBSITE_URL}/api/username`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: username.trim() }),
method: "POST",
mode: "cors",
});
const data = (await response.json()) as ResponseUsernameApi;
return { response, data };
};
// Should update username on user when being redirected from sign up and doing google/saml
useEffect(() => {
async function validateAndSave(username: string) {

View File

@ -4,13 +4,13 @@ import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { useRouter } from "next/router";
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { TextField } from "@calcom/ui/form/fields";
import { withQuery } from "@lib/QueryCell";
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
@ -27,15 +27,19 @@ import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogCont
import Avatar from "@components/ui/Avatar";
import Badge from "@components/ui/Badge";
import InfoBadge from "@components/ui/InfoBadge";
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
import ColorPicker from "@components/ui/colorpicker";
import Select from "@components/ui/form/Select";
import TimezoneSelect, { ITimezone } from "@components/ui/form/TimezoneSelect";
import { AppRouter } from "@server/routers/_app";
import { TRPCClientErrorLike } from "@trpc/client";
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
type Props = inferSSRProps<typeof getServerSideProps>;
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
const { user } = props;
const { t } = useLocale();
const [modalOpen, setModalOpen] = useState(false);
@ -46,12 +50,12 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
name="hide-branding"
type="checkbox"
ref={props.hideBrandingRef}
defaultChecked={isBrandingHidden(props.user)}
defaultChecked={isBrandingHidden(user)}
className={
"h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800 disabled:opacity-50"
}
onClick={(e) => {
if (!e.currentTarget.checked || props.user.plan !== "FREE") {
if (!e.currentTarget.checked || user.plan !== "FREE") {
return;
}
@ -69,20 +73,24 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
}
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
const utils = trpc.useContext();
const { user } = props;
const { t } = useLocale();
const router = useRouter();
const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: async () => {
const utils = trpc.useContext();
const onSuccessMutation = async () => {
showToast(t("your_user_profile_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
await utils.invalidateQueries(["viewer.me"]);
},
onError: (err) => {
};
const onErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
setHasErrors(true);
setErrorMessage(err.message);
setErrorMessage(error.message);
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
},
};
const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: onSuccessMutation,
onError: onErrorMutation,
async onSettled() {
await utils.invalidateQueries(["viewer.public.i18n"]);
},
@ -95,7 +103,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
"Content-Type": "application/json",
},
}).catch((e) => {
console.error(`Error Removing user: ${props.user.id}, email: ${props.user.email} :`, e);
console.error(`Error Removing user: ${user.id}, email: ${user.email} :`, e);
});
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
signOut({ callbackUrl: "/auth/logout?survey=true" });
@ -129,28 +137,28 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
value: props.user.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === props.user.timeFormat)?.label || 12,
value: user.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === user.timeFormat)?.label || 12,
});
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(props.user.timeZone);
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(user.timeZone);
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
value: props.user.weekStart,
label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
value: user.weekStart,
label: nameOfDay(props.localeProp, user.weekStart === "Sunday" ? 0 : 1),
});
const [selectedLanguage, setSelectedLanguage] = useState({
value: props.localeProp || "",
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
});
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || "");
const [imageSrc, setImageSrc] = useState<string>(user.avatar || "");
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [brandColor, setBrandColor] = useState(props.user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(props.user.darkBrandColor);
const [brandColor, setBrandColor] = useState(user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
useEffect(() => {
if (!props.user.theme) return;
const userTheme = themeOptions.find((theme) => theme.value === props.user.theme);
if (!user.theme) return;
const userTheme = themeOptions.find((theme) => theme.value === user.theme);
if (!userTheme) return;
setSelectedTheme(userTheme);
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -192,27 +200,34 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
timeFormat: enteredTimeFormat,
});
}
const [currentUsername, setCurrentUsername] = useState(user.username || undefined);
const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername);
return (
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
{hasErrors && <Alert severity="error" title={errorMessage} />}
<div className="py-6 lg:pb-8">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<>
<div className="pt-6 pb-4 lg:pb-8">
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
<div className="mb-6 w-full sm:w-1/2">
<TextField
name="username"
addOnLeading={
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
}
ref={usernameRef}
defaultValue={props.user.username || undefined}
<div className="w-full">
<UsernameAvailability
currentUsername={currentUsername}
setCurrentUsername={setCurrentUsername}
inputUsernameValue={inputUsernameValue}
usernameRef={usernameRef}
setInputUsernameValue={setInputUsernameValue}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
user={user}
/>
</div>
<div className="w-full sm:w-1/2">
</div>
</div>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
{hasErrors && <Alert severity="error" title={errorMessage} />}
<div className="pb-6 lg:pb-8">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<div className="block sm:flex">
<div className="w-full">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("full_name")}
</label>
@ -224,8 +239,8 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
autoComplete="given-name"
placeholder={t("your_name")}
required
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
defaultValue={props.user.name || undefined}
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800 sm:text-sm"
defaultValue={user.name || undefined}
/>
</div>
</div>
@ -240,8 +255,8 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
name="email"
id="email"
placeholder={t("your_email")}
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
defaultValue={props.user.email}
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
defaultValue={user.email}
/>
<p className="mt-2 text-sm text-gray-500" id="email-description">
{t("change_email_tip")}
@ -260,16 +275,16 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
name="about"
placeholder={t("little_something_about")}
rows={3}
defaultValue={props.user.bio || undefined}
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"></textarea>
defaultValue={user.bio || undefined}
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"></textarea>
</div>
</div>
<div>
<div className="mt-1 flex">
<Avatar
alt={props.user.name || ""}
alt={user.name || ""}
className="relative h-10 w-10 rounded-full"
gravatarFallbackMd5={props.user.emailMd5}
gravatarFallbackMd5={user.emailMd5}
imageSrc={imageSrc}
/>
<input
@ -338,7 +353,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
<div className="mt-1">
<Select
id="timeFormatSelect"
value={selectedTimeFormat || props.user.timeFormat}
value={selectedTimeFormat || user.timeFormat}
onChange={(v) => v && setSelectedTimeFormat(v)}
className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm"
options={timeFormatOptions}
@ -424,24 +439,24 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
{t("light_brand_color")}
</label>
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} />
<ColorPicker defaultValue={user.brandColor} onChange={setBrandColor} />
</div>
<div className="mb-2 sm:w-1/2">
<label htmlFor="darkBrandColor" className="block text-sm font-medium text-gray-700">
{t("dark_brand_color")}
</label>
<ColorPicker defaultValue={props.user.darkBrandColor} onChange={setDarkBrandColor} />
<ColorPicker defaultValue={user.darkBrandColor} onChange={setDarkBrandColor} />
</div>
</div>
<div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<HideBrandingInput user={props.user} hideBrandingRef={hideBrandingRef} />
<HideBrandingInput user={user} hideBrandingRef={hideBrandingRef} />
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding")}{" "}
{props.user.plan !== "PRO" && <Badge variant="default">PRO</Badge>}
{user.plan !== "PRO" && <Badge variant="default">PRO</Badge>}
</label>
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
</div>
@ -483,6 +498,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
</div>
</div>
</form>
</>
);
}

View File

@ -0,0 +1,148 @@
import { expect } from "@playwright/test";
import { UserPlan } from "@prisma/client";
import dayjs from "dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import stripe from "@calcom/stripe/server";
import { getFreePlanPrice, getProPlanPrice } from "@calcom/stripe/utils";
import prisma from "@lib/prisma";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
const IS_SELF_HOSTED = !(
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || !!new URL(WEBAPP_URL).hostname.endsWith(".cal.com")
);
test.describe("Change username on settings", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("User can change username", async ({ page, users }) => {
const user = await users.create({ plan: UserPlan.TRIAL });
await user.login();
// Try to go homepage
await page.goto("/settings/profile");
// Change username from normal to normal
const usernameInput = page.locator("[data-testid=username-input]");
await usernameInput.fill("demousernamex");
// Click on save button
await page.click("[data-testid=update-username-btn-desktop]");
await page.click("[data-testid=save-username]");
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(400);
const newUpdatedUser = await prisma.user.findFirst({
where: {
id: user.id,
},
});
expect(newUpdatedUser?.username).toBe("demousernamex");
});
test("User trial can update to PREMIUM username", async ({ page, users }, testInfo) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
test.skip(IS_SELF_HOSTED, "It shouldn't run on self hosted");
const user = await users.create({ plan: UserPlan.TRIAL });
const customer = await stripe.customers.create({ email: `${user?.username}@example.com` });
await stripe.subscriptionSchedules.create({
customer: customer.id,
start_date: "now",
end_behavior: "release",
phases: [
{
items: [{ price: getProPlanPrice() }],
trial_end: dayjs().add(14, "day").unix(),
end_date: dayjs().add(14, "day").unix(),
},
{
items: [{ price: getFreePlanPrice() }],
},
],
});
await user.login();
await page.goto("/settings/profile");
// Change username from normal to premium
const usernameInput = page.locator("[data-testid=username-input]");
await usernameInput.fill(`xx${testInfo.workerIndex}`);
// Click on save button
await page.click("[data-testid=update-username-btn-desktop]");
// Validate modal text fields
const currentUsernameText = page.locator("[data-testid=current-username]").innerText();
const newUsernameText = page.locator("[data-testid=new-username]").innerText();
expect(currentUsernameText).not.toBe(newUsernameText);
// Click on Go to billing
await page.click("[data-testid=go-to-billing]", { timeout: 300 });
await page.waitForLoadState();
await expect(page).toHaveURL(/.*checkout.stripe.com/);
});
test("User PRO can update to PREMIUM username", async ({ page, users }, testInfo) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
test.skip(IS_SELF_HOSTED, "It shouldn't run on self hosted");
const user = await users.create({ plan: UserPlan.PRO });
const customer = await stripe.customers.create({ email: `${user?.username}@example.com` });
const paymentMethod = await stripe.paymentMethods.create({
type: "card",
card: {
number: "4242424242424242",
cvc: "123",
exp_month: 12,
exp_year: 2040,
},
});
await stripe.paymentMethods.attach(paymentMethod.id, { customer: customer.id });
await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: getProPlanPrice() }],
});
await user.login();
await page.goto("/settings/profile");
// Change username from normal to premium
const usernameInput = page.locator("[data-testid=username-input]");
await usernameInput.fill(`xx${testInfo.workerIndex}`);
// Click on save button
await page.click("[data-testid=update-username-btn-desktop]");
// Validate modal text fields
const currentUsernameText = page.locator("[data-testid=current-username]").innerText();
const newUsernameText = page.locator("[data-testid=new-username]").innerText();
expect(currentUsernameText).not.toBe(newUsernameText);
// Click on Go to billing
await page.click("[data-testid=go-to-billing]", { timeout: 300 });
await page.waitForLoadState();
await expect(page).toHaveURL(/.*billing.stripe.com/);
});
});

View File

@ -29,7 +29,7 @@ test.describe("Stripe integration", () => {
).toContainText("Disconnect");
// Cleanup
await user.delete();
await users.deleteAll();
});
});
@ -67,7 +67,7 @@ test.describe("Stripe integration", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// Cleanup
await user.delete();
await users.deleteAll();
});
todo("Pending payment booking should not be confirmed by default");

View File

@ -902,6 +902,15 @@
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email",
"url_start_with_https": "URL needs to start with http:// or https://",
"number_provided": "Phone number will be provided",
"standard_to_premium_username_description": "This is a standard username and updating will take you to billing to downgrade.",
"current": "Current",
"premium": "premium",
"standard": "standard",
"new": "New",
"confirm_username_change_dialog_title": "Confirm username change",
"change_username_standard_to_premium": "As you are changing from a standard to a premium username, you will be taken to the checkout to upgrade.",
"change_username_premium_to_standard": "As you are changing from a premium to a standard username, you will be taken to the checkout to downgrade.",
"go_to_stripe_billing": "Go to billing",
"trial_expired": "Your trial has expired",
"remove_app": "Remove App",
"yes_remove_app": "Yes, remove app",

View File

@ -6,14 +6,13 @@ import { z } from "zod";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import dayjs from "@calcom/dayjs";
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
import { sendFeedbackEmail } from "@calcom/emails";
import { sendCancelledEmails } from "@calcom/emails";
import { parseRecurringEvent, isPrismaObjOrUndefined } from "@calcom/lib";
import { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { closePayments } from "@ee/lib/stripe/server";
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import { checkUsername } from "@lib/core/server/checkUsername";
import jackson from "@lib/jackson";
import prisma from "@lib/prisma";
import { isTeamOwner } from "@lib/queries/teams";
@ -41,9 +40,6 @@ import { resizeBase64Image } from "../lib/resizeBase64Image";
import { viewerTeamsRouter } from "./viewer/teams";
import { webhookRouter } from "./viewer/webhook";
const checkUsername =
process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? checkPremiumUsername : checkRegularUsername;
// things that unauthenticated users can query about themselves
const publicViewerRouter = createRouter()
.query("session", {
@ -698,7 +694,7 @@ const loggedInViewerRouter = createProtectedRouter()
if (username !== user.username) {
data.username = username;
const response = await checkUsername(username);
if (!response.available || ("premium" in response && response.premium)) {
if (!response.available) {
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
}
}

View File

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

View File

@ -0,0 +1,158 @@
import { UserPlan } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { checkPremiumUsername } from "@calcom/ee/lib/core/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 "@calcom/stripe/constants";
import { getStripeCustomerIdFromUserId } from "@calcom/stripe/customer";
import stripe from "@calcom/stripe/server";
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;
if (!userId || !intentUsername) {
res.status(404).end();
return;
}
if (intentUsername && typeof intentUsername === "object") {
intentUsername = intentUsername[0];
}
const customerId = await getStripeCustomerIdFromUserId(userId);
if (!customerId) {
res.status(404).json({ message: "Missing customer id" });
return;
}
const userData = await prisma.user.findFirst({
where: { id: userId },
select: { id: true, plan: true, metadata: true },
});
if (!userData) {
res.status(404).json({ message: "Missing user data" });
return;
}
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 createSessionParams: Stripe.BillingPortal.SessionCreateParams = {
customer: customerId,
return_url,
};
const checkPremiumResult = await checkPremiumUsername(intentUsername);
if (!checkPremiumResult.available) {
return res.status(404).json({ message: "Intent username not available" });
}
if (userData && (userData.plan === UserPlan.FREE || userData.plan === UserPlan.TRIAL)) {
const subscriptionPrice = checkPremiumResult.premium ? PREMIUM_PLAN_PRICE : PRO_PLAN_PRICE;
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer: customerId,
line_items: [
{
price: subscriptionPrice,
quantity: 1,
},
],
success_url: return_url,
cancel_url: return_url,
allow_promotion_codes: true,
});
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 });
} else {
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
}
} else if (action === UsernameChangeStatusEnum.DOWNGRADE) {
actionText = "Downgrade your plan account";
if (isCurrentlyPremium) {
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
}
}
const configuration = await stripe.billingPortal.configurations.create({
business_profile: {
headline: actionText,
},
features: {
payment_method_update: {
enabled: true,
},
subscription_update: {
enabled: true,
proration_behavior: "always_invoice",
default_allowed_updates: ["price"],
products: customProductsSession,
},
},
});
if (configuration) {
createSessionParams.configuration = configuration.id;
}
}
const stripeSession = await stripe.billingPortal.sessions.create(createSessionParams);
res.redirect(stripeSession.url).end();
}
}

View File

@ -1,11 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { JSONObject } from "superjson/dist/types";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET" && req.session && req.session.user.id) {
const userId = req.session.user.id;
try {
const user = await prisma?.user.findFirst({
const user = await prisma.user.findFirst({
select: {
metadata: true,
},

View File

@ -1,12 +1,12 @@
import slugify from "@calcom/lib/slugify";
import { WEBSITE_URL } from "@calcom/lib/constants";
export type ResponseUsernameApi = {
interface ResponseUsernameApi {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
}
export async function checkPremiumUsername(_username: string): Promise<ResponseUsernameApi> {
const username = slugify(_username);

View File

@ -12,6 +12,9 @@ export const CONSOLE_URL =
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || process.env.NODE_ENV !== "production"
? `https://console.cal.dev`
: `https://console.cal.com`;
export const IS_SELF_HOSTED = !(
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || !!new URL(WEBAPP_URL).hostname.endsWith(".cal.com")
);
export const EMBED_LIB_URL = process.env.NEXT_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
export const TRIAL_LIMIT_DAYS = 14;

View File

@ -0,0 +1,21 @@
type ResponseUsernameApi = {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
export async function fetchUsername(username: string) {
const response = await fetch("/api/username", {
credentials: "include",
method: "POST",
body: JSON.stringify({
username: username.trim(),
}),
headers: {
"Content-Type": "application/json",
},
});
const data = (await response.json()) as ResponseUsernameApi;
return { response, data };
}

View File

@ -0,0 +1,9 @@
import isPrismaObj from "./isPrismaObj";
const hasKeyInMetadata = <T extends string>(
x: { metadata: unknown } | null,
key: T
): x is { metadata: { [key in T]: string | boolean | number } } =>
isPrismaObj(x?.metadata) && !!x?.metadata && key in x.metadata;
export default hasKeyInMetadata;

View File

@ -106,5 +106,7 @@ export const userMetadata = z
proPaidForByTeamId: z.number().optional(),
stripeCustomerId: z.string().optional(),
vitalSettings: vitalSettingsUpdateSchema.optional(),
isPremium: z.boolean().optional(),
intentUsername: z.string().optional(),
})
.nullable();

View File

@ -1,4 +1,6 @@
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 PRO_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || "";
export const PRO_PLAN_PRODUCT = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT || "";
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 || "";
export const PREMIUM_PLAN_PRODUCT_ID = process.env.STRIPE_PREMIUM_PLAN_PRODUCT_ID || "";

View File

@ -0,0 +1,73 @@
import { UserPlan } from "@prisma/client";
import Stripe from "stripe";
import stripe from "@calcom/stripe/server";
import {
getFreePlanPrice,
getPremiumPlanPrice,
getProPlanPrice,
getFreePlanProductId,
getPremiumPlanProductId,
getProPlanProductId,
} from "./utils";
interface IRetrieveSubscriptionIdResponse {
message?: string;
subscriptionId?: string;
}
export async function retrieveSubscriptionIdFromStripeCustomerId(
stripeCustomerId: string
): Promise<IRetrieveSubscriptionIdResponse> {
const customer = await stripe.customers.retrieve(stripeCustomerId, {
expand: ["subscriptions.data.plan"],
});
if (!customer || customer.deleted) {
return {
message: "Not found",
};
}
const subscription = customer.subscriptions?.data[0];
if (!subscription) {
return {
message: "Not found",
};
}
return {
subscriptionId: subscription.id,
};
}
// @NOTE: Remove when user subscription plan id is saved on db and not on stripe only
export function obtainUserPlanDetails(subscription: Stripe.Subscription) {
const proPlanProductId = getProPlanProductId();
const premiumPlanProductId = getPremiumPlanProductId();
const freePlanProductId = getFreePlanProductId();
let priceId = "";
const hasProPlan = !!subscription.items.data.find((item) => item.plan.product === proPlanProductId);
const hasPremiumPlan = !!subscription.items.data.find((item) => item.plan.product === premiumPlanProductId);
const hasFreePlan = !!subscription.items.data.find((item) => item.plan.product === freePlanProductId);
let userPlan: UserPlan;
if (hasPremiumPlan) {
priceId = getPremiumPlanPrice();
userPlan = UserPlan.PRO;
} else if (hasProPlan) {
priceId = getProPlanPrice();
userPlan = UserPlan.PRO;
} else if (hasFreePlan) {
priceId = getFreePlanPrice();
userPlan = UserPlan.FREE;
} else {
userPlan = UserPlan.TRIAL;
}
return {
userPlan,
priceId,
isProPlan: hasProPlan,
isPremiumPlan: hasPremiumPlan,
isFreePlan: hasFreePlan,
};
}

View File

@ -1,4 +1,11 @@
import { FREE_PLAN_PRICE, PREMIUM_PLAN_PRICE, PRO_PLAN_PRICE, PRO_PLAN_PRODUCT } from "./constants";
import {
FREE_PLAN_PRICE,
FREE_PLAN_PRODUCT_ID,
PREMIUM_PLAN_PRICE,
PREMIUM_PLAN_PRODUCT_ID,
PRO_PLAN_PRICE,
PRO_PLAN_PRODUCT_ID,
} from "./constants";
export function getPerSeatProPlanPrice(): string {
return PRO_PLAN_PRICE;
@ -12,10 +19,18 @@ export function getProPlanPrice(): string {
return PRO_PLAN_PRICE;
}
export function getProPlanProduct(): string {
return PRO_PLAN_PRODUCT;
}
export function getFreePlanPrice(): string {
return FREE_PLAN_PRICE;
}
export function getProPlanProductId(): string {
return PRO_PLAN_PRODUCT_ID;
}
export function getPremiumPlanProductId(): string {
return PREMIUM_PLAN_PRODUCT_ID;
}
export function getFreePlanProductId(): string {
return FREE_PLAN_PRODUCT_ID;
}

View File

@ -34,7 +34,9 @@
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT",
"$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"$NEXT_PUBLIC_WEBAPP_URL",
"$NEXT_PUBLIC_WEBSITE_URL"
@ -56,7 +58,9 @@
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT",
"$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"$NEXT_PUBLIC_WEBAPP_URL",
"$NEXT_PUBLIC_WEBSITE_URL"