From 4856ed99775d7fcbcb10beef7c9871a7ae1b000c Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 8 Sep 2022 06:08:37 +0530 Subject: [PATCH] Signup Flow improvements (#4012) * Get login working * Update website * Fixes * Save * svae * Save * Change translation key * Various fixes after testing * Update website * Add TS Tests * Upate website * Fix tests * Fix linting and other issues * Fix linting and other issues * Fix bugs found during recording of demos * Revert default coookie change * Self review fixe * Link fixes * Removed inline styles, cleanup * Various fixes * Added new envs to e2e Co-authored-by: Peer Richelsen Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars --- .env.example | 2 + .github/workflows/e2e-embed.yml | 2 + .github/workflows/e2e.yml | 2 + .../steps-views/UserSettings.tsx | 6 + .../UsernameAvailability/PremiumTextfield.tsx | 271 ++++++++++-------- apps/web/pages/auth/error.tsx | 15 +- apps/web/pages/auth/verify.tsx | 114 +++++--- apps/web/pages/event-types/[type].tsx | 1 - apps/web/playwright/change-password.e2e.ts | 1 - apps/web/playwright/change-username.e2e.ts | 6 +- apps/web/public/static/locales/ar/common.json | 2 +- apps/web/public/static/locales/cs/common.json | 2 +- apps/web/public/static/locales/de/common.json | 2 +- apps/web/public/static/locales/en/common.json | 5 +- apps/web/public/static/locales/es/common.json | 2 +- apps/web/public/static/locales/fr/common.json | 2 +- apps/web/public/static/locales/he/common.json | 2 +- apps/web/public/static/locales/it/common.json | 2 +- apps/web/public/static/locales/ja/common.json | 2 +- apps/web/public/static/locales/ko/common.json | 2 +- apps/web/public/static/locales/nl/common.json | 2 +- apps/web/public/static/locales/pl/common.json | 2 +- .../public/static/locales/pt-BR/common.json | 2 +- apps/web/public/static/locales/pt/common.json | 2 +- apps/web/public/static/locales/ro/common.json | 2 +- apps/web/public/static/locales/ru/common.json | 2 +- apps/web/public/static/locales/sr/common.json | 2 +- apps/web/public/static/locales/sv/common.json | 2 +- apps/web/public/static/locales/tr/common.json | 2 +- apps/web/public/static/locales/uk/common.json | 2 +- apps/web/public/static/locales/vi/common.json | 2 +- .../public/static/locales/zh-CN/common.json | 2 +- .../public/static/locales/zh-TW/common.json | 2 +- apps/website | 2 +- packages/app-store/stripepayment/api/index.ts | 2 + .../stripepayment/api/paymentCallback.ts | 51 ++++ .../stripepayment/api/subscription.ts | 85 +++--- .../app-store/stripepayment/lib/constants.ts | 2 + .../lib/getCustomerAndCheckoutSession.ts | 24 ++ .../app-store/stripepayment/lib/server.ts | 1 - packages/app-store/stripepayment/lib/utils.ts | 16 +- packages/lib/sync/services/CloseComService.ts | 1 - packages/prisma/zod-utils.ts | 1 + packages/trpc/server/routers/viewer.tsx | 130 +++++++++ packages/ui/Icon.tsx | 2 +- packages/ui/v2/core/skeleton/index.tsx | 2 +- turbo.json | 4 + 47 files changed, 547 insertions(+), 245 deletions(-) create mode 100644 packages/app-store/stripepayment/api/paymentCallback.ts create mode 100644 packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts diff --git a/.env.example b/.env.example index 27fdb5b419..84f91a5e7f 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,8 @@ NEXT_PUBLIC_IS_E2E= # Used for internal billing system NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE= NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE= +NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0 +NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE= NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE= STRIPE_WEBHOOK_SECRET= STRIPE_PRO_PLAN_PRODUCT_ID= diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 0617591190..83ed8ae5fd 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -25,6 +25,8 @@ jobs: NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }} + NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }} + NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1 STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 48a2b1fa8a..61d0321534 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,6 +26,8 @@ jobs: NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }} + NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }} + NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1 STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} diff --git a/apps/web/components/getting-started/steps-views/UserSettings.tsx b/apps/web/components/getting-started/steps-views/UserSettings.tsx index ecdec06eb8..214060fa99 100644 --- a/apps/web/components/getting-started/steps-views/UserSettings.tsx +++ b/apps/web/components/getting-started/steps-views/UserSettings.tsx @@ -41,7 +41,12 @@ const UserSettings = (props: IUserSettingsProps) => { const mutation = trpc.useMutation("viewer.updateProfile", { onSuccess: onSuccess, }); + const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]); + const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false; const onSubmit = handleSubmit((data) => { + if (paymentRequired) { + return; + } mutation.mutate({ name: data.name, timeZone: selectedTimeZone, @@ -56,6 +61,7 @@ const UserSettings = (props: IUserSettingsProps) => {
{/* Username textfield */} ; + readonly?: boolean; } +const obtainNewUsernameChangeCondition = ({ + userIsPremium, + isNewUsernamePremium, + stripeCustomer, +}: { + userIsPremium: boolean; + isNewUsernamePremium: boolean; + stripeCustomer: inferQueryOutput<"viewer.stripeCustomer"> | undefined; +}) => { + if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) { + return UsernameChangeStatusEnum.UPGRADE; + } + if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") { + return UsernameChangeStatusEnum.DOWNGRADE; + } + return UsernameChangeStatusEnum.NORMAL; +}; + +const useIsUsernamePremium = (username: string) => { + const [isCurrentUsernamePremium, setIsCurrentUsernamePremium] = useState(false); + useEffect(() => { + (async () => { + if (!username) return; + const { data } = await fetchUsername(username); + setIsCurrentUsernamePremium(data.premium); + })(); + }, [username]); + return isCurrentUsernamePremium; +}; + const PremiumTextfield = (props: ICustomUsernameProps) => { const { t } = useLocale(); const { @@ -58,73 +90,38 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { usernameRef, onSuccessMutation, onErrorMutation, - user, + readonly: disabled, } = props; const [usernameIsAvailable, setUsernameIsAvailable] = useState(false); const [markAsError, setMarkAsError] = useState(false); + const router = useRouter(); + const { paymentStatus: recentAttemptPaymentStatus } = router.query; const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false); - const [usernameChangeCondition, setUsernameChangeCondition] = useState( - null - ); - - const userIsPremium = - user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false; - const [premiumUsername, setPremiumUsername] = useState(false); + const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]); + const isCurrentUsernamePremium = useIsUsernamePremium(currentUsername || ""); + const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false); const debouncedApiCall = useCallback( debounce(async (username) => { const { data } = await fetchUsername(username); - setMarkAsError(!data.available); - setPremiumUsername(data.premium); + setMarkAsError(!data.available && username !== currentUsername); + setIsInputUsernamePremium(data.premium); setUsernameIsAvailable(data.available); }, 150), [] ); useEffect(() => { - if (currentUsername !== inputUsernameValue) { - debouncedApiCall(inputUsernameValue); - } else if (inputUsernameValue === "") { - setMarkAsError(false); - setPremiumUsername(false); - setUsernameIsAvailable(false); - } else { - setPremiumUsername(userIsPremium); - setUsernameIsAvailable(false); - } - }, [inputUsernameValue]); + // Use the current username or if it's not set, use the one available from stripe + setInputUsernameValue(currentUsername || stripeCustomer?.username || ""); + }, [setInputUsernameValue, currentUsername, stripeCustomer?.username]); useEffect(() => { - if (usernameIsAvailable || premiumUsername) { - const condition = obtainNewUsernameChangeCondition({ - userIsPremium, - isNewUsernamePremium: premiumUsername, - }); - - setUsernameChangeCondition(condition); - } - }, [usernameIsAvailable, premiumUsername]); - - const obtainNewUsernameChangeCondition = ({ - userIsPremium, - isNewUsernamePremium, - }: { - userIsPremium: boolean; - isNewUsernamePremium: boolean; - }) => { - let resultCondition: UsernameChangeStatusEnum; - if (!userIsPremium && isNewUsernamePremium) { - resultCondition = UsernameChangeStatusEnum.UPGRADE; - } else if (userIsPremium && !isNewUsernamePremium) { - resultCondition = UsernameChangeStatusEnum.DOWNGRADE; - } else { - resultCondition = UsernameChangeStatusEnum.NORMAL; - } - return resultCondition; - }; + if (!inputUsernameValue) return; + debouncedApiCall(inputUsernameValue); + }, [debouncedApiCall, inputUsernameValue]); const utils = trpc.useContext(); - const updateUsername = trpc.useMutation("viewer.updateProfile", { onSuccess: async () => { onSuccessMutation && (await onSuccessMutation()); @@ -139,34 +136,62 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { }, }); - const ActionButtons = (props: { index: string }) => { - const { index } = props; - return (usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue ? ( -
- - -
- ) : ( - <> - ); + // when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for + const paymentRequired = !currentUsername && stripeCustomer?.isPremium && !stripeCustomer?.paidForPremium; + + const usernameChangeCondition = obtainNewUsernameChangeCondition({ + userIsPremium: isCurrentUsernamePremium, + isNewUsernamePremium: isInputUsernamePremium, + stripeCustomer, + }); + + const usernameFromStripe = stripeCustomer?.username; + + const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${ + inputUsernameValue || usernameFromStripe + }&action=${usernameChangeCondition}&callbackUrl=${router.asPath}`; + + const ActionButtons = () => { + if (paymentRequired) { + return ( +
+ +
+ ); + } + if ((usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue) { + return ( +
+ + +
+ ); + } + return <>; }; const saveUsername = () => { @@ -179,79 +204,89 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { return (
-
+
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/ -
+ +
{ event.preventDefault(); setInputUsernameValue(event.target.value); }} data-testid="username-input" /> - {currentUsername !== inputUsernameValue && ( -
- - {premiumUsername ? : <>} - {!premiumUsername && usernameIsAvailable ? : <>} - -
- )} -
-
- +
+ + {isInputUsernamePremium ? : <>} + {!isInputUsernamePremium && usernameIsAvailable ? ( + + ) : ( + <> + )} + +
+ + {(usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue && ( +
+ +
+ )}
+ {paymentRequired ? ( + recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid" ? ( + + Your payment could not be completed. Your username is still not reserved + + ) : ( + + You need to reserve your premium username for {getPremiumPlanPriceValue()} + + ) + ) : null} {markAsError &&

Username is already taken

} {usernameIsAvailable && (

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

)} - {(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && ( -
- -
- )} -
+
@@ -291,7 +326,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { type="button" loading={updateUsername.isLoading} data-testid="go-to-billing" - href={`/api/integrations/stripepayment/subscription?intentUsername=${inputUsernameValue}`}> + href={paymentLink}> <> {t("go_to_stripe_billing")} diff --git a/apps/web/pages/auth/error.tsx b/apps/web/pages/auth/error.tsx index bfb363e919..ab4782c86f 100644 --- a/apps/web/pages/auth/error.tsx +++ b/apps/web/pages/auth/error.tsx @@ -1,19 +1,30 @@ import { GetStaticPropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; +import z from "zod"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; import { Icon } from "@calcom/ui/Icon"; +import { SkeletonText } from "@calcom/ui/v2"; import AuthContainer from "@components/ui/AuthContainer"; import { ssgInit } from "@server/lib/ssg"; +const querySchema = z.object({ + error: z.string().optional(), +}); + export default function Error() { const { t } = useLocale(); const router = useRouter(); - const { error } = router.query; + const { error } = querySchema.parse(router.query); + const isTokenVerificationError = error?.toLowerCase() === "verification"; + let errorMsg = ; + if (router.isReady) { + errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login"); + } return ( @@ -26,7 +37,7 @@ export default function Error() { {error}
-

{t("error_during_login")}

+

{errorMsg}

diff --git a/apps/web/pages/auth/verify.tsx b/apps/web/pages/auth/verify.tsx index 69056e0e5e..586fb5f731 100644 --- a/apps/web/pages/auth/verify.tsx +++ b/apps/web/pages/auth/verify.tsx @@ -1,12 +1,17 @@ -import { CheckIcon, ExclamationIcon, MailOpenIcon } from "@heroicons/react/outline"; -import { getSession, signIn } from "next-auth/react"; +import { CheckIcon, MailOpenIcon, ExclamationIcon } from "@heroicons/react/outline"; +import { signIn } from "next-auth/react"; import Head from "next/head"; import { useRouter } from "next/router"; -import React, { useEffect, useRef, useState } from "react"; +import * as React from "react"; +import { useEffect, useState, useRef } from "react"; +import z from "zod"; import { WEBAPP_URL } from "@calcom/lib/constants"; import showToast from "@calcom/lib/notification"; -import Button from "@calcom/ui/Button"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/v2/"; + +import Loader from "@components/Loader"; async function sendVerificationLogin(email: string, username: string) { await signIn("email", { @@ -23,25 +28,43 @@ async function sendVerificationLogin(email: string, username: string) { }); } -function useSendFirstVerificationLogin() { - const router = useRouter(); - const { email, username } = router.query; +function useSendFirstVerificationLogin({ + email, + username, +}: { + email: string | undefined; + username: string | undefined; +}) { const sent = useRef(false); useEffect(() => { - if (router.isReady && !sent.current) { - (async () => { - await sendVerificationLogin(`${email}`, `${username}`); - sent.current = true; - })(); + if (!email || !username || sent.current) { + return; } - }, [email, router.isReady, username]); + (async () => { + await sendVerificationLogin(email, username); + sent.current = true; + })(); + }, [email, username]); } +const querySchema = z.object({ + stripeCustomerId: z.string().optional(), + sessionId: z.string().optional(), + t: z.string().optional(), +}); + export default function Verify() { const router = useRouter(); - const { email, username, t, session_id, cancel } = router.query; + const { t, sessionId, stripeCustomerId } = querySchema.parse(router.query); const [secondsLeft, setSecondsLeft] = useState(30); - + const { data } = trpc.useQuery([ + "viewer.public.stripeCheckoutSession", + { + stripeCustomerId, + checkoutSessionId: sessionId, + }, + ]); + useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username }); // @note: check for t=timestamp and apply disabled state and secondsLeft accordingly // to avoid refresh to skip waiting 30 seconds to re-send email useEffect(() => { @@ -68,35 +91,28 @@ export default function Verify() { } }, [secondsLeft]); - // @note: check for session, redirect to webapp if session found - useEffect(() => { - let intervalId: NodeJS.Timer, redirecting: boolean; - // eslint-disable-next-line prefer-const - intervalId = setInterval(async () => { - const session = await getSession(); - if (session && !redirecting) { - // User connected using the magic link -> redirect him/her - redirecting = true; - // @note: redirect to webapp /getting-started, user will end up with two tabs open with the onboarding 'getting-started' wizard. - router.push(WEBAPP_URL + "/getting-started"); - } - }, 1000); - return () => { - intervalId && clearInterval(intervalId); - }; - }, [router]); + if (!router.isReady || !data) { + // Loading state + return ; + } + const { valid, hasPaymentFailed, customer } = data; + if (!valid) { + throw new Error("Invalid session or customer id"); + } - useSendFirstVerificationLogin(); + if (!stripeCustomerId && !sessionId) { + return
Invalid Link
; + } return ( -
+
{/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth it or too hard to read. */} - {cancel + {hasPaymentFailed ? "Your payment failed" - : session_id + : sessionId ? "Payment successful!" : "Verify your email" + " | Cal.com"} @@ -104,34 +120,41 @@ export default function Verify() {
- {cancel ? ( + {hasPaymentFailed ? ( - ) : session_id ? ( + ) : sessionId ? ( ) : ( )}

- {cancel ? "Your payment failed" : session_id ? "Payment successful!" : "Check your Inbox"} + {hasPaymentFailed + ? "Your payment failed" + : sessionId + ? "Payment successful!" + : "Check your Inbox"}

- {cancel && ( + {hasPaymentFailed && (

Your account has been created, but your premium has not been reserved.

)}

- We have sent an email to {email} with a link to activate your account.{" "} - {cancel && + We have sent an email to {customer?.email} with a link to activate your account.{" "} + {hasPaymentFailed && "Once you activate your account you will be able to try purchase your premium username again or select a different one."}

Don't see an email? Click the button below to send another email.

-
+
diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index c746896971..29de73af56 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -245,7 +245,6 @@ const EventTypePage = (props: inferSSRProps) => { hasGiphyIntegration, hasRainbowIntegration, } = props; - const router = useRouter(); const updateMutation = trpc.useMutation("viewer.eventTypes.update", { diff --git a/apps/web/playwright/change-password.e2e.ts b/apps/web/playwright/change-password.e2e.ts index f240ae1e6f..2f7a2665b1 100644 --- a/apps/web/playwright/change-password.e2e.ts +++ b/apps/web/playwright/change-password.e2e.ts @@ -10,7 +10,6 @@ test.describe("Change Password Test", () => { await pro.login(); // Go to http://localhost:3000/settings/security await page.goto("/settings/security"); - if (!pro.username) throw Error("Test user doesn't have a username"); // Fill form diff --git a/apps/web/playwright/change-username.e2e.ts b/apps/web/playwright/change-username.e2e.ts index f16df172e8..8118691b35 100644 --- a/apps/web/playwright/change-username.e2e.ts +++ b/apps/web/playwright/change-username.e2e.ts @@ -37,7 +37,7 @@ test.describe("Change username on settings", () => { await usernameInput.fill("demousernamex"); // Click on save button - await page.click("[data-testid=update-username-btn-desktop]"); + await page.click("[data-testid=update-username-btn]"); await Promise.all([ page.waitForResponse("**/viewer.updateProfile*"), @@ -84,7 +84,7 @@ test.describe("Change username on settings", () => { await usernameInput.fill(`xx${testInfo.workerIndex}`); // Click on save button - await page.click("[data-testid=update-username-btn-desktop]"); + await page.click("[data-testid=update-username-btn]"); // Validate modal text fields const currentUsernameText = page.locator("[data-testid=current-username]").innerText(); @@ -130,7 +130,7 @@ test.describe("Change username on settings", () => { await usernameInput.fill(`xx${testInfo.workerIndex}`); // Click on save button - await page.click("[data-testid=update-username-btn-desktop]"); + await page.click("[data-testid=update-username-btn]"); // Validate modal text fields const currentUsernameText = page.locator("[data-testid=current-username]").innerText(); diff --git a/apps/web/public/static/locales/ar/common.json b/apps/web/public/static/locales/ar/common.json index 5269162b22..5c0a723860 100644 --- a/apps/web/public/static/locales/ar/common.json +++ b/apps/web/public/static/locales/ar/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "يُمكّنك سير العمل من أتمتة إرسال التذكير والإشعارات.", "active_on": "إجراء في", "workflow_updated_successfully": "تم تحديث سير العمل {{workflowName}} بنجاح", - "standard_to_premium_username_description": "هذا اسم مستخدم قياسي، سوف ينقلك هذا التحديث إلى صفحة الفوترة لخفض المستوى.", + "premium_to_standard_username_description": "هذا اسم مستخدم قياسي، سوف ينقلك هذا التحديث إلى صفحة الفوترة لخفض المستوى.", "current": "الحالي", "premium": "مميز", "standard": "قياسي", diff --git a/apps/web/public/static/locales/cs/common.json b/apps/web/public/static/locales/cs/common.json index 9c6c275c7c..c437f3eba0 100644 --- a/apps/web/public/static/locales/cs/common.json +++ b/apps/web/public/static/locales/cs/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Pracovní postupy umožňují automatizovat zasílání upomínek a oznámení.", "active_on": "Aktivní pro:", "workflow_updated_successfully": "Pracovní postup {{workflowName}} byl aktualizován", - "standard_to_premium_username_description": "Toto je standardní uživatelské jméno a při aktualizaci přejdete k fakturaci, kde provedete snížení úrovně.", + "premium_to_standard_username_description": "Toto je standardní uživatelské jméno a při aktualizaci přejdete k fakturaci, kde provedete snížení úrovně.", "current": "Aktuální", "premium": "prémiové", "standard": "standardní", diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index e138ce0875..064b73e7ec 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Workflows ermöglichen Ihnen das automatisierte Versenden von Erinnerungen und Benachrichtigungen.", "active_on": "Aktiv am", "workflow_updated_successfully": "{{workflowName}} Workflow erfolgreich aktualisiert", - "standard_to_premium_username_description": "Dies ist ein Standard-Benutzername und die Aktualisierung führt Sie zur Rechnungsstellung, um ein Downgrade durchzuführen.", + "premium_to_standard_username_description": "Dies ist ein Standard-Benutzername und die Aktualisierung führt Sie zur Rechnungsstellung, um ein Downgrade durchzuführen.", "current": "Aktuell", "premium": "Premium", "standard": "Standard", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 76fd55499e..e3bcdac62b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -977,7 +977,7 @@ "new_workflow_description": "Workflows enable you to automate sending reminders and notifications.", "active_on": "Active on", "workflow_updated_successfully": "{{workflowName}} workflow updated successfully", - "standard_to_premium_username_description": "This is a standard username and updating will take you to billing to downgrade.", + "premium_to_standard_username_description": "This is a standard username and updating will take you to billing to downgrade.", "current": "Current", "premium": "premium", "standard": "standard", @@ -1180,5 +1180,6 @@ "edit_form_later_subtitle": "You’ll be able to edit this later.", "connect_calendar_later": "I'll connect my calendar later", "set_my_availability_later": "I'll set my availability later", - "problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support." + "problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support.", + "token_invalid_expired": "Token is either invalid or expired." } diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index 08ba2f16a7..0372d85537 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Los flujos de trabajo le permiten automatizar el envío de recordatorios y notificaciones.", "active_on": "Activo en", "workflow_updated_successfully": "Flujo de trabajo {{workflowName}} actualizado correctamente", - "standard_to_premium_username_description": "Este es un nombre de usuario estándar y la actualización te llevará a la facturación para bajar de categoría.", + "premium_to_standard_username_description": "Este es un nombre de usuario estándar y la actualización te llevará a la facturación para bajar de categoría.", "current": "Actual", "premium": "premium", "standard": "estándar", diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 7defb2de78..b9cd80e556 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Les workflows vous permettent d'automatiser l'envoi de rappels et de notifications.", "active_on": "Actif le", "workflow_updated_successfully": "Workflow {{workflowName}} mis à jour avec succès", - "standard_to_premium_username_description": "Ceci est un nom d'utilisateur standard et la mise à jour vous mènera à la facturation à downgrader.", + "premium_to_standard_username_description": "Ceci est un nom d'utilisateur standard et la mise à jour vous mènera à la facturation à downgrader.", "current": "Actuel", "premium": "premium", "standard": "standard", diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index ae03ba3552..982e948ec8 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "תהליכי עבודה מאפשרים להפוך את פעולת השליחה של תזכורות ועדכונים לאוטומטית.", "active_on": "פעיל ב", "workflow_updated_successfully": "עדכון תהליך העבודה {{workflowName}} בוצע בהצלחה", - "standard_to_premium_username_description": "זהו שם משתמש סטנדרטי ועדכון שלו יגרום להעברה שלך אל דף החיוב לצורך שנמוך.", + "premium_to_standard_username_description": "זהו שם משתמש סטנדרטי ועדכון שלו יגרום להעברה שלך אל דף החיוב לצורך שנמוך.", "current": "נוכחי", "premium": "פרימיום", "standard": "רגיל", diff --git a/apps/web/public/static/locales/it/common.json b/apps/web/public/static/locales/it/common.json index 17f31878c2..36eafd8da0 100644 --- a/apps/web/public/static/locales/it/common.json +++ b/apps/web/public/static/locales/it/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "I flussi di lavoro consentono di automatizzare l'invio di promemoria e notifiche.", "active_on": "Data attivazione", "workflow_updated_successfully": "Flusso di lavoro {{workflowName}} aggiornato correttamente", - "standard_to_premium_username_description": "Questo è un nome utente standard e l'aggiornamento ti porterà alla fatturazione per il downgrade.", + "premium_to_standard_username_description": "Questo è un nome utente standard e l'aggiornamento ti porterà alla fatturazione per il downgrade.", "current": "Corrente", "premium": "premium", "standard": "standard", diff --git a/apps/web/public/static/locales/ja/common.json b/apps/web/public/static/locales/ja/common.json index fdd5ea8dcc..5607d908be 100644 --- a/apps/web/public/static/locales/ja/common.json +++ b/apps/web/public/static/locales/ja/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "ワークフローにより、リマインダーと通知の送信を自動化できます。", "active_on": "有効日時", "workflow_updated_successfully": "{{workflowName}} が正常に更新されました", - "standard_to_premium_username_description": "これはスタンダードユーザー名で、更新するとダウングレードするための請求画面に移動します。", + "premium_to_standard_username_description": "これはスタンダードユーザー名で、更新するとダウングレードするための請求画面に移動します。", "current": "現在", "premium": "プレミアム", "standard": "スタンダード", diff --git a/apps/web/public/static/locales/ko/common.json b/apps/web/public/static/locales/ko/common.json index bf4ff6cb1d..c8d5f84f92 100644 --- a/apps/web/public/static/locales/ko/common.json +++ b/apps/web/public/static/locales/ko/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "워크플로를 사용하면 미리 알림 및 알림 보내기를 자동화할 수 있습니다.", "active_on": "유효일", "workflow_updated_successfully": "{{workflowName}} 워크플로 업데이트 완료", - "standard_to_premium_username_description": "이것은 표준 사용자 이름이며 업데이트하면 다운그레이드를 위한 청구로 이동합니다.", + "premium_to_standard_username_description": "이것은 표준 사용자 이름이며 업데이트하면 다운그레이드를 위한 청구로 이동합니다.", "current": "기존", "premium": "프리미엄", "standard": "표준", diff --git a/apps/web/public/static/locales/nl/common.json b/apps/web/public/static/locales/nl/common.json index acf8eb6628..bcd22f80b0 100644 --- a/apps/web/public/static/locales/nl/common.json +++ b/apps/web/public/static/locales/nl/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Met werkstromen kunt u het verzenden van herinneringen en meldingen automatiseren.", "active_on": "Actief op", "workflow_updated_successfully": "Werkstroom {{workflowName}} bijgewerkt", - "standard_to_premium_username_description": "Dit is een standaard gebruikersnaam, door bij te werken ga je naar facturatie om te downgraden.", + "premium_to_standard_username_description": "Dit is een standaard gebruikersnaam, door bij te werken ga je naar facturatie om te downgraden.", "current": "Huidig", "premium": "premium", "standard": "standaard", diff --git a/apps/web/public/static/locales/pl/common.json b/apps/web/public/static/locales/pl/common.json index bc4863fbfe..e4836d36a6 100644 --- a/apps/web/public/static/locales/pl/common.json +++ b/apps/web/public/static/locales/pl/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Przepływy pracy umożliwiają automatyzację wysyłania przypomnień i powiadomień.", "active_on": "Aktywny dnia", "workflow_updated_successfully": "Pomyślnie zaktualizowano przepływ pracy {{workflowName}}", - "standard_to_premium_username_description": "To standardowa nazwa użytkownika i jej aktualizacja spowoduje przejście do płatności za zmianę wersji.", + "premium_to_standard_username_description": "To standardowa nazwa użytkownika i jej aktualizacja spowoduje przejście do płatności za zmianę wersji.", "current": "Bieżąca", "premium": "Premium", "standard": "Standardowa", diff --git a/apps/web/public/static/locales/pt-BR/common.json b/apps/web/public/static/locales/pt-BR/common.json index bf373b4953..46b20d2afc 100644 --- a/apps/web/public/static/locales/pt-BR/common.json +++ b/apps/web/public/static/locales/pt-BR/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Fluxos de trabalho permitem que você automatize o envio de lembretes e notificações.", "active_on": "Ativar em", "workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} atualizado com sucesso", - "standard_to_premium_username_description": "Este é um nome de usuário padrão. Para atualizá-lo, levaremos você para fazer downgrade na cobrança.", + "premium_to_standard_username_description": "Este é um nome de usuário padrão. Para atualizá-lo, levaremos você para fazer downgrade na cobrança.", "current": "Atual", "premium": "premium", "standard": "padrão", diff --git a/apps/web/public/static/locales/pt/common.json b/apps/web/public/static/locales/pt/common.json index 3fdf8f26c7..8677970c4a 100644 --- a/apps/web/public/static/locales/pt/common.json +++ b/apps/web/public/static/locales/pt/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Os fluxos de trabalho permitem automatizar o envio de lembretes e notificações.", "active_on": "Activo em", "workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} actualizado com sucesso", - "standard_to_premium_username_description": "Este é um nome de utilizador padrão, e ao atualizar irá para a faturação para fazer o downgrade.", + "premium_to_standard_username_description": "Este é um nome de utilizador padrão, e ao atualizar irá para a faturação para fazer o downgrade.", "current": "Atual", "premium": "premium", "standard": "padrão", diff --git a/apps/web/public/static/locales/ro/common.json b/apps/web/public/static/locales/ro/common.json index b0b0c6deb3..dcb65fd75c 100644 --- a/apps/web/public/static/locales/ro/common.json +++ b/apps/web/public/static/locales/ro/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Fluxurile de lucru vă permit să automatizați trimiterea mementourilor și a notificărilor.", "active_on": "Activ pe", "workflow_updated_successfully": "Fluxul de lucru {{workflowName}} a fost actualizat cu succes", - "standard_to_premium_username_description": "Acesta este un nume de utilizator standard, iar actualizarea vă va duce la facturare pentru retrogradare.", + "premium_to_standard_username_description": "Acesta este un nume de utilizator standard, iar actualizarea vă va duce la facturare pentru retrogradare.", "current": "Actual", "premium": "premium", "standard": "standard", diff --git a/apps/web/public/static/locales/ru/common.json b/apps/web/public/static/locales/ru/common.json index 3a2bf2cfc5..5beb3a9349 100644 --- a/apps/web/public/static/locales/ru/common.json +++ b/apps/web/public/static/locales/ru/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Рабочие процессы позволяют автоматизировать отправку напоминаний и уведомлений.", "active_on": "Активен с", "workflow_updated_successfully": "Рабочий процесс {{workflowName}} обновлен", - "standard_to_premium_username_description": "Это стандартное имя пользователя. В случае изменения вы будете перенаправлены на страницу оплаты для смены тарифа.", + "premium_to_standard_username_description": "Это стандартное имя пользователя. В случае изменения вы будете перенаправлены на страницу оплаты для смены тарифа.", "current": "Текущее", "premium": "премиум", "standard": "стандартное", diff --git a/apps/web/public/static/locales/sr/common.json b/apps/web/public/static/locales/sr/common.json index cfd24d3c82..a80a58ad4c 100644 --- a/apps/web/public/static/locales/sr/common.json +++ b/apps/web/public/static/locales/sr/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Radni tokovi vam omogućavaju da automatizujete slanje podsetnika i notifikacija.", "active_on": "Aktivan uključen", "workflow_updated_successfully": "Radni tok {{workflowName}} je uspešno ažuriran", - "standard_to_premium_username_description": "Ovo je standardno korisničko ime i ažuriranje će vas poslati na naplatu da biste prešli na nižu verziju.", + "premium_to_standard_username_description": "Ovo je standardno korisničko ime i ažuriranje će vas poslati na naplatu da biste prešli na nižu verziju.", "current": "Trenutno", "premium": "premijum", "standard": "standardno", diff --git a/apps/web/public/static/locales/sv/common.json b/apps/web/public/static/locales/sv/common.json index 25025a14f3..a790234c8e 100644 --- a/apps/web/public/static/locales/sv/common.json +++ b/apps/web/public/static/locales/sv/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Arbetsflöden gör att du kan automatisera att skicka påminnelser och aviseringar.", "active_on": "Aktiv på", "workflow_updated_successfully": "{{workflowName}} uppdaterades framgångsrikt", - "standard_to_premium_username_description": "Detta är ett standardanvändarnamn och uppdatering tar dig till fakturering för att nedgradera.", + "premium_to_standard_username_description": "Detta är ett standardanvändarnamn och uppdatering tar dig till fakturering för att nedgradera.", "current": "Nuvarande", "premium": "premium", "standard": "standard", diff --git a/apps/web/public/static/locales/tr/common.json b/apps/web/public/static/locales/tr/common.json index fd490003ec..e758330326 100644 --- a/apps/web/public/static/locales/tr/common.json +++ b/apps/web/public/static/locales/tr/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "İş akışları, hatırlatıcı ve bildirim göndermeyi otomatikleştirmenizi sağlar.", "active_on": "Aktif", "workflow_updated_successfully": "{{workflowName}} iş akışı başarıyla güncellendi", - "standard_to_premium_username_description": "Bu, standart bir kullanıcı adıdır ve güncelleme işlemi eski sürüme geçmeniz için sizi faturalandırma sayfasına yönlendirecektir.", + "premium_to_standard_username_description": "Bu, standart bir kullanıcı adıdır ve güncelleme işlemi eski sürüme geçmeniz için sizi faturalandırma sayfasına yönlendirecektir.", "current": "Mevcut", "premium": "premium", "standard": "standart", diff --git a/apps/web/public/static/locales/uk/common.json b/apps/web/public/static/locales/uk/common.json index 605a52c501..d8eb42ec49 100644 --- a/apps/web/public/static/locales/uk/common.json +++ b/apps/web/public/static/locales/uk/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Робочі процеси дають змогу автоматизувати надсилання нагадувань і сповіщень.", "active_on": "Активується", "workflow_updated_successfully": "Робочий процес «{{workflowName}}» оновлено", - "standard_to_premium_username_description": "Це стандандартне ім’я користувача. Якщо оновити його, ви перейдете до виставлення рахунків для переходу на дешевшу підписку.", + "premium_to_standard_username_description": "Це стандандартне ім’я користувача. Якщо оновити його, ви перейдете до виставлення рахунків для переходу на дешевшу підписку.", "current": "Поточна", "premium": "преміум", "standard": "стандарт", diff --git a/apps/web/public/static/locales/vi/common.json b/apps/web/public/static/locales/vi/common.json index 367ad06062..4a2fece362 100644 --- a/apps/web/public/static/locales/vi/common.json +++ b/apps/web/public/static/locales/vi/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "Dòng công việc giúp bạn tự động hóa việc gửi lời nhắc và thông báo.", "active_on": "Hoạt động vào", "workflow_updated_successfully": "Đã cập nhật thành công tiến độ công việc {{workflowName}}", - "standard_to_premium_username_description": "Đây là tên người dùng tiêu chuẩn và việc cập nhật sẽ đưa bạn đến khâu thanh toán để hạ cấp độ.", + "premium_to_standard_username_description": "Đây là tên người dùng tiêu chuẩn và việc cập nhật sẽ đưa bạn đến khâu thanh toán để hạ cấp độ.", "current": "Hiện tại", "premium": "cao cấp", "standard": "tiêu chuẩn", diff --git a/apps/web/public/static/locales/zh-CN/common.json b/apps/web/public/static/locales/zh-CN/common.json index ff612607d7..a9852a1991 100644 --- a/apps/web/public/static/locales/zh-CN/common.json +++ b/apps/web/public/static/locales/zh-CN/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "工作流程可让您自动发送提醒和通知。", "active_on": "活跃于", "workflow_updated_successfully": "{{workflowName}} 工作流程已成功更新", - "standard_to_premium_username_description": "这是一个标准用户名,更新流程会将您引导至账单页面进行降级。", + "premium_to_standard_username_description": "这是一个标准用户名,更新流程会将您引导至账单页面进行降级。", "current": "当前", "premium": "高级", "standard": "标准", diff --git a/apps/web/public/static/locales/zh-TW/common.json b/apps/web/public/static/locales/zh-TW/common.json index 63157c5988..9c786d418d 100644 --- a/apps/web/public/static/locales/zh-TW/common.json +++ b/apps/web/public/static/locales/zh-TW/common.json @@ -955,7 +955,7 @@ "new_workflow_description": "工作流程可讓您自動傳送提醒和通知。", "active_on": "啟用類型:", "workflow_updated_successfully": "已成功更新 {{workflowName}}", - "standard_to_premium_username_description": "此為標準使用者名稱,更新系統會帶您前往付費頁面進行降級。", + "premium_to_standard_username_description": "此為標準使用者名稱,更新系統會帶您前往付費頁面進行降級。", "current": "目前", "premium": "高級", "standard": "標準", diff --git a/apps/website b/apps/website index f3fba833b8..81ff552ad9 160000 --- a/apps/website +++ b/apps/website @@ -1 +1 @@ -Subproject commit f3fba833b8c5a0b61fe3b0a8a08acc67b7fdb099 +Subproject commit 81ff552ad98ff4d2b5819c73cde30e53269bda37 diff --git a/packages/app-store/stripepayment/api/index.ts b/packages/app-store/stripepayment/api/index.ts index 796adc6f9e..2fd249e419 100644 --- a/packages/app-store/stripepayment/api/index.ts +++ b/packages/app-store/stripepayment/api/index.ts @@ -2,5 +2,7 @@ export { default as add } from "./add"; export { default as callback } from "./callback"; export { default as portal } from "./portal"; export { default as subscription } from "./subscription"; +export { default as paymentCallback } from "./paymentCallback"; + // TODO: Figure out how to handle webhook endpoints from App Store // export { default as webhook } from "./webhook"; diff --git a/packages/app-store/stripepayment/api/paymentCallback.ts b/packages/app-store/stripepayment/api/paymentCallback.ts new file mode 100644 index 0000000000..546d8bba4b --- /dev/null +++ b/packages/app-store/stripepayment/api/paymentCallback.ts @@ -0,0 +1,51 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; + +import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import { prisma } from "@calcom/prisma"; + +const querySchema = z.object({ + callbackUrl: z.string().transform((url) => { + if (url.search(/^https?:\/\//) === -1) { + url = `${WEBAPP_URL}${url}`; + } + return new URL(url); + }), + checkoutSessionId: z.string(), +}); + +// It handles premium user payment success/failure. Can be modified to handle other PRO upgrade payment as well. +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + const { callbackUrl, checkoutSessionId } = querySchema.parse(req.query); + const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId); + + if (!stripeCustomer) return { message: "Stripe customer not found or deleted" }; + + if (checkoutSession.payment_status === "paid") { + console.log("Found payment "); + try { + await prisma.user.update({ + data: { + username: stripeCustomer.metadata.username, + }, + where: { + email: stripeCustomer.metadata.email, + }, + }); + } catch (error) { + console.error(error); + return { + message: + "We have received your payment. Your premium username could still not be reserved. Please contact support@cal.com and mention your premium username", + }; + } + } + callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status); + return res.redirect(callbackUrl.toString()).end(); +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/stripepayment/api/subscription.ts b/packages/app-store/stripepayment/api/subscription.ts index 54bf8a4065..62af6520d8 100644 --- a/packages/app-store/stripepayment/api/subscription.ts +++ b/packages/app-store/stripepayment/api/subscription.ts @@ -2,45 +2,32 @@ import { UserPlan } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import Stripe from "stripe"; +import { + getPremiumPlanMode, + getPremiumPlanPrice, + getPremiumPlanProductId, +} from "@calcom/app-store/stripepayment/lib/utils"; import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import prisma from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; -import { - PREMIUM_PLAN_PRICE, - PREMIUM_PLAN_PRODUCT_ID, - PRO_PLAN_PRICE, - PRO_PLAN_PRODUCT_ID, -} from "../lib/constants"; +import { PRO_PLAN_PRICE, PRO_PLAN_PRODUCT_ID } from "../lib/constants"; import { getStripeCustomerIdFromUserId } from "../lib/customer"; import stripe from "../lib/server"; -enum UsernameChangeStatusEnum { +export enum UsernameChangeStatusEnum { NORMAL = "NORMAL", UPGRADE = "UPGRADE", DOWNGRADE = "DOWNGRADE", } -const obtainNewConditionAction = ({ - userCurrentPlan, - isNewUsernamePremium, -}: { - userCurrentPlan: UserPlan; - isNewUsernamePremium: boolean; -}) => { - if (userCurrentPlan === UserPlan.PRO) { - if (isNewUsernamePremium) return UsernameChangeStatusEnum.UPGRADE; - return UsernameChangeStatusEnum.DOWNGRADE; - } - return UsernameChangeStatusEnum.NORMAL; -}; - export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "GET") { const userId = req.session?.user.id; - let { intentUsername = null } = req.query; + let { intentUsername = null } = req.query; + const { action, callbackUrl } = req.query; if (!userId || !intentUsername) { res.status(404).end(); return; @@ -66,18 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const isCurrentlyPremium = hasKeyInMetadata(userData, "isPremium") && !!userData.metadata.isPremium; - // Save the intentUsername in the metadata - await prisma.user.update({ - where: { id: userId }, - data: { - metadata: { - ...(userData.metadata as Prisma.JsonObject), - intentUsername, - }, - }, - }); - - const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/profile`; + const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=${callbackUrl}`; const createSessionParams: Stripe.BillingPortal.SessionCreateParams = { customer: customerId, return_url, @@ -87,11 +63,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!checkPremiumResult.available) { return res.status(404).json({ message: "Intent username not available" }); } + const stripeCustomer = await stripe.customers.retrieve(customerId); + if (!stripeCustomer || stripeCustomer.deleted) { + return res.status(400).json({ message: "Stripe customer not found or deleted" }); + } + await stripe.customers.update(customerId, { + metadata: { + ...stripeCustomer.metadata, + username: intentUsername, + }, + }); if (userData && (userData.plan === UserPlan.FREE || userData.plan === UserPlan.TRIAL)) { - const subscriptionPrice = checkPremiumResult.premium ? PREMIUM_PLAN_PRICE : PRO_PLAN_PRICE; + const subscriptionPrice = checkPremiumResult.premium ? getPremiumPlanPrice() : PRO_PLAN_PRICE; const checkoutSession = await stripe.checkout.sessions.create({ - mode: "subscription", + mode: getPremiumPlanMode(), payment_method_types: ["card"], customer: customerId, line_items: [ @@ -104,24 +90,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) cancel_url: return_url, allow_promotion_codes: true, }); + // Save the intentUsername in the metadata + await prisma.user.update({ + where: { id: userId }, + data: { + metadata: { + ...(userData.metadata as Prisma.JsonObject), + checkoutSessionId: checkoutSession.id, + intentUsername, + }, + }, + }); if (checkoutSession && checkoutSession.url) { return res.redirect(checkoutSession.url).end(); } return res.status(404).json({ message: "Couldn't redirect to stripe checkout session" }); } - const action = obtainNewConditionAction({ - userCurrentPlan: userData?.plan ?? UserPlan.FREE, - isNewUsernamePremium: checkPremiumResult.premium, - }); - if (action && userData) { let actionText = ""; const customProductsSession = []; if (action === UsernameChangeStatusEnum.UPGRADE) { actionText = "Upgrade your plan account"; if (checkPremiumResult.premium) { - customProductsSession.push({ prices: [PREMIUM_PLAN_PRICE], product: PREMIUM_PLAN_PRODUCT_ID }); + customProductsSession.push({ prices: [getPremiumPlanPrice()], product: getPremiumPlanProductId() }); } else { customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID }); } @@ -148,6 +140,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }, }); + await prisma.user.update({ + where: { id: userId }, + data: { + metadata: { + ...(userData.metadata as Prisma.JsonObject), + intentUsername, + }, + }, + }); if (configuration) { createSessionParams.configuration = configuration.id; } diff --git a/packages/app-store/stripepayment/lib/constants.ts b/packages/app-store/stripepayment/lib/constants.ts index 8d48d7f9e2..13d023d88d 100644 --- a/packages/app-store/stripepayment/lib/constants.ts +++ b/packages/app-store/stripepayment/lib/constants.ts @@ -1,5 +1,7 @@ export const FREE_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE || ""; export const PREMIUM_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE || ""; +export const IS_PREMIUM_NEW_PLAN = process.env.NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN === "1" ? true : false; +export const PREMIUM_NEW_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE || ""; export const PRO_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || ""; export const FREE_PLAN_PRODUCT_ID = process.env.STRIPE_FREE_PLAN_PRODUCT_ID || ""; export const PRO_PLAN_PRODUCT_ID = process.env.STRIPE_PRO_PLAN_PRODUCT_ID || ""; diff --git a/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts b/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts new file mode 100644 index 0000000000..6931856c22 --- /dev/null +++ b/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts @@ -0,0 +1,24 @@ +import stripe from "@calcom/app-store/stripepayment/lib/server"; + +export async function getCustomerAndCheckoutSession(checkoutSessionId: string) { + const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutSessionId); + const customerOrCustomerId = checkoutSession.customer; + let customerId = null; + + if (!customerOrCustomerId) { + return { checkoutSession, customer: null }; + } + + if (typeof customerOrCustomerId === "string") { + customerId = customerOrCustomerId; + } else if (customerOrCustomerId.deleted) { + return { checkoutSession, customer: null }; + } else { + customerId = customerOrCustomerId.id; + } + const stripeCustomer = await stripe.customers.retrieve(customerId); + if (stripeCustomer.deleted) { + return { checkoutSession, customer: null }; + } + return { stripeCustomer, checkoutSession }; +} diff --git a/packages/app-store/stripepayment/lib/server.ts b/packages/app-store/stripepayment/lib/server.ts index 4211e06c48..bef456ba00 100644 --- a/packages/app-store/stripepayment/lib/server.ts +++ b/packages/app-store/stripepayment/lib/server.ts @@ -33,7 +33,6 @@ export const stripeDataSchema = stripeOAuthTokenSchema.extend({ export type StripeData = z.infer; const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!; - const stripe = new Stripe(stripePrivateKey, { apiVersion: "2020-08-27", }); diff --git a/packages/app-store/stripepayment/lib/utils.ts b/packages/app-store/stripepayment/lib/utils.ts index fdcf3b4faa..9838921c90 100644 --- a/packages/app-store/stripepayment/lib/utils.ts +++ b/packages/app-store/stripepayment/lib/utils.ts @@ -4,15 +4,25 @@ import { PREMIUM_PLAN_PRICE, PREMIUM_PLAN_PRODUCT_ID, PRO_PLAN_PRICE, + PREMIUM_NEW_PLAN_PRICE, + IS_PREMIUM_NEW_PLAN, PRO_PLAN_PRODUCT_ID, } from "./constants"; -export function getPerSeatProPlanPrice(): string { - return PRO_PLAN_PRICE; +export function getPremiumPlanMode() { + return IS_PREMIUM_NEW_PLAN ? "payment" : "subscription"; +} + +export function getPremiumPlanPriceValue() { + return IS_PREMIUM_NEW_PLAN ? "$499" : "$29/mo"; } export function getPremiumPlanPrice(): string { - return PREMIUM_PLAN_PRICE; + return IS_PREMIUM_NEW_PLAN ? PREMIUM_NEW_PLAN_PRICE : PREMIUM_PLAN_PRICE; +} + +export function getPerSeatProPlanPrice(): string { + return PRO_PLAN_PRICE; } export function getProPlanPrice(): string { diff --git a/packages/lib/sync/services/CloseComService.ts b/packages/lib/sync/services/CloseComService.ts index efce222a7e..bece375096 100644 --- a/packages/lib/sync/services/CloseComService.ts +++ b/packages/lib/sync/services/CloseComService.ts @@ -39,7 +39,6 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer // Get Custom Contact fields ids const customFieldsIds = await getCustomFieldsIds("contact", calComCustomContactFields, this.service); this.log.debug("sync:closecom:user:customFieldsIds", { customFieldsIds }); - debugger; // Get shared fields ids const sharedFieldsIds = await getCustomFieldsIds("shared", calComSharedFields, this.service); this.log.debug("sync:closecom:user:sharedFieldsIds", { sharedFieldsIds }); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 00fc010a8b..370c5e5a61 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -128,6 +128,7 @@ export const userMetadata = z vitalSettings: vitalSettingsUpdateSchema.optional(), isPremium: z.boolean().optional(), intentUsername: z.string().optional(), + checkoutSessionId: z.string().nullable().optional(), }) .nullable(); diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index f400306df0..5c39f9fe6c 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -7,6 +7,7 @@ import { z } from "zod"; import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router"; import ethRouter from "@calcom/app-store/rainbow/trpc/router"; import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; +import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession"; import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server"; import getApps, { getLocationOptions } from "@calcom/app-store/utils"; import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; @@ -37,6 +38,7 @@ import { updateWebUser as syncServicesUpdateWebUser, } from "@calcom/lib/sync/SyncServiceManager"; import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image"; import { TRPCError } from "@trpc/server"; @@ -79,6 +81,71 @@ const publicViewerRouter = createRouter() return await samlTenantProduct(prisma, email); }, }) + .query("stripeCheckoutSession", { + input: z.object({ + stripeCustomerId: z.string().optional(), + checkoutSessionId: z.string().optional(), + }), + async resolve({ input }) { + const { checkoutSessionId, stripeCustomerId } = input; + + // TODO: Move the following data checks to superRefine + if (!checkoutSessionId && !stripeCustomerId) { + throw new Error("Missing checkoutSessionId or stripeCustomerId"); + } + + if (checkoutSessionId && stripeCustomerId) { + throw new Error("Both checkoutSessionId and stripeCustomerId provided"); + } + let customerId: string; + let isPremiumUsername = false; + let hasPaymentFailed = false; + if (checkoutSessionId) { + try { + const session = await stripe.checkout.sessions.retrieve(checkoutSessionId); + if (typeof session.customer !== "string") { + return { + valid: false, + }; + } + customerId = session.customer; + isPremiumUsername = true; + hasPaymentFailed = session.payment_status !== "paid"; + } catch (e) { + return { + valid: false, + }; + } + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + customerId = stripeCustomerId!; + } + + try { + const customer = await stripe.customers.retrieve(customerId); + if (customer.deleted) { + return { + valid: false, + }; + } + + return { + valid: true, + hasPaymentFailed, + isPremiumUsername, + customer: { + username: customer.metadata.username, + email: customer.metadata.email, + stripeCustomerId: customerId, + }, + }; + } catch (e) { + return { + valid: false, + }; + } + }, + }) .merge("slots.", slotsRouter); // routes only available to authenticated users @@ -687,6 +754,43 @@ const loggedInViewerRouter = createProtectedRouter() return app; }, }) + .query("stripeCustomer", { + async resolve({ ctx }) { + const { + user: { id: userId }, + prisma, + } = ctx; + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + metadata: true, + }, + }); + + if (!user) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "User not found" }); + } + + const metadata = userMetadata.parse(user.metadata); + const checkoutSessionId = metadata?.checkoutSessionId; + //TODO: Rename checkoutSessionId to premiumUsernameCheckoutSessionId + if (!checkoutSessionId) return { isPremium: false }; + + const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId); + if (!stripeCustomer) { + throw new TRPCError({ code: "NOT_FOUND", message: "Stripe User not found" }); + } + + return { + isPremium: true, + paidForPremium: checkoutSession.payment_status === "paid", + username: stripeCustomer.metadata.username, + }; + }, + }) .mutation("updateProfile", { input: z.object({ username: z.string().optional(), @@ -711,12 +815,14 @@ const loggedInViewerRouter = createProtectedRouter() const data: Prisma.UserUpdateInput = { ...input, }; + let isPremiumUsername = false; if (input.username) { const username = slugify(input.username); // Only validate if we're changing usernames if (username !== user.username) { data.username = username; const response = await checkUsername(username); + isPremiumUsername = response.premium; if (!response.available) { throw new TRPCError({ code: "BAD_REQUEST", message: response.message }); } @@ -725,6 +831,30 @@ const loggedInViewerRouter = createProtectedRouter() if (input.avatar) { data.avatar = await resizeBase64Image(input.avatar); } + const userToUpdate = await prisma.user.findUnique({ + where: { + id: user.id, + }, + }); + + if (!userToUpdate) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + const metadata = userMetadata.parse(userToUpdate.metadata); + // Checking the status of payment directly from stripe allows to avoid the situation where the user has got the refund or maybe something else happened asyncly at stripe but our DB thinks it's still paid for + // TODO: Test the case where one time payment is refunded. + const premiumUsernameCheckoutSessionId = metadata?.checkoutSessionId; + if (premiumUsernameCheckoutSessionId) { + const checkoutSession = await stripe.checkout.sessions.retrieve(premiumUsernameCheckoutSessionId); + const canUserHavePremiumUsername = checkoutSession.payment_status == "paid"; + + if (isPremiumUsername && !canUserHavePremiumUsername) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You need to pay for premium username", + }); + } + } const updatedUser = await prisma.user.update({ where: { diff --git a/packages/ui/Icon.tsx b/packages/ui/Icon.tsx index 14dfcb0b2c..90336278bf 100644 --- a/packages/ui/Icon.tsx +++ b/packages/ui/Icon.tsx @@ -7,7 +7,7 @@ export { CollectionIcon } from "@heroicons/react/outline"; export { ShieldCheckIcon } from "@heroicons/react/outline"; export { BadgeCheckIcon } from "@heroicons/react/outline"; export { ClipboardCopyIcon } from "@heroicons/react/outline"; - +export { StarIcon as StarIconSolid } from "@heroicons/react/solid"; // TODO: // right now: Icon.Sun comes from react-feather // CollectionIcon comes from "@heroicons/react/outline"; diff --git a/packages/ui/v2/core/skeleton/index.tsx b/packages/ui/v2/core/skeleton/index.tsx index 00e5c04713..530357aac2 100644 --- a/packages/ui/v2/core/skeleton/index.tsx +++ b/packages/ui/v2/core/skeleton/index.tsx @@ -33,7 +33,7 @@ const SkeletonText: React.FC = ({ width = "", height = "", cl className = width ? `${className} w-${width}` : className; className = height ? `${className} h-${height}` : className; return ( -