From c890e8d06d7917e413ccf1dfc651cab79af6f2f8 Mon Sep 17 00:00:00 2001 From: alannnc Date: Wed, 6 Jul 2022 13:31:07 -0600 Subject: [PATCH] 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 Co-authored-by: zomars Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .env.example | 4 +- .github/workflows/e2e-embed.yml | 3 + .github/workflows/e2e.yml | 6 + .../UsernameAvailability/PremiumTextfield.tsx | 332 ++++++++++ .../UsernameTextfield.tsx | 216 +++++++ .../ui/UsernameAvailability/index.tsx | 6 + .../core/{ => server}/checkRegularUsername.ts | 3 + apps/web/lib/core/server/checkUsername.ts | 6 + apps/web/pages/api/intent-username/index.ts | 50 ++ apps/web/pages/api/username.ts | 4 +- apps/web/pages/auth/sso/[provider].tsx | 6 +- apps/web/pages/getting-started.tsx | 16 +- apps/web/pages/settings/profile.tsx | 590 +++++++++--------- apps/web/playwright/change-username.test.ts | 148 +++++ .../playwright/integrations-stripe.test.ts | 4 +- apps/web/public/static/locales/en/common.json | 25 +- apps/web/server/routers/viewer.tsx | 8 +- packages/app-store/stripepayment/api/index.ts | 1 + .../stripepayment/api/subscription.ts | 158 +++++ packages/app-store/vital/api/settings.ts | 4 +- packages/ee/lib/core/checkPremiumUsername.ts | 4 +- packages/lib/constants.ts | 3 + packages/lib/fetchUsername.ts | 21 + packages/lib/hasKeyInMetadata.ts | 9 + packages/prisma/zod-utils.ts | 2 + packages/stripe/constants.ts | 4 +- packages/stripe/subscriptions.ts | 73 +++ packages/stripe/utils.ts | 25 +- turbo.json | 8 +- yarn.lock | 2 +- 30 files changed, 1405 insertions(+), 336 deletions(-) create mode 100644 apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx create mode 100644 apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx create mode 100644 apps/web/components/ui/UsernameAvailability/index.tsx rename apps/web/lib/core/{ => server}/checkRegularUsername.ts (81%) create mode 100644 apps/web/lib/core/server/checkUsername.ts create mode 100644 apps/web/pages/api/intent-username/index.ts create mode 100644 apps/web/playwright/change-username.test.ts create mode 100644 packages/app-store/stripepayment/api/subscription.ts create mode 100644 packages/lib/fetchUsername.ts create mode 100644 packages/lib/hasKeyInMetadata.ts create mode 100644 packages/stripe/subscriptions.ts diff --git a/.env.example b/.env.example index 486d312639..c621143796 100644 --- a/.env.example +++ b/.env.example @@ -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_ diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 90520fbd35..14d07dfb01 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -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 }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e9e216a9fa..db6c256935 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx new file mode 100644 index 0000000000..5b75d96063 --- /dev/null +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -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; + setInputUsernameValue: (value: string) => void; + onSuccessMutation?: () => void; + onErrorMutation?: (error: TRPCClientErrorLike) => 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( + 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 ? ( +
+ + +
+ ) : ( + <> + ); + }; + + const saveUsername = () => { + if (usernameChangeCondition === UsernameChangeStatusEnum.NORMAL) { + updateUsername.mutate({ + username: inputUsernameValue, + }); + } + }; + + return ( + <> +
+ +
+
+ + {process.env.NEXT_PUBLIC_WEBSITE_URL}/ + +
+ { + event.preventDefault(); + setInputUsernameValue(event.target.value); + }} + data-testid="username-input" + /> + {currentUsername !== inputUsernameValue && ( +
+ + {premiumUsername ? : <>} + {!premiumUsername && usernameIsAvailable ? : <>} + +
+ )} +
+
+ +
+
+ {markAsError &&

Username is already taken

} + + {usernameIsAvailable && ( +

+ {usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && ( + <>{t("standard_to_premium_username_description")} + )} +

+ )} + + {(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && ( +
+ +
+ )} + + + +
+ +
+
+
+
+ +
+
+ + {usernameChangeCondition && usernameChangeCondition !== UsernameChangeStatusEnum.NORMAL && ( +

+ {usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && + t("change_username_standard_to_premium")} + {usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && + t("change_username_premium_to_standard")} +

+ )} + +
+
+

+ {t("current")} {t("username")} +

+

+ {currentUsername} +

+
+
+

+ {t("new")} {t("username")} +

+

{inputUsernameValue}

+
+
+
+
+ +
+ {/* redirect to checkout */} + {(usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE || + usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE) && ( + + )} + {/* Normal save */} + {usernameChangeCondition === UsernameChangeStatusEnum.NORMAL && ( + + )} + + + +
+
+
+ + ); +}; + +export { PremiumTextfield }; diff --git a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx new file mode 100644 index 0000000000..fd887e7d5a --- /dev/null +++ b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx @@ -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; + setInputUsernameValue: (value: string) => void; + onSuccessMutation?: () => void; + onErrorMutation?: (error: TRPCClientErrorLike) => 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 ? ( +
+ + +
+ ) : ( + <> + ); + }; + + return ( + <> +
+ +
+
+ + {process.env.NEXT_PUBLIC_WEBSITE_URL}/ + +
+ { + event.preventDefault(); + setInputUsernameValue(event.target.value); + }} + data-testid="username-input" + /> + {currentUsername !== inputUsernameValue && ( +
+ + {usernameIsAvailable ? : <>} + +
+ )} +
+
+ +
+
+ {markAsError &&

Username is already taken

} + + {usernameIsAvailable && currentUsername !== inputUsernameValue && ( +
+ +
+ )} + + + +
+ +
+
+
+
+ +
+
+ + +
+
+

+ {t("current")} {t("username").toLocaleLowerCase()} +

+

+ {currentUsername} +

+
+
+

+ {t("new")} {t("username").toLocaleLowerCase()} +

+

{inputUsernameValue}

+
+
+
+
+ +
+ + + + + +
+
+
+ + ); +}; + +export { UsernameTextfield }; diff --git a/apps/web/components/ui/UsernameAvailability/index.tsx b/apps/web/components/ui/UsernameAvailability/index.tsx new file mode 100644 index 0000000000..ef0b8e1a33 --- /dev/null +++ b/apps/web/components/ui/UsernameAvailability/index.tsx @@ -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; diff --git a/apps/web/lib/core/checkRegularUsername.ts b/apps/web/lib/core/server/checkRegularUsername.ts similarity index 81% rename from apps/web/lib/core/checkRegularUsername.ts rename to apps/web/lib/core/server/checkRegularUsername.ts index 0a574da58f..6fd6dcdb4c 100644 --- a/apps/web/lib/core/checkRegularUsername.ts +++ b/apps/web/lib/core/server/checkRegularUsername.ts @@ -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, }; } diff --git a/apps/web/lib/core/server/checkUsername.ts b/apps/web/lib/core/server/checkUsername.ts new file mode 100644 index 0000000000..579686488c --- /dev/null +++ b/apps/web/lib/core/server/checkUsername.ts @@ -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; diff --git a/apps/web/pages/api/intent-username/index.ts b/apps/web/pages/api/intent-username/index.ts new file mode 100644 index 0000000000..d43cbf874f --- /dev/null +++ b/apps/web/pages/api/intent-username/index.ts @@ -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 }), +}); diff --git a/apps/web/pages/api/username.ts b/apps/web/pages/api/username.ts index e1fd6d910f..57fddb64d0 100644 --- a/apps/web/pages/api/username.ts +++ b/apps/web/pages/api/username.ts @@ -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): Promise { - const result = await checkPremiumUsername(req.body.username); + const result = await checkUsername(req.body.username); return res.status(200).json(result); } diff --git a/apps/web/pages/auth/sso/[provider].tsx b/apps/web/pages/auth/sso/[provider].tsx index 719caf1f3f..9b0fc6fd12 100644 --- a/apps/web/pages/auth/sso/[provider].tsx +++ b/apps/web/pages/auth/sso/[provider].tsx @@ -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, diff --git a/apps/web/pages/getting-started.tsx b/apps/web/pages/getting-started.tsx index c2afd503d3..822f4a0b5d 100644 --- a/apps/web/pages/getting-started.tsx +++ b/apps/web/pages/getting-started.tsx @@ -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({ 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) { diff --git a/apps/web/pages/settings/profile.tsx b/apps/web/pages/settings/profile.tsx index bbaa83c0d9..a5d711c565 100644 --- a/apps/web/pages/settings/profile.tsx +++ b/apps/web/pages/settings/profile.tsx @@ -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; function HideBrandingInput(props: { hideBrandingRef: RefObject; user: Props["user"] }) { + const { user } = props; const { t } = useLocale(); const [modalOpen, setModalOpen] = useState(false); @@ -46,12 +50,12 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject 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 } function SettingsView(props: ComponentProps & { localeProp: string }) { - const utils = trpc.useContext(); + const { user } = props; const { t } = useLocale(); const router = useRouter(); + 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"]); + }; + + const onErrorMutation = (error: TRPCClientErrorLike) => { + setHasErrors(true); + setErrorMessage(error.message); + document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); + }; const mutation = trpc.useMutation("viewer.updateProfile", { - onSuccess: async () => { - showToast(t("your_user_profile_updated_successfully"), "success"); - setHasErrors(false); // dismiss any open errors - await utils.invalidateQueries(["viewer.me"]); - }, - onError: (err) => { - setHasErrors(true); - setErrorMessage(err.message); - document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); - }, + onSuccess: onSuccessMutation, + onError: onErrorMutation, async onSettled() { await utils.invalidateQueries(["viewer.public.i18n"]); }, @@ -95,7 +103,7 @@ function SettingsView(props: ComponentProps & { 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 & { localeProp: str const allowDynamicGroupBookingRef = useRef(null!); const [selectedTheme, setSelectedTheme] = useState(); 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(props.user.timeZone); + const [selectedTimeZone, setSelectedTimeZone] = useState(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(props.user.avatar || ""); + const [imageSrc, setImageSrc] = useState(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,297 +200,305 @@ function SettingsView(props: ComponentProps & { localeProp: str timeFormat: enteredTimeFormat, }); } + const [currentUsername, setCurrentUsername] = useState(user.username || undefined); + const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername); return ( -
- {hasErrors && } -
-
-
-
-
- - {process.env.NEXT_PUBLIC_WEBSITE_URL}/ - - } - ref={usernameRef} - defaultValue={props.user.username || undefined} - /> -
-
- - -
-
-
-
- - -

- {t("change_email_tip")} -

-
-
- -
- -
- -
-
-
-
- - -
- { - avatarRef.current.value = newAvatar; - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value" - )?.set; - nativeInputValueSetter?.call(avatarRef.current, newAvatar); - const ev2 = new Event("input", { bubbles: true }); - avatarRef.current.dispatchEvent(ev2); - updateProfileHandler(ev2 as unknown as FormEvent); - setImageSrc(newAvatar); - }} - imageSrc={imageSrc} + <> +
+
+
+ +
+
+
+ + {hasErrors && } +
+
+
+
+
+ +
-
-
-
- -
- +

+ {t("change_email_tip")} +

+
-
-
- -
- v && setSelectedTimeZone(v)} - className="mt-1 block w-full rounded-sm shadow-sm sm:text-sm" - /> -
-
-
- -
- v && setSelectedWeekStartDay(v)} - className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm" - options={[ - { value: "Sunday", label: nameOfDay(props.localeProp, 0) }, - { value: "Monday", label: nameOfDay(props.localeProp, 1) }, - { value: "Tuesday", label: nameOfDay(props.localeProp, 2) }, - { value: "Wednesday", label: nameOfDay(props.localeProp, 3) }, - { value: "Thursday", label: nameOfDay(props.localeProp, 4) }, - { value: "Friday", label: nameOfDay(props.localeProp, 5) }, - { value: "Saturday", label: nameOfDay(props.localeProp, 6) }, - ]} - /> -
-
-
-
- -
-
-
-
- -
- +
+ { + avatarRef.current.value = newAvatar; + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(avatarRef.current, newAvatar); + const ev2 = new Event("input", { bubbles: true }); + avatarRef.current.dispatchEvent(ev2); + updateProfileHandler(ev2 as unknown as FormEvent); + setImageSrc(newAvatar); + }} + imageSrc={imageSrc} + /> +
+
+
+
+
+ +
+ v && setSelectedTimeFormat(v)} + className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm" + options={timeFormatOptions} + /> +
+
+
+ +
+ setSelectedTheme(e.target.checked ? undefined : themeOptions[0])} - checked={!selectedTheme} + ref={allowDynamicGroupBookingRef} + defaultChecked={props.user.allowDynamicBooking || false} className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 " />
-
-
-
-
-
-
-
-
- +
+ setSelectedTheme(e.target.checked ? undefined : themeOptions[0])} + checked={!selectedTheme} + className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 " + /> +
+
+ +
+
+
+
+
+ -

{t("disable_cal_branding_description")}

+ +
+
+ +
-
-

{t("danger_zone")}

-
-
- - - - - - {t("confirm_delete_account")} +
+
+
+ +
+
+ +

{t("disable_cal_branding_description")}

+
+
+
+

{t("danger_zone")}

+
+
+ + + - } - onConfirm={() => deleteAccount()}> - {t("delete_account_confirmation_message")} - - + + + {t("confirm_delete_account")} + + } + onConfirm={() => deleteAccount()}> + {t("delete_account_confirmation_message")} + +
+
+
+
+ +
-
-
- -
-
- + + ); } diff --git a/apps/web/playwright/change-username.test.ts b/apps/web/playwright/change-username.test.ts new file mode 100644 index 0000000000..02a251b34e --- /dev/null +++ b/apps/web/playwright/change-username.test.ts @@ -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/); + }); +}); diff --git a/apps/web/playwright/integrations-stripe.test.ts b/apps/web/playwright/integrations-stripe.test.ts index 4bee0cc980..d6558170fd 100644 --- a/apps/web/playwright/integrations-stripe.test.ts +++ b/apps/web/playwright/integrations-stripe.test.ts @@ -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"); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 46fbd44772..9e603c75e6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -184,9 +184,9 @@ "getting_started": "Getting Started", "15min_meeting": "15 Min Meeting", "30min_meeting": "30 Min Meeting", - "secret":"Secret", - "leave_blank_to_remove_secret":"Leave blank to remove secret", - "webhook_secret_key_description":"Ensure your server is only receiving the expected Cal.com requests for security reasons", + "secret": "Secret", + "leave_blank_to_remove_secret": "Leave blank to remove secret", + "webhook_secret_key_description": "Ensure your server is only receiving the expected Cal.com requests for security reasons", "secret_meeting": "Secret Meeting", "login_instead": "Login instead", "already_have_an_account": "Already have an account?", @@ -398,7 +398,7 @@ "change_password": "Change Password", "change_secret": "Change Secret", "new_password_matches_old_password": "New password matches your old password. Please choose a different password.", - "forgotten_secret_description":"If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated", + "forgotten_secret_description": "If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated", "current_incorrect_password": "Current password is incorrect", "incorrect_password": "Password is incorrect.", "1_on_1": "1-on-1", @@ -896,23 +896,32 @@ "event_location": "Event's location", "reschedule_optional": "Reason for rescheduling (optional)", "reschedule_placeholder": "Let others know why you need to reschedule", - "event_cancelled":"This event is cancelled", + "event_cancelled": "This event is cancelled", "emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.", "this_input_will_shown_booking_this_event": "This input will be shown when booking this event", "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", "are_you_sure_you_want_to_remove_this_app": "Are you sure you want to remove this app?", "web_conference": "Web conference", "requires_confirmation": "Requires confirmation", - "set_whereby_link":"Set Whereby link", + "set_whereby_link": "Set Whereby link", "invalid_whereby_link": "Please enter a valid Whereby Link", - "set_around_link":"Set Around.Co link", + "set_around_link": "Set Around.Co link", "invalid_around_link": "Please enter a valid Around Link", - "set_riverside_link":"Set Riverside link", + "set_riverside_link": "Set Riverside link", "invalid_riverside_link": "Please enter a valid Riverside Link", "add_exchange2013": "Connect Exchange 2013 Server", "add_exchange2016": "Connect Exchange 2016 Server", diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index 035f329c78..1a7a875451 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -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 }); } } diff --git a/packages/app-store/stripepayment/api/index.ts b/packages/app-store/stripepayment/api/index.ts index 11890c921c..796adc6f9e 100644 --- a/packages/app-store/stripepayment/api/index.ts +++ b/packages/app-store/stripepayment/api/index.ts @@ -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"; diff --git a/packages/app-store/stripepayment/api/subscription.ts b/packages/app-store/stripepayment/api/subscription.ts new file mode 100644 index 0000000000..2b770cc913 --- /dev/null +++ b/packages/app-store/stripepayment/api/subscription.ts @@ -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(); + } +} diff --git a/packages/app-store/vital/api/settings.ts b/packages/app-store/vital/api/settings.ts index 4e489a6c5b..86da2d2759 100644 --- a/packages/app-store/vital/api/settings.ts +++ b/packages/app-store/vital/api/settings.ts @@ -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, }, diff --git a/packages/ee/lib/core/checkPremiumUsername.ts b/packages/ee/lib/core/checkPremiumUsername.ts index 445e790f3d..cf32fd8b81 100644 --- a/packages/ee/lib/core/checkPremiumUsername.ts +++ b/packages/ee/lib/core/checkPremiumUsername.ts @@ -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 { const username = slugify(_username); diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 395208f46f..4a9d546193 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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; diff --git a/packages/lib/fetchUsername.ts b/packages/lib/fetchUsername.ts new file mode 100644 index 0000000000..e33aedad46 --- /dev/null +++ b/packages/lib/fetchUsername.ts @@ -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 }; +} diff --git a/packages/lib/hasKeyInMetadata.ts b/packages/lib/hasKeyInMetadata.ts new file mode 100644 index 0000000000..06155c2c8c --- /dev/null +++ b/packages/lib/hasKeyInMetadata.ts @@ -0,0 +1,9 @@ +import isPrismaObj from "./isPrismaObj"; + +const hasKeyInMetadata = ( + 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; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 7b25c2bfe5..c44bc5fdf2 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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(); diff --git a/packages/stripe/constants.ts b/packages/stripe/constants.ts index fe9bec7379..8d48d7f9e2 100644 --- a/packages/stripe/constants.ts +++ b/packages/stripe/constants.ts @@ -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 || ""; diff --git a/packages/stripe/subscriptions.ts b/packages/stripe/subscriptions.ts new file mode 100644 index 0000000000..4d5a88e5c3 --- /dev/null +++ b/packages/stripe/subscriptions.ts @@ -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 { + 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, + }; +} diff --git a/packages/stripe/utils.ts b/packages/stripe/utils.ts index 5d0fac13ad..fdcf3b4faa 100644 --- a/packages/stripe/utils.ts +++ b/packages/stripe/utils.ts @@ -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; +} diff --git a/turbo.json b/turbo.json index a39dcf5b3e..ec53759d16 100644 --- a/turbo.json +++ b/turbo.json @@ -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" diff --git a/yarn.lock b/yarn.lock index 154d36293a..482fa71310 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17843,4 +17843,4 @@ zwitch@^1.0.0: zwitch@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1" - integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== + integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== \ No newline at end of file