diff --git a/.env.local.example b/.env.local.example index 43deecef1c..2a436d14d6 100644 --- a/.env.local.example +++ b/.env.local.example @@ -44,6 +44,7 @@ WEB_CONTENT_FUNCTION_SECRET=SECRET # Resned email api key RESEND_EMAIL_API_KEY=KEY RESEND_EMAIL_RECEIVER=EMAIL +RESEND_EMAIL_RECEIVER2=EMAIL # SendGrid SENDGRID_API_KEY=KEY diff --git a/.eslintignore b/.eslintignore index 76ec51e213..143929bb6a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ +firebase/functions/.eslintrc.js firebase/* diff --git a/components/User/Settings/ChangeSubscriptionButton.tsx b/components/User/Settings/ChangeSubscriptionButton.tsx new file mode 100644 index 0000000000..0f72235dc7 --- /dev/null +++ b/components/User/Settings/ChangeSubscriptionButton.tsx @@ -0,0 +1,98 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { OrderedSubscriptionPlans } from '@/utils/app/const'; + +import type { StripeProductPaidPlanType } from '@/types/stripe-product'; +import type { UserSubscriptionDetail } from '@/types/user'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +const ChangeSubscriptionButton: React.FC<{ + plan: StripeProductPaidPlanType; + userSubscription: UserSubscriptionDetail | undefined; +}> = ({ plan, userSubscription }) => { + const { t } = useTranslation('model'); + + const showUpgradeButton = useMemo(() => { + if (!userSubscription) return false; + if (!userSubscription.userPlan) return false; + return ( + OrderedSubscriptionPlans.indexOf(plan) > + OrderedSubscriptionPlans.indexOf(userSubscription.userPlan) + ); + }, [userSubscription, plan]); + + const showDowngradeButton = useMemo(() => { + if (!userSubscription) return false; + if (!userSubscription.userPlan) return false; + return ( + OrderedSubscriptionPlans.indexOf(plan) < + OrderedSubscriptionPlans.indexOf(userSubscription.userPlan) + ); + }, [userSubscription, plan]); + + // Only show upgrade/downgrade button if user has a valid subscription + if (!userSubscription) return null; + + return ( + + + {showDowngradeButton ? ( +
+ + {t(`Downgrade to ${plan === 'pro' ? 'Pro' : 'Ultra'} Plan`)} + +
+ ) : showUpgradeButton ? ( +
+ + {t(`Upgrade to ${plan === 'pro' ? 'Pro' : 'Ultra'} Plan`)} + +
+ ) : null} +
+ + + + {t('Need Assistance with Subscription Change?')} + + + {t( + 'If you wish to upgrade or downgrade your subscription, kindly contact our support team via email at', + )} + + jack@exploratorlabs.com + + . + + + +
+ ); +}; + +export default ChangeSubscriptionButton; diff --git a/components/User/Settings/PlanComparison.tsx b/components/User/Settings/PlanComparison.tsx new file mode 100644 index 0000000000..397acb511f --- /dev/null +++ b/components/User/Settings/PlanComparison.tsx @@ -0,0 +1,389 @@ +import { useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; + +import { useUserSubscriptionDetail } from '@/hooks/stripeSubscription/useUserSubscriptionDetail'; + +import { + OrderedSubscriptionPlans, + STRIPE_PAID_PLAN_LINKS, +} from '@/utils/app/const'; +import { trackEvent } from '@/utils/app/eventTracking'; +import { FeatureItem, PlanDetail } from '@/utils/app/ui'; + +import type { User, UserSubscriptionDetail } from '@/types/user'; + +import Spinner from '@/components/Spinner'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import ChangeSubscriptionButton from './ChangeSubscriptionButton'; + +import dayjs from 'dayjs'; + +const PlanComparison = ({ + user, + isPaidUser, +}: { + user: User | null; + isPaidUser: boolean; +}) => { + const { data: userSubscriptionDetail, isFetched } = useUserSubscriptionDetail( + { + isPaidUser: isPaidUser, + user, + }, + ); + + if (isPaidUser && !isFetched) { + return ( +
+ +
+ ); + } + return ( +
+ {/* Free Plan */} +
+ +
+ + {/* Pro Plan */} +
+ +
+ + {/* Ultra Plan */} +
+ +
+
+ ); +}; + +export default PlanComparison; + +const PlanExpirationDate: React.FC<{ expirationDate: string }> = ({ + expirationDate, +}) => { + const { t } = useTranslation('model'); + return ( +
+
+ {`${t('Expires on')}: ${dayjs(expirationDate).format('ll')}`} +
+
+ ); +}; + +const FreePlanContent = ({ user }: { user: User | null }) => { + const { t } = useTranslation('model'); + return ( + <> +
+ Free + {(user?.plan === 'free' || !user) && } +
+
+ {PlanDetail.free.features.map((feature, index) => ( + + ))} +
+ + ); +}; +const ProPlanContent = ({ + user, + userSubscription, +}: { + user: User | null; + userSubscription: UserSubscriptionDetail | undefined; +}) => { + const { t, i18n } = useTranslation('model'); + const showUpgradeToPro = useMemo(() => { + if (userSubscription) return false; + if (!user) return true; + const userPlanIndex = OrderedSubscriptionPlans.indexOf(user.plan); + const proPlanIndex = OrderedSubscriptionPlans.indexOf('pro'); + return userPlanIndex < proPlanIndex; + }, [user, userSubscription]); + + const upgradeLinkOnClick = () => { + const paymentLink = + i18n.language === 'zh-Hant' || i18n.language === 'zh' + ? STRIPE_PAID_PLAN_LINKS['pro-monthly'].twd.link + : STRIPE_PAID_PLAN_LINKS['pro-monthly'].usd.link; + + const userEmail = user?.email; + const userId = user?.id; + + trackEvent('Upgrade button clicked'); + + if (!user) { + toast.error(t('Please sign-up before upgrading to paid plan')); + } else { + window.open( + `${paymentLink}?prefilled_email=${userEmail}&client_reference_id=${userId}`, + '_blank', + ); + } + }; + + return ( + <> +
+ + Pro + + {user?.plan === 'pro' && } +
+ +
+ + + {PlanDetail.pro.features.map((feature, index) => ( + + ))} +
+ {/* Upgrade button */} + {showUpgradeToPro && ( +
+ + {t('Upgrade')} + +

+ {t('No Strings Attached - Cancel Anytime!')} +

+
+ )} + + + + {user?.plan === 'pro' && user.proPlanExpirationDate && ( + + )} + + ); +}; + +const UltraPlanContent = ({ + user, + userSubscription, +}: { + user: User | null; + userSubscription: UserSubscriptionDetail | undefined; +}) => { + const { t, i18n } = useTranslation('model'); + const [priceType, setPriceType] = useState<'monthly' | 'yearly'>('monthly'); + const showUpgradeToUltra = useMemo(() => { + if (userSubscription) return false; + if (!user) return true; + const userPlanIndex = OrderedSubscriptionPlans.indexOf(user.plan); + const ultraPlanIndex = OrderedSubscriptionPlans.indexOf('ultra'); + return userPlanIndex < ultraPlanIndex; + }, [user, userSubscription]); + + const upgradeLinkOnClick = () => { + let paymentLink = STRIPE_PAID_PLAN_LINKS['ultra-monthly'].usd.link; + if (priceType === 'monthly') { + if (i18n.language === 'zh-Hant' || i18n.language === 'zh') { + paymentLink = STRIPE_PAID_PLAN_LINKS['ultra-monthly'].twd.link; + } else { + paymentLink = STRIPE_PAID_PLAN_LINKS['ultra-monthly'].usd.link; + } + } else { + if (i18n.language === 'zh-Hant' || i18n.language === 'zh') { + paymentLink = STRIPE_PAID_PLAN_LINKS['ultra-yearly'].twd.link; + } else { + paymentLink = STRIPE_PAID_PLAN_LINKS['ultra-yearly'].usd.link; + } + } + + const userEmail = user?.email; + const userId = user?.id; + + trackEvent('Upgrade button clicked'); + + if (!user) { + toast.error(t('Please sign-up before upgrading to paid plan')); + } else { + window.open( + `${paymentLink}?prefilled_email=${userEmail}&client_reference_id=${userId}`, + '_blank', + ); + } + }; + + return ( + <> +
+ + Ultra + + {user?.plan === 'ultra' && } +
+ {user?.plan !== 'ultra' && ( + + )} + +
+ + + {PlanDetail.ultra.features.map((feature, index) => ( + + ))} +
+ {/* Upgrade button */} + {showUpgradeToUltra && ( +
+ + {t('Upgrade to Ultra')} + +

+ {t('No Strings Attached - Cancel Anytime!')} +

+
+ )} + + + {user?.plan === 'ultra' && user.proPlanExpirationDate && ( + + )} + + ); +}; + +const CurrentPlanTag = () => { + return ( + + CURRENT PLAN + + ); +}; + +const ProPlanPrice = ({ + userSubscription, +}: { + userSubscription: UserSubscriptionDetail | undefined; +}) => { + const { i18n } = useTranslation('model'); + + if (userSubscription && userSubscription.subscriptionCurrency === 'twd') { + return {'TWD$249.99 / month'}; + } + if (userSubscription && userSubscription.subscriptionCurrency === 'usd') { + return {'USD$9.99 / month'}; + } + switch (i18n.language) { + case 'zh-Hant': + case 'zh': + return {'TWD$249.99 / month'}; + default: + return {'USD$9.99 / month'}; + } +}; + +const UltraPlanPrice = ({ + setPriceType, + userSubscription, +}: { + setPriceType: (type: 'monthly' | 'yearly') => void; + userSubscription: UserSubscriptionDetail | undefined; +}) => { + const { t, i18n } = useTranslation('model'); + + const monthlyPriceComponent = useMemo(() => { + if (userSubscription && userSubscription.subscriptionCurrency === 'twd') { + return {'TWD$880 / month'}; + } + + if (userSubscription && userSubscription.subscriptionCurrency === 'usd') { + return {'USD$29.99 / month'}; + } + if (i18n.language === 'zh-Hant' || i18n.language === 'zh') { + return {'TWD$880 / month'}; + } else { + return {'USD$29.99 / month'}; + } + }, [userSubscription, i18n.language]); + + const yearlyPriceComponent = useMemo(() => { + if (userSubscription && userSubscription.subscriptionCurrency === 'twd') { + return {'TWD$8800 / year'}; + } + if (userSubscription && userSubscription.subscriptionCurrency === 'usd') { + return {'USD$279.99 / year'}; + } + + if (i18n.language === 'zh-Hant' || i18n.language === 'zh') { + return {'TWD$8800 / year'}; + } else { + return {'USD$279.99 / year'}; + } + }, [userSubscription, i18n.language]); + + return ( + + + { + setPriceType('monthly'); + }} + > + {t('MONTHLY')} + + { + setPriceType('yearly'); + }} + > + {t('YEARLY')} + + + {monthlyPriceComponent} + {yearlyPriceComponent} + + ); +}; diff --git a/components/User/Settings/SettingsModel.tsx b/components/User/Settings/SettingsModel.tsx index 62b2b5f29f..fe9e595134 100644 --- a/components/User/Settings/SettingsModel.tsx +++ b/components/User/Settings/SettingsModel.tsx @@ -72,7 +72,7 @@ export default function SettingsModel({ onClose }: Props) { leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + { - const paymentLink = - process.env.NEXT_PUBLIC_ENV === 'production' - ? 'https://buy.stripe.com/8wM8Av2DM0u99fWfZ1' - : 'https://buy.stripe.com/test_4gw4hLcvq52Odt6fYY'; - const userEmail = user?.email; - const userId = user?.id; - - trackEvent('Upgrade button clicked'); - - if (!user) { - toast.error('Please sign-up before upgrading to pro plan'); - } else { - window.open( - `${paymentLink}?prefilled_email=${userEmail}&client_reference_id=${userId}`, - '_blank', - ); - } - }; - const subscriptionManagementLink = () => process.env.NEXT_PUBLIC_ENV === 'production' ? 'https://billing.stripe.com/p/login/5kAbMj0wt5VF6AwaEE' @@ -81,49 +58,8 @@ export default function Settings_Account() {

)} -
-
- Free -
- {PlanDetail.free.features.map((feature, index) => ( - - ))} -
-
-
- {user?.plan === 'ultra' ? ( - - ) : ( - - )} - - {(!user || !isPaidUser) && ( - - )} + {} - {(user?.plan === 'pro' || user?.plan === 'ultra') && - user.proPlanExpirationDate && ( -
- {`${t('Expires on')}: - ${dayjs(user.proPlanExpirationDate).format( - 'll', - )}`}{' '} -
- )} -
-
{displayReferralCodeEnterer && }
{isPaidUser && !user?.isInReferralTrial && ( @@ -196,46 +132,3 @@ export default function Settings_Account() {
); } - -const ProPlanContent = () => { - const { t } = useTranslation('model'); - return ( - <> - Pro - {t('USD$9.99 / month')} -
- - - {PlanDetail.pro.features.map((feature, index) => ( - - ))} -
- - ); -}; - -const UltraPlanContent = () => { - const { t } = useTranslation('model'); - return ( - <> - - Ultra - -
- - - {PlanDetail.ultra.features.map((feature, index) => ( - - ))} -
- - ); -}; diff --git a/components/User/UsageCreditModel.tsx b/components/User/UsageCreditModel.tsx index ff839aa986..442f858cef 100644 --- a/components/User/UsageCreditModel.tsx +++ b/components/User/UsageCreditModel.tsx @@ -4,6 +4,10 @@ import { Fragment, useContext } from 'react'; import { useTranslation } from 'next-i18next'; +import { + AI_IMAGE_CREDIT_PURCHASE_LINKS, + GPT4_CREDIT_PURCHASE_LINKS, +} from '@/utils/app/const'; import { DefaultMonthlyCredits } from '@/utils/config'; import { PluginID } from '@/types/plugin'; @@ -14,16 +18,6 @@ type Props = { onClose: () => void; }; -const gpt4CreditPurchaseLinks = { - '50': 'https://buy.stripe.com/28o03Z0vE3Glak09AJ', - '150': 'https://buy.stripe.com/cN2dUP6U2dgV0JqcMW', - '300': 'https://buy.stripe.com/dR6g2Xemu5Otcs83cn', -}; -const aiImageCreditPurchaseLinks = { - '100': 'https://buy.stripe.com/fZeg2Xdiq4Kp8bS9AT', - '500': 'https://buy.stripe.com/8wMg2XcemccR2Ry8wQ', -}; - export const UsageCreditModel: FC = ({ onClose }) => { const { t } = useTranslation('model'); @@ -108,7 +102,7 @@ export const UsageCreditModel: FC = ({ onClose }) => { : gpt4Credit} - {Object.entries(gpt4CreditPurchaseLinks).map( + {Object.entries(GPT4_CREDIT_PURCHASE_LINKS).map( ([key, value]) => ( = ({ onClose }) => { : aiImageCredit} - {Object.entries(aiImageCreditPurchaseLinks).map( + {Object.entries(AI_IMAGE_CREDIT_PURCHASE_LINKS).map( ([key, value]) => ( Ultra @@ -27,7 +21,7 @@ export default function UserAccountBadge() { if (user.plan === 'pro') { return ( Pro diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000000..6ee3c32bf8 --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/components/v2Chat/payment-dialog.tsx b/components/v2Chat/payment-dialog.tsx index bf5aca95f5..a01f4505c5 100644 --- a/components/v2Chat/payment-dialog.tsx +++ b/components/v2Chat/payment-dialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import toast from 'react-hot-toast'; +import { V2_CHAT_UPGRADE_LINK } from '@/utils/app/const'; import { trackEvent } from '@/utils/app/eventTracking'; import type { UserProfile } from '@/types/user'; @@ -44,10 +45,7 @@ export const PaymentDialog = ({ const userId = userProfile.id; const upgradeLinkOnClick = () => { - const paymentLink = - process.env.NEXT_PUBLIC_ENV === 'production' - ? 'https://buy.stripe.com/4gw9Ez6U2gt71NudRd' - : 'https://buy.stripe.com/test_dR68y152Y7aWagUcMU'; + const paymentLink = V2_CHAT_UPGRADE_LINK; trackEvent('v2 Payment link clicked'); diff --git a/cypress/e2e/account.ts b/cypress/e2e/account.ts index 7122bba9c9..96f0e73c3a 100644 --- a/cypress/e2e/account.ts +++ b/cypress/e2e/account.ts @@ -18,9 +18,21 @@ const TEACHER_USER = { password: 'chateverywhere', }; +const TEST_PAYMENT_USER = { + email: 'cypress+stripe@exploratorlabs.com', + password: 'chateverywhere', +}; + const PRIORITY_USER = { email: 'cypress+priority@exploratorlabs.com', password: 'chateverywhere', }; -export { FREE_USER, PRO_USER, ULTRA_USER, TEACHER_USER, PRIORITY_USER }; +export { + FREE_USER, + PRO_USER, + ULTRA_USER, + TEACHER_USER, + TEST_PAYMENT_USER, + PRIORITY_USER, +}; diff --git a/cypress/e2e/payment.cy.ts b/cypress/e2e/payment.cy.ts new file mode 100644 index 0000000000..0d08742afb --- /dev/null +++ b/cypress/e2e/payment.cy.ts @@ -0,0 +1,161 @@ +import { TEST_PAYMENT_USER } from './account'; + +import dayjs from 'dayjs'; + +describe('Test Payment Flow', () => { + const hostUrl = Cypress.env('HOST_URL') || 'http://localhost:3000'; + + beforeEach(() => { + cy.session( + 'user', + () => { + cy.login(TEST_PAYMENT_USER.email, TEST_PAYMENT_USER.password); + }, + { + validate: () => { + cy.get('[data-cy="user-account-badge"]', { timeout: 10000 }).should( + 'be.visible', + ); + }, + }, + ); + + cy.visit(hostUrl); + }); + + // Reset payment by calling API + afterEach(() => { + cy.request({ + method: 'POST', + url: '/api/cypress/reset-test-payment-user-subscription', + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + // Logout + after(() => { + cy.reload(); + cy.get('[data-cy="settings-button"]').click(); + cy.get('[data-cy="chatbar-settings-modal"]') + .scrollIntoView() + .should('be.visible'); + cy.get('[data-cy="sign-out-confirmation-button"]').click(); + cy.get('[data-cy="sign-out-and-clear-button"]').click(); + cy.contains('You have been logged out'); + }); + it('upgrade to pro plan', () => { + // Make sure the user is on Free plan. + cy.get('[data-cy="user-account-badge"]', { timeout: 10000 }).then(($el) => { + expect($el).to.have.text('Free'); + }); + + // calls the `/api/cypress/test-subscription-plan-payment` endpoint to test the payment flow. + cy.request({ + method: 'POST', + url: '/api/cypress/test-subscription-plan-payment', + headers: { + 'Content-Type': 'application/json', + }, + body: { + plan: 'pro', + }, + }); + + // Refreshes the page and checks if the user is on Pro plan. + cy.reload(); + cy.get('[data-cy="user-account-badge"]', { timeout: 10000 }).then(($el) => { + expect($el).to.have.text('Pro'); + }); + + cy.get('[data-cy="settings-button"]').click(); + cy.get('[data-cy="chatbar-settings-modal"]') + .scrollIntoView() + .should('be.visible'); + cy.get('[data-cy="chatbar-settings-modal"]').within(() => { + const newExpirationDate = dayjs().add(1, 'month').format('MMM DD, YYYY'); + cy.contains(`Expires on: ${newExpirationDate}`); + }); + }); + + it('upgrade to ultra plan', () => { + // Make sure the user is on Free plan. + cy.get('[data-cy="user-account-badge"]', { timeout: 10000 }).then(($el) => { + expect($el).to.have.text('Free'); + }); + + // calls the `/api/cypress/test-subscription-plan-payment` endpoint to test the payment flow. + cy.request({ + method: 'POST', + url: '/api/cypress/test-subscription-plan-payment', + headers: { + 'Content-Type': 'application/json', + }, + body: { + plan: 'ultra', + }, + }); + + // Refreshes the page and checks if the user is on Ultra plan. + cy.reload(); + cy.get('[data-cy="user-account-badge"]', { timeout: 10000 }).then(($el) => { + expect($el).to.have.text('Ultra'); + }); + + cy.get('[data-cy="settings-button"]').click(); + cy.get('[data-cy="chatbar-settings-modal"]') + .scrollIntoView() + .should('be.visible'); + cy.get('[data-cy="chatbar-settings-modal"]').within(() => { + const newExpirationDate = dayjs().add(1, 'month').format('MMM DD, YYYY'); + cy.contains(`Expires on: ${newExpirationDate}`); + }); + }); + + it('cancel subscription event should not cancel the plan immediately', () => { + // Make the user to Pro plan by calling the /api/cypress/test-subscription-plan-payment endpoint + cy.request({ + method: 'POST', + url: '/api/cypress/test-subscription-plan-payment', + headers: { + 'Content-Type': 'application/json', + }, + body: { + plan: 'pro', + }, + }); + + // Make sure the user is on Pro plan + cy.reload(); + cy.get('[data-cy="user-account-badge"]', { timeout: 10000 }).then(($el) => { + expect($el).to.have.text('Pro'); + }); + + // Call the /api/cypress/test-cancel-subscription endpoint + cy.request({ + method: 'POST', + url: '/api/cypress/test-cancel-subscription', + headers: { + 'Content-Type': 'application/json', + }, + }); + + cy.reload(); + // Make sure the user is on Pro plan but with a extra day from the expiration date + cy.get('[data-cy="user-account-badge"]', { timeout: 10000 }).then(($el) => { + expect($el).to.have.text('Pro'); + }); + cy.get('[data-cy="settings-button"]').click(); + cy.get('[data-cy="chatbar-settings-modal"]') + .scrollIntoView() + .should('be.visible'); + cy.get('[data-cy="chatbar-settings-modal"]').within(() => { + const newExpirationDate = dayjs() + .add(1, 'month') + .add(1, 'day') + .format('MMM DD, YYYY'); + cy.contains(`Expires on: ${newExpirationDate}`); + }); + }); +}); diff --git a/hooks/stripeSubscription/useUserSubscriptionDetail.ts b/hooks/stripeSubscription/useUserSubscriptionDetail.ts new file mode 100644 index 0000000000..ad1e8fc5a5 --- /dev/null +++ b/hooks/stripeSubscription/useUserSubscriptionDetail.ts @@ -0,0 +1,61 @@ +import { useSupabaseClient } from '@supabase/auth-helpers-react'; +import { useQuery } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; + +import type { User, UserSubscriptionDetail } from '@/types/user'; + +export const useUserSubscriptionDetail = ({ + isPaidUser, + user, +}: { + isPaidUser: boolean; + user: User | null; +}) => { + const { t } = useTranslation('common'); + const supabase = useSupabaseClient(); + + const fetchUserSubscriptionDetail = + async (): Promise => { + const accessToken = (await supabase.auth.getSession()).data.session + ?.access_token!; + + const response = await fetch('/api/stripe/user-subscription-detail', { + method: 'GET', + headers: { + 'access-token': accessToken, + }, + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const data = (await response.json())?.data; + + // NOTE: return undefined will be triggered as error in useQuery + return data + ? data + : { + userPlan: user?.plan || null, + subscriptionCurrency: 'usd', + }; + }; + + return useQuery( + ['userSubscriptionDetail', isPaidUser], + fetchUserSubscriptionDetail, + { + cacheTime: 0, + enabled: isPaidUser, + onError: (error) => { + console.error('Error fetching user subscription detail:', error); + toast.error( + t( + 'There was a problem fetching user subscription detail, please contact support team', + ), + ); + }, + }, + ); +}; diff --git a/package-lock.json b/package-lock.json index e1d4cf58b7..c0d131901a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "@ramonak/react-progress-bar": "^5.0.3", "@sendgrid/eventwebhook": "^8.0.0", @@ -4374,6 +4375,261 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", + "integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", @@ -25951,6 +26207,126 @@ "@radix-ui/react-use-size": "1.0.1" } }, + "@radix-ui/react-tabs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", + "integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==", + "requires": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "requires": {} + }, + "@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "requires": {} + }, + "@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "requires": {} + }, + "@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.0" + } + }, + "@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + } + }, + "@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "requires": { + "@radix-ui/react-slot": "1.1.0" + } + }, + "@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "requires": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + } + }, + "@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.0" + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "requires": {} + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "requires": { + "@radix-ui/react-use-callback-ref": "1.1.0" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "requires": {} + } + } + }, "@radix-ui/react-tooltip": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", diff --git a/package.json b/package.json index 282ebaedc5..1f8d103bf5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "@ramonak/react-progress-bar": "^5.0.3", "@sendgrid/eventwebhook": "^8.0.0", diff --git a/pages/api/cypress/reset-test-payment-user-subscription.ts b/pages/api/cypress/reset-test-payment-user-subscription.ts new file mode 100644 index 0000000000..64bf306c23 --- /dev/null +++ b/pages/api/cypress/reset-test-payment-user-subscription.ts @@ -0,0 +1,25 @@ +import { getAdminSupabaseClient } from '@/utils/server/supabase'; + +import { TEST_PAYMENT_USER } from '@/cypress/e2e/account'; + +export const config = { + runtime: 'edge', +}; + +const handler = async (): Promise => { + try { + const supabase = getAdminSupabaseClient(); + // Reset the test payment user's plan to free + await supabase + .from('profiles') + .update({ plan: 'free' }) + .eq('email', TEST_PAYMENT_USER.email); + + return new Response(JSON.stringify({ success: true }), { status: 200 }); + } catch (error) { + console.error(error); + return new Response('Error', { status: 500 }); + } +}; + +export default handler; diff --git a/pages/api/cypress/test-cancel-subscription.ts b/pages/api/cypress/test-cancel-subscription.ts new file mode 100644 index 0000000000..ba4b46efc8 --- /dev/null +++ b/pages/api/cypress/test-cancel-subscription.ts @@ -0,0 +1,61 @@ +// NOTE: This file is intended for testing the cancel subscription process. +import { getHomeUrl } from '@/utils/app/api'; +import { getAdminSupabaseClient } from '@/utils/server/supabase'; + +import { TEST_PAYMENT_USER } from '@/cypress/e2e/account'; + +export const config = { + runtime: 'edge', +}; + +const handler = async (req: Request): Promise => { + const isProd = process.env.VERCEL_ENV === 'production'; + + if (req.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + + try { + if (isProd) { + return new Response('Not allowed in production', { status: 403 }); + } + // Get user id from supabase + const supabase = getAdminSupabaseClient(); + const { data: userProfile } = await supabase + .from('profiles') + .select('id') + .eq('email', TEST_PAYMENT_USER.email) + .single(); + + if (!userProfile) + return new Response('Test User not found', { status: 404 }); + + const fakeCancelProPlanSubscriptionEvent = { + data: { + object: {}, + }, + type: 'customer.subscription.deleted', + }; + + const response = await fetch(`${getHomeUrl()}/api/webhooks/stripe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fakeEvent: fakeCancelProPlanSubscriptionEvent, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return new Response(JSON.stringify({ success: true }), { status: 200 }); + } catch (error) { + console.error(error); + return new Response('Error', { status: 500 }); + } +}; + +export default handler; diff --git a/pages/api/cypress/test-subscription-plan-payment.ts b/pages/api/cypress/test-subscription-plan-payment.ts new file mode 100644 index 0000000000..abbe605dfa --- /dev/null +++ b/pages/api/cypress/test-subscription-plan-payment.ts @@ -0,0 +1,308 @@ +// NOTE: This file is intended for testing the subscription plan payment process. +import { getHomeUrl } from '@/utils/app/api'; +import { getAdminSupabaseClient } from '@/utils/server/supabase'; + +import { TEST_PAYMENT_USER } from '@/cypress/e2e/account'; +import { z } from 'zod'; + +export const config = { + runtime: 'edge', +}; + +const requestSchema = z.object({ + plan: z.enum(['pro', 'ultra']), +}); + +const handler = async (req: Request): Promise => { + const isProd = process.env.VERCEL_ENV === 'production'; + + if (req.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + + try { + if (isProd) { + return new Response('Not allowed in production', { status: 403 }); + } + // Get user id from supabase + const supabase = getAdminSupabaseClient(); + const { data: userProfile } = await supabase + .from('profiles') + .select('id') + .eq('email', TEST_PAYMENT_USER.email) + .single(); + + if (!userProfile) + return new Response('Test User not found', { status: 404 }); + + const fakeProPlanSubscriptionEvent = { + id: 'evt_1PbZayEEvfd1BzvuIWPC3rOO', + object: 'event', + api_version: '2020-08-27', + created: 1720752748, + data: { + object: { + id: 'cs_test_b1oa9hQjSmRAIY7VADODVvgEsT533JfITznflkeTCh60nBlfFlyvGr8MbJ', + object: 'checkout.session', + after_expiration: null, + allow_promotion_codes: true, + amount_subtotal: 999, + amount_total: 999, + automatic_tax: { + enabled: false, + liability: null, + status: null, + }, + billing_address_collection: 'auto', + cancel_url: 'https://stripe.com', + client_reference_id: userProfile.id, + client_secret: null, + consent: null, + consent_collection: { + payment_method_reuse_agreement: null, + promotions: 'none', + terms_of_service: 'none', + }, + created: 1720752727, + currency: 'usd', + currency_conversion: null, + custom_fields: [], + custom_text: { + after_submit: null, + shipping_address: null, + submit: null, + terms_of_service_acceptance: null, + }, + customer: 'cus_QSUavMdAX9Zdp3', + customer_creation: 'if_required', + customer_details: { + address: { + city: null, + country: 'MO', + line1: null, + line2: null, + postal_code: null, + state: null, + }, + email: TEST_PAYMENT_USER.email, + name: 'StripeCardTest', + phone: null, + tax_exempt: 'none', + tax_ids: [], + }, + customer_email: null, + expires_at: 1720839127, + invoice: 'in_1PbZaqEEvfd1Bzvut22eUx6c', + invoice_creation: null, + livemode: false, + locale: 'auto', + metadata: { + plan_code: 'MONTHLY_PRO_PLAN_SUBSCRIPTION', + }, + mode: 'subscription', + payment_intent: null, + payment_link: 'plink_1N09fvEEvfd1Bzvu8RHD78kb', + payment_method_collection: 'always', + payment_method_configuration_details: { + id: 'pmc_1MzuEJEEvfd1BzvuSIHOnamP', + parent: null, + }, + payment_method_options: { + card: { + request_three_d_secure: 'automatic', + }, + }, + payment_method_types: ['card', 'link'], + payment_status: 'paid', + phone_number_collection: { + enabled: false, + }, + recovered_from: null, + saved_payment_method_options: { + allow_redisplay_filters: ['always'], + payment_method_remove: null, + payment_method_save: null, + }, + setup_intent: null, + shipping: null, + shipping_address_collection: null, + shipping_options: [], + shipping_rate: null, + status: 'complete', + submit_type: 'auto', + subscription: 'sub_1PbZaqEEvfd1BzvunkVi2BBG', + success_url: 'https://stripe.com', + total_details: { + amount_discount: 0, + amount_shipping: 0, + amount_tax: 0, + }, + ui_mode: 'hosted', + url: null, + }, + }, + livemode: false, + pending_webhooks: 4, + request: { + id: null, + idempotency_key: null, + }, + type: 'checkout.session.completed', + }; + + const fakeUltraPlanSubscriptionEvent = { + id: 'evt_1Pbb50EEvfd1Bzvu6cO2Pg6O', + object: 'event', + api_version: '2020-08-27', + created: 1720758454, + data: { + object: { + id: 'cs_test_a1OykWFuJxe6mR80M6mmm73ENFnZ8VJ2cDyrpxEXIzp74uVQtaRwDRMr7n', + object: 'checkout.session', + after_expiration: null, + allow_promotion_codes: false, + amount_subtotal: 2999, + amount_total: 2999, + automatic_tax: { + enabled: false, + liability: null, + status: null, + }, + billing_address_collection: 'auto', + cancel_url: 'https://stripe.com', + client_reference_id: userProfile.id, + client_secret: null, + consent: null, + consent_collection: { + payment_method_reuse_agreement: null, + promotions: 'none', + terms_of_service: 'none', + }, + created: 1720758430, + currency: 'usd', + currency_conversion: null, + custom_fields: [], + custom_text: { + after_submit: null, + shipping_address: null, + submit: null, + terms_of_service_acceptance: null, + }, + customer: 'cus_QSW7Jm6qEisEf2', + customer_creation: 'if_required', + customer_details: { + address: { + city: null, + country: 'MO', + line1: null, + line2: null, + postal_code: null, + state: null, + }, + email: TEST_PAYMENT_USER.email, + name: 'StripeCardTest', + phone: null, + tax_exempt: 'none', + tax_ids: [], + }, + customer_email: null, + expires_at: 1720844830, + invoice: 'in_1Pbb4tEEvfd1BzvueDlUtXzd', + invoice_creation: null, + livemode: false, + locale: 'auto', + metadata: { + plan_code: 'monthly_ultra_plan_subscription', + }, + mode: 'subscription', + payment_intent: null, + payment_link: 'plink_1PLhmVEEvfd1BzvuRYKzYTER', + payment_method_collection: 'always', + payment_method_configuration_details: { + id: 'pmc_1MzuEJEEvfd1BzvuSIHOnamP', + parent: null, + }, + payment_method_options: { + card: { + request_three_d_secure: 'automatic', + }, + }, + payment_method_types: ['card', 'link'], + payment_status: 'paid', + phone_number_collection: { + enabled: false, + }, + recovered_from: null, + saved_payment_method_options: { + allow_redisplay_filters: ['always'], + payment_method_remove: null, + payment_method_save: null, + }, + setup_intent: null, + shipping: null, + shipping_address_collection: null, + shipping_options: [], + shipping_rate: null, + status: 'complete', + submit_type: 'auto', + subscription: 'sub_1Pbb4tEEvfd1BzvuNYGOFJLD', + success_url: 'https://stripe.com', + total_details: { + amount_discount: 0, + amount_shipping: 0, + amount_tax: 0, + }, + ui_mode: 'hosted', + url: null, + }, + }, + livemode: false, + pending_webhooks: 4, + request: { + id: null, + idempotency_key: null, + }, + type: 'checkout.session.completed', + }; + + // read req body (plan: pro/ultra) + const body = await req.json(); + const validationResult = requestSchema.safeParse(body); + + if (!validationResult.success) { + return new Response( + 'Invalid request body. Plan must be either "pro" or "ultra".', + { status: 400 }, + ); + } + + const { plan } = validationResult.data; + + if (!plan) { + throw new Error('Missing plan in the request body'); + } + const response = await fetch(`${getHomeUrl()}/api/webhooks/stripe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fakeEvent: + plan === 'pro' + ? fakeProPlanSubscriptionEvent + : fakeUltraPlanSubscriptionEvent, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return new Response(JSON.stringify({ success: true }), { status: 200 }); + } catch (error) { + console.error(error); + return new Response('Error', { status: 500 }); + } +}; + +export default handler; diff --git a/pages/api/stripe/user-subscription-detail.ts b/pages/api/stripe/user-subscription-detail.ts new file mode 100644 index 0000000000..39c340698b --- /dev/null +++ b/pages/api/stripe/user-subscription-detail.ts @@ -0,0 +1,79 @@ +import { fetchUserProfileWithAccessToken } from '@/utils/server/auth'; +import StripeHelper, { + fetchSubscriptionIdByUserId, +} from '@/utils/server/stripe/strip_helper'; + +import Stripe from 'stripe'; + +export const config = { + runtime: 'edge', +}; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2022-11-15', +}); + +const handler = async (req: Request) => { + if (req.method !== 'GET') { + return new Response('Method Not Allowed', { status: 405 }); + } + + try { + // Step 1: Get User Profile and Subscription ID + const userProfile = await fetchUserProfileWithAccessToken(req); + if (userProfile.plan !== 'pro' && userProfile.plan !== 'ultra') { + throw new Error('User is not in Paid plan'); + } + const defaultResponse = () => { + return new Response( + JSON.stringify({ + data: undefined, + }), + { status: 200 }, + ); + }; + const subscriptionId = await fetchSubscriptionIdByUserId(userProfile.id); + if (!subscriptionId) { + return defaultResponse(); + } + + // Step 2: Retrieve Current Subscription + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const productId = subscription.items.data[0].price.product; + if (!productId || typeof productId !== 'string') { + return defaultResponse(); + } + const product = await StripeHelper.product.getProductByProductId( + productId, + 'subscription', + ); + if (product.type !== 'paid_plan') { + return defaultResponse(); + } + + // Step 3: Extract User Plan and Currency + const userPlan = product.productValue; + const currency = subscription.currency; + + return new Response( + JSON.stringify({ + data: { + userPlan, + subscriptionCurrency: currency, + }, + }), + { + status: 200, + }, + ); + } catch (error) { + console.error(error); + if (error instanceof Error) { + return new Response(error.message, { status: 500 }); + } + + return new Response('Internal Server Error', { status: 500 }); + } +}; + +export default handler; diff --git a/pages/api/webhooks/stripe.ts b/pages/api/webhooks/stripe.ts index 499b775e87..408c8a2680 100644 --- a/pages/api/webhooks/stripe.ts +++ b/pages/api/webhooks/stripe.ts @@ -15,11 +15,15 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { }); const handler = async (req: NextApiRequest, res: NextApiResponse) => { + let isFakeEvent = false; + const isProd = process.env.VERCEL_ENV === 'production'; + if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); res.status(405).end('Method Not Allowed'); return; } + const sig = req.headers['stripe-signature'] as string; let event: Stripe.Event; @@ -33,31 +37,43 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { process.env.STRIPE_WEBHOOK_SECRET!, ); } catch (err) { - console.error( - `Webhook signature verification failed.`, - (err as any).message, - ); - return res.status(400).send(`Webhook signature verification failed.`); + const bodyData = JSON.parse(rawBody.toString()); + // The fakeEvent is only available in the non-prod environment, its used by Cypress `api/cypress/test-subscription-plan-payment` + if (!isProd && bodyData.fakeEvent) { + event = bodyData.fakeEvent; + isFakeEvent = true; + } else { + console.error( + `Webhook signature verification failed.`, + (err as any).message, + ); + return res.status(400).send(`Webhook signature verification failed.`); + } } try { switch (event.type) { case 'checkout.session.completed': - // One time payment / Initial Monthly Pro Plan Subscription + // One time payment / Initial Monthly [Pro / Ultra] Plan Subscription / Top Up Request + console.log('✅ checkout.session.completed'); await handleCheckoutSessionCompleted( event.data.object as Stripe.Checkout.Session, + isFakeEvent, ); break; case 'customer.subscription.updated': - // Monthly Pro Plan Subscription recurring payment + // Monthly Pro / Ultra Plan Subscription recurring payment + console.log('✅ customer.subscription.updated'); await handleCustomerSubscriptionUpdated( event.data.object as Stripe.Subscription, ); break; case 'customer.subscription.deleted': // Monthly Pro Plan Subscription removal + console.log('✅ customer.subscription.deleted'); await handleCustomerSubscriptionDeleted( event.data.object as Stripe.Subscription, + isFakeEvent, ); break; default: @@ -67,6 +83,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { // Return a response to acknowledge receipt of the event res.json({ received: true }); } catch (error) { + console.log(error); if (error instanceof Error) { const user = error.cause && typeof error.cause === 'object' && 'user' in error.cause @@ -74,7 +91,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { : undefined; try { - await sendReportForStripeWebhookError(error.message, event, user); + await sendReportForStripeWebhookError( + error.message, + event, + user, + error.cause, + ); } catch (error) { console.error(error); throw error; diff --git a/public/locales/zh-Hans/common.json b/public/locales/zh-Hans/common.json index abc783a0bf..6bcea7678a 100644 --- a/public/locales/zh-Hans/common.json +++ b/public/locales/zh-Hans/common.json @@ -11,6 +11,8 @@ "Upload": "上传", "File uploaded successfully": "文件上传成功", "File upload failed": "文件上传失败", + "There was a problem when changing subscription plan, please contact support team": "当您更改订阅方案时,发生问题,请联系支援团队", + "There was a problem fetching user subscription detail, please contact support team": "取得使用者订阅详情时,发生问题,请联系支援团队", "Error": "错误", "Retry": "重试", "Report a bug in this conversation to Chat Everywhere": "在此对话中报告错误", diff --git a/public/locales/zh-Hans/model.json b/public/locales/zh-Hans/model.json index 54f62add2f..3e8e0ddd34 100644 --- a/public/locales/zh-Hans/model.json +++ b/public/locales/zh-Hans/model.json @@ -46,6 +46,7 @@ "Please sign-up before upgrading to pro plan": "请先注册和登入帐号", "Upgrade": "升级为专业版", "Upgrade for one month only": "只购买一个月的专业版", + "Upgrade to Ultra": "升級到Ultra专业版", "Unlock all the amazing features by upgrading to our Pro plan, cancel anytime!": "升级为专业版,解锁以下所有功能!", "AI Image": "AI图像生成", "Creative": "创意", @@ -238,6 +239,12 @@ "Error deleting file": "文件删除失败", "Download failed!": "下载失败", "Downloading...": "下载中...", + "Please sign-up before upgrading to paid plan": "请在注册后再升级到付费方案", + "MONTHLY": "月费", + "YEARLY": "年费", + "Chat with document": "文档对话", + "Need Assistance with Subscription Change?": "需要订阅变更的帮助?", + "If you wish to upgrade or downgrade your subscription, kindly contact our support team via email at": "如果您想升级或降级您的订阅,请通过电邮联系我们的支援团队", "Clear Browser Chat History and Sign out": "清除浏览器对话历史并登出", "You can choose to clear your chat history or just sign out.": "您可以选择清除对话历史或只登出。", "Sign out option": "登出选项", diff --git a/public/locales/zh-Hant/common.json b/public/locales/zh-Hant/common.json index 6adea6aabc..2393fe79dd 100644 --- a/public/locales/zh-Hant/common.json +++ b/public/locales/zh-Hant/common.json @@ -11,6 +11,8 @@ "Upload": "上傳", "File uploaded successfully": "檔案上傳成功", "File upload failed": "檔案上傳失敗", + "There was a problem when changing subscription plan, please contact support team": "當您更改訂閱方案時,發生問題,請聯絡支援團隊", + "There was a problem fetching user subscription detail, please contact support team": "取得使用者訂閱詳細資訊時,發生問題,請聯絡支援團隊", "Error": "錯誤", "Retry": "重試", "Report a bug in this conversation to Chat Everywhere": "在此對話中報告錯誤", diff --git a/public/locales/zh-Hant/model.json b/public/locales/zh-Hant/model.json index 9cda777f0c..3c4c46d0bd 100644 --- a/public/locales/zh-Hant/model.json +++ b/public/locales/zh-Hant/model.json @@ -44,6 +44,7 @@ "Please sign-up before upgrading to pro plan": "請先註冊和登入帳號", "Upgrade": "升級到Pro專業版", "Upgrade for one month only": "只購買一個月的Pro專業版", + "Upgrade to Ultra": "升級到Ultra專業版", "Unlock all the amazing features by upgrading to our Pro plan, cancel anytime!": "升級為專業版,解鎖以下的所有功能!", "Creative": "創意", "Balanced": "平衡", @@ -239,6 +240,12 @@ "Error deleting file": "檔案刪除失敗", "Download failed!": "下載失敗", "Downloading...": "下載中...", + "Please sign-up before upgrading to paid plan": "請在註冊後再升級到付費方案", + "MONTHLY": "月費", + "YEARLY": "年費", + "Chat with document": "文檔對話", + "Need Assistance with Subscription Change?": "需要訂閱變更的協助?", + "If you wish to upgrade or downgrade your subscription, kindly contact our support team via email at": "如果您想升級或降級您的訂閱,請通過電郵聯繫我們的支援團隊", "Clear Browser Chat History and Sign out": "清除瀏覽器對話歷史並登出", "You can choose to clear your chat history or just sign out.": "您可以選擇清除對話歷史或只登出。", "Sign out option": "登出選項", diff --git a/styles/globals.css b/styles/globals.css index a6dde0de33..01b5abca3f 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -161,3 +161,10 @@ body { } } } + +.text-clip-transparent { + color: transparent; + -webkit-background-clip: text; + -webkit-text-stroke-width: 1px; + -webkit-text-stroke-color: transparent; +} diff --git a/tailwind.config.js b/tailwind.config.js index 9914cb782a..31f97eac7b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -91,6 +91,14 @@ module.exports = { transform: 'translateX(-100%)', }, }, + 'background-gradient-slide': { + '0%': { + backgroundPosition: '-20% 50%', + }, + '100%': { + backgroundPosition: '100% 50%', + }, + }, }, animation: { 'slide-from-left': @@ -99,6 +107,17 @@ module.exports = { 'slide-to-left 0.25s cubic-bezier(0.82, 0.085, 0.395, 0.895)', 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', + 'background-gradient-slide': + 'background-gradient-slide 6s linear infinite', + }, + backgroundSize: { + '500%': '500% 100%', + }, + backgroundImage: { + 'gradient-ultra': + 'linear-gradient(to right, #fd68a6, #6c62f7, #fd68a6)', + 'gradient-pro': + 'linear-gradient(to right, rgb(99 102 241), rgb(129 140 248), rgb(99 102 241))', }, }, }, diff --git a/types/paid_plan.ts b/types/paid_plan.ts new file mode 100644 index 0000000000..7c3e04e39e --- /dev/null +++ b/types/paid_plan.ts @@ -0,0 +1,14 @@ +export enum PaidPlan { + ProMonthly = 'pro-monthly', + ProOneTime = 'pro-one-time', + ProYearly = 'pro-yearly', + UltraMonthly = 'ultra-monthly', + UltraOneTime = 'ultra-one-time', + UltraYearly = 'ultra-yearly', +} + +export enum TopUpRequest { + ImageCredit = 'image-credit', + GPT4Credit = 'gpt4-credit', +} +export type SubscriptionPlan = 'free' | 'pro' | 'ultra' | 'edu'; diff --git a/types/stripe-product.ts b/types/stripe-product.ts new file mode 100644 index 0000000000..f61d3b9639 --- /dev/null +++ b/types/stripe-product.ts @@ -0,0 +1,63 @@ +import type { SubscriptionPlan } from './paid_plan'; + +import type Stripe from 'stripe'; + +export type StripeProductType = 'top_up' | 'paid_plan'; + +export type StripeProductPaidPlanType = Exclude< + SubscriptionPlan, + 'free' | 'edu' +>; +export type StripeProductTopUpType = + | '50_GPT4_CREDIT' + | '150_GPT4_CREDIT' + | '300_GPT4_CREDIT' + | '100_IMAGE_CREDIT' + | '500_IMAGE_CREDIT'; + +export type StripeProductName = + | StripeProductPaidPlanType + | StripeProductTopUpType; + +export interface BaseStripeProduct { + type: StripeProductType; + mode: Stripe.Checkout.Session.Mode; + productValue: StripeProductName; + productId: string; + note?: string; +} + +export interface StripeTopUpProduct extends BaseStripeProduct { + type: 'top_up'; + credit: number; +} + +export type StripePaidPlanProduct = + | StripeSubscriptionPaidPlanProduct + | StripeOneTimePaidPlanProduct; +export interface StripeSubscriptionPaidPlanProduct extends BaseStripeProduct { + type: 'paid_plan'; + mode: 'subscription'; +} +export interface StripeOneTimePaidPlanProduct extends BaseStripeProduct { + type: 'paid_plan'; + mode: 'payment'; + givenDays: number; +} + +export type NewStripeProduct = StripeTopUpProduct | StripePaidPlanProduct; + +export type PaidPlanCurrencyType = 'usd' | 'twd'; +export type AvailablePaidPlanType = + | 'ultra-yearly' + | 'ultra-monthly' + | 'pro-monthly'; + +export type PaidPlanLink = { + [currency in PaidPlanCurrencyType]: { + price_id: string; + link: string; + }; +}; + +export type PaidPlanLinks = Record; diff --git a/types/user.ts b/types/user.ts index bed328804c..5aa4693606 100644 --- a/types/user.ts +++ b/types/user.ts @@ -1,14 +1,19 @@ import type { ExportFormatV4 } from './export'; +import type { SubscriptionPlan } from './paid_plan'; import type { PluginID } from './plugin'; +import type { + PaidPlanCurrencyType, + StripeProductPaidPlanType, +} from './stripe-product'; import type { SupabaseClient } from '@supabase/supabase-js'; +export type { SubscriptionPlan } from './paid_plan'; + export interface User extends UserProfile { email: string; } -export type SubscriptionPlan = 'free' | 'pro' | 'ultra' | 'edu'; - export interface UserConversation { id: string; uid: string; @@ -56,3 +61,8 @@ export type UserProfileQuery = RequiredOne< UserProfileQueryProps, 'userId' | 'email' >; + +export interface UserSubscriptionDetail { + userPlan: StripeProductPaidPlanType | null; + subscriptionCurrency: PaidPlanCurrencyType; +} diff --git a/utils/app/const.ts b/utils/app/const.ts index d39739259f..64a07d62e7 100644 --- a/utils/app/const.ts +++ b/utils/app/const.ts @@ -1,8 +1,18 @@ import { OpenAIModels, fallbackModelID } from '@/types/openai'; +import type { SubscriptionPlan } from '@/types/user'; import dayjs from 'dayjs'; import { v4 as uuidv4 } from 'uuid'; +export { + STRIPE_PAID_PLAN_LINKS, + STRIPE_PLAN_CODE_GPT4_CREDIT, + STRIPE_PLAN_CODE_IMAGE_CREDIT, + GPT4_CREDIT_PURCHASE_LINKS, + AI_IMAGE_CREDIT_PURCHASE_LINKS, + V2_CHAT_UPGRADE_LINK, +} from './stripe/stripe_config'; + export const RESPONSE_IN_CHINESE_PROMPT = `Whenever you respond in Chinese, you must respond in Traditional Chinese (繁體中文).`; export const DEFAULT_SYSTEM_PROMPT = @@ -129,6 +139,13 @@ export const newDefaultConversation = { lastUpdateAtUTC: dayjs().valueOf(), }; +export const OrderedSubscriptionPlans: SubscriptionPlan[] = [ + 'free', + 'pro', + 'ultra', + 'edu', +]; + // Gemini File Upload Constants // NOTE: https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models // NOTE:https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/document-understanding diff --git a/utils/app/eventTracking.ts b/utils/app/eventTracking.ts index 6366cb123e..e550d250c6 100644 --- a/utils/app/eventTracking.ts +++ b/utils/app/eventTracking.ts @@ -162,7 +162,6 @@ export type PayloadType = { mjQueueCleanupJobOneWeekAgo?: string; mjQueueCleanupJobFiveMinutesAgo?: string; mjQueueJobDetail?: MjJob; - }; export interface UserPostHogProfile { diff --git a/utils/app/stripe/stripe_config.ts b/utils/app/stripe/stripe_config.ts new file mode 100644 index 0000000000..5e32916d8d --- /dev/null +++ b/utils/app/stripe/stripe_config.ts @@ -0,0 +1,54 @@ +import { + STRIPE_PAID_PLAN_LINKS_PRODUCTION, + STRIPE_PAID_PLAN_LINKS_STAGING, +} from './stripe_paid_plan_links_config'; +import { + STRIPE_PRODUCT_LIST_PRODUCTION, + STRIPE_PRODUCT_LIST_STAGING, +} from './stripe_product_list_config'; + +// STRIPE CREDIT CODE +export const STRIPE_PLAN_CODE_GPT4_CREDIT = 'GPT4_CREDIT'; +export const STRIPE_PLAN_CODE_IMAGE_CREDIT = 'IMAGE_CREDIT'; + +export const STRIPE_PRODUCT_LIST = + process.env.NEXT_PUBLIC_ENV === 'production' + ? STRIPE_PRODUCT_LIST_PRODUCTION + : STRIPE_PRODUCT_LIST_STAGING; + +export const STRIPE_PAID_PLAN_LINKS = + process.env.NEXT_PUBLIC_ENV === 'production' + ? STRIPE_PAID_PLAN_LINKS_PRODUCTION + : STRIPE_PAID_PLAN_LINKS_STAGING; + +// =========== TOP UP LINKS =========== +export const GPT4_CREDIT_PURCHASE_LINKS = { + '50': + process.env.NEXT_PUBLIC_ENV === 'production' + ? 'https://buy.stripe.com/28o03Z0vE3Glak09AJ' + : 'https://buy.stripe.com/test_9AQ01v8fabrccp228b', + '150': + process.env.NEXT_PUBLIC_ENV === 'production' + ? 'https://buy.stripe.com/cN2dUP6U2dgV0JqcMW' + : 'https://buy.stripe.com/test_9AQ01v8fabrccp228b', + '300': + process.env.NEXT_PUBLIC_ENV === 'production' + ? 'https://buy.stripe.com/dR6g2Xemu5Otcs83cn' + : 'https://buy.stripe.com/test_9AQ01v8fabrccp228b', +}; +export const AI_IMAGE_CREDIT_PURCHASE_LINKS = { + '100': + process.env.NEXT_PUBLIC_ENV === 'production' + ? 'https://buy.stripe.com/fZeg2Xdiq4Kp8bS9AT' + : 'https://buy.stripe.com/test_9AQ01v8fabrccp228b', + '500': + process.env.NEXT_PUBLIC_ENV === 'production' + ? 'https://buy.stripe.com/8wMg2XcemccR2Ry8wQ' + : 'https://buy.stripe.com/test_9AQ01v8fabrccp228b', +}; + +// =========== V2 UPGRADE LINKS =========== +export const V2_CHAT_UPGRADE_LINK = + process.env.NEXT_PUBLIC_ENV === 'production' + ? 'https://buy.stripe.com/4gw9Ez6U2gt71NudRd' + : 'https://buy.stripe.com/test_dR68y152Y7aWagUcMU'; diff --git a/utils/app/stripe/stripe_paid_plan_links_config.ts b/utils/app/stripe/stripe_paid_plan_links_config.ts new file mode 100644 index 0000000000..de6297f0cc --- /dev/null +++ b/utils/app/stripe/stripe_paid_plan_links_config.ts @@ -0,0 +1,79 @@ +import type { PaidPlanLinks } from '@/types/stripe-product'; + +export const STRIPE_PAID_PLAN_LINKS_PRODUCTION: PaidPlanLinks = { + 'ultra-yearly': { + twd: { + // $8,800.00 TWD / year + link: 'https://buy.stripe.com/6oEaID6U2b8Ncs85kK', + price_id: 'price_1PGVh6EEvfd1Bzvu3OyJGTZ2', + }, + usd: { + // $279.99 USD / year + link: 'https://buy.stripe.com/fZebMH0vEdgVeAg3cF', + price_id: 'price_1PMSCyEEvfd1Bzvuk7VHjx6S', + }, + }, + 'ultra-monthly': { + twd: { + // $880.00 TWD / month + link: 'https://buy.stripe.com/8wMeYT92aekZ0Jq9B1', + price_id: 'price_1PMS9KEEvfd1BzvuBCA4LAJA', + }, + usd: { + // $29.99 USD / month + link: 'https://buy.stripe.com/4gwbMH6U27WB9fW9B2', + price_id: 'price_1PMSBdEEvfd1BzvuqUuMvUv7', + }, + }, + 'pro-monthly': { + twd: { + // $249.99 TWD / month + link: 'https://buy.stripe.com/dR65oj2DM90FeAgcNg', + price_id: 'price_1PMSIDEEvfd1BzvuegdR9cyP', + }, + usd: { + // $9.99 USD / month + link: 'https://buy.stripe.com/8wM8Av2DM0u99fWfZ1', + price_id: 'price_1N1VMjEEvfd1BzvuWqqVu9YZ', + }, + }, +}; + +export const STRIPE_PAID_PLAN_LINKS_STAGING: PaidPlanLinks = { + 'ultra-yearly': { + twd: { + // $8,800.00 TWD / year + link: 'https://buy.stripe.com/test_8wM9C5fHCan8agUdR9', + price_id: 'price_1PLiWVEEvfd1Bzvu7voi21Jw', + }, + usd: { + // $279.99 USD / year + link: 'https://buy.stripe.com/test_3csaG952Y2UG74IfZg', + price_id: 'price_1PLiWmEEvfd1BzvuDFmiLKI6', + }, + }, + 'ultra-monthly': { + twd: { + // $880.00 TWD / month + link: 'https://buy.stripe.com/test_fZe6pT1QM1QC2Os6oF', + price_id: 'price_1PLiWBEEvfd1BzvunVr1yZ55', + }, + usd: { + // $29.99 USD / month + link: 'https://buy.stripe.com/test_cN29C5dzu8f0dt6fZe', + price_id: 'price_1PLhlhEEvfd1Bzvu0UEqwm9y', + }, + }, + 'pro-monthly': { + twd: { + // $249.99 TWD / month + link: 'https://buy.stripe.com/test_6oE01v1QM66S74I7sH', + price_id: 'price_1PLhJREEvfd1BzvuxCM477DD', + }, + usd: { + // $9.99 USD / month + link: 'https://buy.stripe.com/test_4gw4hLcvq52Odt6fYY', + price_id: 'price_1N09fTEEvfd1BzvuJwBCAfg2', + }, + }, +}; diff --git a/utils/app/stripe/stripe_product_list_config.ts b/utils/app/stripe/stripe_product_list_config.ts new file mode 100644 index 0000000000..6fc6987e78 --- /dev/null +++ b/utils/app/stripe/stripe_product_list_config.ts @@ -0,0 +1,123 @@ +import type { NewStripeProduct } from '@/types/stripe-product'; + +export const STRIPE_PRODUCT_LIST_STAGING: NewStripeProduct[] = [ + { + type: 'paid_plan', + mode: 'payment', + givenDays: 7, + productValue: 'pro', + productId: 'prod_P39kXR1vgNBVZh', + note: 'This is a v2 weekly Pro plan', + }, + { + type: 'paid_plan', + mode: 'subscription', + productValue: 'ultra', + productId: 'prod_QC5xRJFNyaB3h7', + }, + { + type: 'paid_plan', + mode: 'subscription', + productValue: 'pro', + productId: 'prod_Nlh3dRKPO799ja', + }, + { + type: 'top_up', + mode: 'payment', + productValue: '50_GPT4_CREDIT', + productId: 'prod_OKKu2YQZyaJTYN', + credit: 50, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '150_GPT4_CREDIT', + productId: 'prod_OKKu2YQZyaJTYN', + credit: 150, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '300_GPT4_CREDIT', + productId: 'prod_OKKu2YQZyaJTYN', + credit: 300, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '500_IMAGE_CREDIT', + productId: 'prod_OKJgVwM66OOWuR', + credit: 500, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '100_IMAGE_CREDIT', + productId: 'prod_OKJgVwM66OOWuR', + credit: 100, + }, +]; +export const STRIPE_PRODUCT_LIST_PRODUCTION: NewStripeProduct[] = [ + { + type: 'paid_plan', + mode: 'payment', + givenDays: 7, + productValue: 'pro', + productId: 'prod_P39h2AVZANAN1M', + note: 'This is a v2 weekly Pro plan', + }, + { + type: 'paid_plan', + mode: 'subscription', + productValue: 'ultra', + productId: 'prod_Q6j96oOouZMFN4', + }, + { + type: 'paid_plan', + mode: 'subscription', + productValue: 'ultra', + productId: 'prod_PGES2QxP0aYFr4', + note: 'This is the pre-sell plan for the Ultra plan', + }, + { + type: 'paid_plan', + mode: 'subscription', + productValue: 'pro', + productId: 'prod_NlR6az1csuoBHl', + }, + { + type: 'top_up', + mode: 'payment', + productValue: '50_GPT4_CREDIT', + productId: 'prod_Nofw6ncuYiYD81', + credit: 50, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '150_GPT4_CREDIT', + productId: 'prod_Nog8i22B8eLX6y', + credit: 150, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '300_GPT4_CREDIT', + productId: 'prod_Nog91rmXzSJY1w', + credit: 300, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '100_IMAGE_CREDIT', + productId: 'prod_OKYWXZGkysAtFS', + credit: 100, + }, + { + type: 'top_up', + mode: 'payment', + productValue: '500_IMAGE_CREDIT', + productId: 'prod_OKYouQss6inD9q', + credit: 500, + }, +]; diff --git a/utils/app/ui.tsx b/utils/app/ui.tsx index 36897ee270..12a4cd69fb 100644 --- a/utils/app/ui.tsx +++ b/utils/app/ui.tsx @@ -41,7 +41,7 @@ export const PlanDetail = { 'LINE connection', 'Unlimited GPT-4', 'Unlimited MidJourney generation', - 'Chat with document (coming soon)', + 'Chat with document', ], }, combinedSimplify: [ diff --git a/utils/server/mjServiceServerHelper.ts b/utils/server/mjServiceServerHelper.ts index 6c0c9ce6ac..bd76af7844 100644 --- a/utils/server/mjServiceServerHelper.ts +++ b/utils/server/mjServiceServerHelper.ts @@ -35,7 +35,7 @@ export const trackFailedEvent = (jobInfo: MjJob, errorMessage: string) => { mjImageGenTotalProcessingTimeInSeconds: totalProcessingTimeInSeconds, mjImageGenErrorMessage: errorMessage, usedOnDemandCredit: jobInfo.usedOnDemandCredit || false, - lastUsedKey: jobInfo.lastUsedKey + lastUsedKey: jobInfo.lastUsedKey, }, ); return trackEventPromise; @@ -67,7 +67,7 @@ export const trackSuccessEvent = (jobInfo: MjJob) => { totalWaitingInQueueTimeInSeconds, mjImageGenTotalProcessingTimeInSeconds: totalProcessingTimeInSeconds, usedOnDemandCredit: jobInfo.usedOnDemandCredit || false, - lastUsedKey: jobInfo.lastUsedKey + lastUsedKey: jobInfo.lastUsedKey, }, ); @@ -82,8 +82,8 @@ export const trackCleanupJobEvent = ({ fiveMinutesAgo, }: { event: - | 'MJ Queue Cleanup Completed / Failed Job' - | 'MJ Queue Cleanup Processing Job'; + | 'MJ Queue Cleanup Completed / Failed Job' + | 'MJ Queue Cleanup Processing Job'; executedAt: string; enqueuedAt: string; oneWeekAgo?: string; @@ -120,8 +120,9 @@ export const OriginalMjLogEvent = async ({ if (errorMessage) { payloadToLog.imageGenerationFailed = 'true'; payloadToLog.imageGenerationErrorMessage = errorMessage; - payloadToLog.imageGenerationPrompt = `${promptBeforeProcessing || 'N/A' - } -> ${generationPrompt || 'N/A'}`; + payloadToLog.imageGenerationPrompt = `${ + promptBeforeProcessing || 'N/A' + } -> ${generationPrompt || 'N/A'}`; } await serverSideTrackEvent(userId, 'AI image generation', payloadToLog); diff --git a/utils/server/resend.ts b/utils/server/resend.ts index 9f767da000..512afadc0d 100644 --- a/utils/server/resend.ts +++ b/utils/server/resend.ts @@ -45,48 +45,55 @@ export async function sendReportForStripeWebhookError( subject = '', event: Stripe.Event, user?: UserProfile, + cause?: any, ) { const session = event.data.object as Stripe.Checkout.Session; const stripeSubscriptionId = session.subscription as string; - const paymentDateInCanadaTime = dayjs - .tz(session.created * 1000, 'America/Toronto') - .format('YYYY-MM-DD HH:mm:ss'); - const paymentDateInTaiwanTime = dayjs - .tz(session.created * 1000, 'Asia/Taipei') - .format('YYYY-MM-DD HH:mm:ss'); + + const formatDate = (timestamp: number, timezone: string) => + dayjs.tz(timestamp * 1000, timezone).format('YYYY-MM-DD HH:mm:ss'); + + const paymentDateInCanadaTime = formatDate( + session.created, + 'America/Toronto', + ); + const paymentDateInTaiwanTime = formatDate(session.created, 'Asia/Taipei'); + const paymentDateHTML = ` -

Stripe Session Date(payment date): ${paymentDateInCanadaTime} (Canada Time)

-

Stripe Session Date(payment date): ${paymentDateInTaiwanTime} (Taiwan Time)

+

Stripe Session Date(payment date): ${paymentDateInCanadaTime} (Canada Time)

+

Stripe Session Date(payment date): ${paymentDateInTaiwanTime} (Taiwan Time)

`; const userDataHTML = user ? ` -

User id: ${user?.id}

-

User Email: ${user?.email}

- ` +

User id: ${user.id}

+

User Email: ${user.email}

+ ` : ''; + const referenceDataHTML = session ? ` -

Stripe Session Reference

-
-  ${JSON.stringify(session)}
-
` - : ` -

Stripe Event

-
-${JSON.stringify(event)}
-
-`; - await sendReport( - `Stripe Webhook Error - ${subject}`, +

Stripe Session Reference

+
${JSON.stringify(session, null, 2)}
+

Cause data

+
${JSON.stringify(cause, null, 2)}
` + : ` +

Stripe Event

+
${JSON.stringify(event, null, 2)}
+

Cause data

+
${JSON.stringify(cause, null, 2)}
+ `; + + const emailHTML = `

Stripe Webhook Error - ${subject}

Stripe Session Id: ${session.id}

${paymentDateHTML} -

Stripe event type : ${event.type}

-

Stripe Strip Subscription Id: ${stripeSubscriptionId}

+

Stripe event type: ${event.type}

+

Stripe Subscription Id: ${stripeSubscriptionId}

${userDataHTML} ${referenceDataHTML} - `, - ); + `; + + await sendReport(`Stripe Webhook Error - ${subject}`, emailHTML); } diff --git a/utils/server/stripe/getCustomerEmailByCustomerID.ts b/utils/server/stripe/getCustomerEmailByCustomerID.ts deleted file mode 100644 index 609a3658d0..0000000000 --- a/utils/server/stripe/getCustomerEmailByCustomerID.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Stripe from 'stripe'; - -export default async function getCustomerEmailByCustomerID( - customerID: string, -): Promise { - try { - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: '2022-11-15', - }); - - // We get the customer id from webhook, so we know the customer is not deleted - const customer = (await stripe.customers.retrieve( - customerID, - )) as Stripe.Customer; - if (!customer.email) { - throw new Error( - `the customer does not have an email, customer id is ${customerID}`, - ); - } - return customer.email; - } catch (e) { - throw new Error(`getCustomerEmailByCustomerID failed: ${e}`); - } -} diff --git a/utils/server/stripe/handleCheckoutSessionCompleted.ts b/utils/server/stripe/handleCheckoutSessionCompleted.ts index 732850d22b..7b797465dd 100644 --- a/utils/server/stripe/handleCheckoutSessionCompleted.ts +++ b/utils/server/stripe/handleCheckoutSessionCompleted.ts @@ -1,145 +1,105 @@ import { serverSideTrackEvent } from '@/utils/app/eventTracking'; import { PluginID } from '@/types/plugin'; +import type { + NewStripeProduct, + StripeOneTimePaidPlanProduct, +} from '@/types/stripe-product'; +import type { UserProfile } from '@/types/user'; import { addCredit, getAdminSupabaseClient, userProfileQuery, } from '../supabase'; -import updateUserAccount from './updateUserAccount'; +import StripeHelper, { + updateUserAccountByEmail, + updateUserAccountById, +} from './strip_helper'; import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; import type Stripe from 'stripe'; -const ONE_TIME_PRO_PLAN_FOR_1_MONTH = - process.env.STRIPE_PLAN_CODE_ONE_TIME_PRO_PLAN_FOR_1_MONTH; +const supabase = getAdminSupabaseClient(); -const IMAGE_CREDIT = process.env.STRIPE_PLAN_CODE_IMAGE_CREDIT; -const GPT4_CREDIT = process.env.STRIPE_PLAN_CODE_GPT4_CREDIT; +dayjs.extend(utc); export default async function handleCheckoutSessionCompleted( session: Stripe.Checkout.Session, + isFakeEvent = false, ): Promise { const userId = session.client_reference_id; const email = session.customer_details?.email; - const planCode = session.metadata?.plan_code; - const planGivingWeeks = session.metadata?.plan_giving_weeks; - const credit = session.metadata?.credit; const stripeSubscriptionId = session.subscription as string; - if (!planCode && !planGivingWeeks) { - throw new Error('no plan code or plan giving weeks from Stripe webhook'); - } + const sessionId = session.id; + const product = await StripeHelper.product.getProductByCheckoutSessionId( + sessionId, + session.mode, + ); if (!email) { throw new Error('missing Email from Stripe webhook'); } - - const isTopUpCreditRequest = - (planCode === IMAGE_CREDIT || planCode === GPT4_CREDIT) && credit; - // Handle TopUp Image Credit / GPT4 Credit - if (isTopUpCreditRequest) { - return await addCreditToUser( - email, - +credit, - planCode === IMAGE_CREDIT ? PluginID.IMAGE_GEN : PluginID.GPT4, - ); - } - - const sinceDate = dayjs.unix(session.created).utc().toDate(); - const proPlanExpirationDate = await getProPlanExpirationDate( - planGivingWeeks, - planCode, + const user = await userProfileQuery({ + client: supabase, email, - sinceDate, - ); - - serverSideTrackEvent(userId || 'N/A', 'New paying customer', { - paymentDetail: - !session.amount_subtotal || session.amount_subtotal <= 50000 - ? 'One-time' - : 'Monthly', }); - // Update user account by User id - if (userId) { - await updateUserAccount({ - upgrade: true, - userId, - stripeSubscriptionId, - proPlanExpirationDate: proPlanExpirationDate, - }); - } else { - // Update user account by Email - await updateUserAccount({ - upgrade: true, - email: email!, - stripeSubscriptionId, - proPlanExpirationDate: proPlanExpirationDate, - }); - } -} - -async function getProPlanExpirationDate( - planGivingWeeks: string | undefined, - planCode: string | undefined, - email: string, - sinceDate: Date, -) { - // Takes plan_giving_weeks priority over plan_code - if (planGivingWeeks && typeof planGivingWeeks === 'string') { - // Get users' pro expiration date - const supabase = getAdminSupabaseClient(); - const user = await userProfileQuery({ - client: supabase, - email, - }); - const userProPlanExpirationDate = user?.proPlanExpirationDate; - if (userProPlanExpirationDate) { - // when user bought one-time pro plan previously or user has referral trial - return dayjs(userProPlanExpirationDate) - .add(+planGivingWeeks, 'week') - .toDate(); - } else if (user.plan === 'pro' && !user.proPlanExpirationDate) { - // when user is pro monthly subscriber - throw new Error( - 'Monthly Pro subscriber bought one-time pro plan, should not happen', - { - cause: { - user, - }, - }, + // # Upgrade plan flow + if (product.type === 'paid_plan') { + if (session.mode === 'subscription') { + // Recurring payment flow + await handleSubscription( + session, + user, + product, + stripeSubscriptionId, + userId || undefined, + email || undefined, + isFakeEvent, + ); + } else if (session.mode === 'payment') { + // One-time payment flow + await handleOneTimePayment( + session, + user, + product as StripeOneTimePaidPlanProduct, + stripeSubscriptionId, + userId || undefined, + email || undefined, ); } else { - // when user is not pro yet - return dayjs(sinceDate).add(+planGivingWeeks, 'week').toDate(); + throw new Error(`Unhandled session mode ${session.mode}`, { + cause: { + session, + product, + }, + }); } - } else if ( - planCode?.toUpperCase() === ONE_TIME_PRO_PLAN_FOR_1_MONTH?.toUpperCase() - ) { - // Only store expiration for one month plan - return dayjs(sinceDate).add(1, 'month').toDate(); - } else { - return undefined; + } else if (product.type === 'top_up') { + // Top Up Credit flow + return await addCreditToUser( + user, + product.credit, + product.productValue === '500_IMAGE_CREDIT' || + product.productValue === '100_IMAGE_CREDIT' + ? PluginID.IMAGE_GEN + : PluginID.GPT4, + ); } } async function addCreditToUser( - email: string, + user: UserProfile, credit: number, creditType: Exclude< PluginID, PluginID.LANGCHAIN_CHAT | PluginID.IMAGE_TO_PROMPT >, ) { - // Get user id by email address - const supabase = getAdminSupabaseClient(); - const user = await userProfileQuery({ - client: supabase, - email, - }); // Check is Pro user if (user.plan === 'free') { throw Error(`A free user try to top up ${creditType}}`, { @@ -167,3 +127,121 @@ async function addCreditToUser( credit, ); } + +async function handleSubscription( + session: Stripe.Checkout.Session, + user: UserProfile, + product: NewStripeProduct, + stripeSubscriptionId: string, + userId: string | undefined, + email: string | undefined, + isFakeEvent = false, +) { + const subscription = + await StripeHelper.subscription.getSubscriptionById(stripeSubscriptionId); + // NOTE: Since the stripeSubscriptionId is fake, we use the current date to simulate the expiration date + const currentPeriodEnd = dayjs + .unix( + isFakeEvent + ? dayjs().add(1, 'month').unix() + : subscription.current_period_end, + ) + .utc() + .toDate(); + + const userIsInPaidPlan = user.plan !== 'free' && user.plan !== 'edu'; + if (userIsInPaidPlan) { + throw new Error( + 'User is already in a paid plan, cannot purchase a new plan, should issue an refund', + { + cause: { + user, + buyingProduct: product, + session, + }, + }, + ); + } + const subscriptionBillPeriod = (() => { + switch (subscription.items.data?.[0].plan?.interval) { + case 'month': + return 'Monthly'; + case 'year': + return 'Yearly'; + case 'week': + return 'Weekly'; + case 'day': + return 'Daily'; + default: + return 'Monthly'; + } + })(); + serverSideTrackEvent(userId || 'N/A', 'New paying customer', { + paymentDetail: subscriptionBillPeriod, + }); + + // Update user account by User id + if (userId) { + await updateUserAccountById({ + userId, + plan: product.productValue, + stripeSubscriptionId, + proPlanExpirationDate: currentPeriodEnd, + }); + } else { + // Update user account by Email + await updateUserAccountByEmail({ + email: email!, + plan: product.productValue, + stripeSubscriptionId, + proPlanExpirationDate: currentPeriodEnd, + }); + } +} + +async function handleOneTimePayment( + session: Stripe.Checkout.Session, + user: UserProfile, + product: StripeOneTimePaidPlanProduct, + stripeSubscriptionId: string, + userId: string | undefined, + email: string | undefined, +) { + const productGivenDays = product.givenDays; + const currentPeriodEnd = dayjs().add(productGivenDays, 'day').utc().toDate(); + + const userIsInPaidPlan = user.plan !== 'free' && user.plan !== 'edu'; + if (userIsInPaidPlan) { + throw new Error( + 'User is already in a paid plan, cannot purchase a new plan, should issue an refund', + { + cause: { + user, + buyingProduct: product, + session, + }, + }, + ); + } + serverSideTrackEvent(userId || 'N/A', 'New paying customer', { + paymentDetail: 'One-time', + }); + + // Update user account by User id + if (userId) { + await updateUserAccountById({ + userId, + plan: product.productValue, + stripeSubscriptionId, + proPlanExpirationDate: currentPeriodEnd, + }); + } else { + // Update user account by Email + await updateUserAccountByEmail({ + email: email!, + plan: product.productValue, + stripeSubscriptionId, + proPlanExpirationDate: currentPeriodEnd, + }); + } +} diff --git a/utils/server/stripe/handleCustomerSubscriptionDeleted.ts b/utils/server/stripe/handleCustomerSubscriptionDeleted.ts index 1030bd7e50..c977e66fbc 100644 --- a/utils/server/stripe/handleCustomerSubscriptionDeleted.ts +++ b/utils/server/stripe/handleCustomerSubscriptionDeleted.ts @@ -1,24 +1,59 @@ -import getCustomerEmailByCustomerID from './getCustomerEmailByCustomerID'; -import updateUserAccount from './updateUserAccount'; +import { getAdminSupabaseClient } from '../supabase'; +import { getCustomerEmailByCustomerID } from './strip_helper'; +import { TEST_PAYMENT_USER } from '@/cypress/e2e/account'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; import type Stripe from 'stripe'; +const supabase = getAdminSupabaseClient(); +dayjs.extend(utc); + export default async function handleCustomerSubscriptionDeleted( session: Stripe.Subscription, + isFakeEvent = false, ): Promise { + // Add 1 day to the current date to avoid time zone differences + + if (isFakeEvent) { + const { error: updatedUserError } = await supabase + .from('profiles') + .update({ + pro_plan_expiration_date: dayjs() + .add(1, 'month') + .add(1, 'day') + .utc() + .toDate(), + }) + .eq('email', TEST_PAYMENT_USER.email); + if (updatedUserError) throw updatedUserError; + return; + } + + const newExpirationDate = dayjs + .unix(session.current_period_end) + .add(1, 'day') + .utc() + .toDate(); const stripeSubscriptionId = session.id; if (!stripeSubscriptionId) { const customerId = session.customer as string; const email = await getCustomerEmailByCustomerID(customerId); - await updateUserAccount({ - upgrade: false, - email, - }); + const { error: updatedUserError } = await supabase + .from('profiles') + .update({ + pro_plan_expiration_date: newExpirationDate, + }) + .eq('email', email); + if (updatedUserError) throw updatedUserError; } else { - await updateUserAccount({ - upgrade: false, - stripeSubscriptionId, - }); + const { error: updatedUserError } = await supabase + .from('profiles') + .update({ + pro_plan_expiration_date: newExpirationDate, + }) + .eq('stripe_subscription_id', stripeSubscriptionId); + if (updatedUserError) throw updatedUserError; } } diff --git a/utils/server/stripe/handleCustomerSubscriptionUpdated.ts b/utils/server/stripe/handleCustomerSubscriptionUpdated.ts index 2d58708807..d433095d2e 100644 --- a/utils/server/stripe/handleCustomerSubscriptionUpdated.ts +++ b/utils/server/stripe/handleCustomerSubscriptionUpdated.ts @@ -1,10 +1,14 @@ -import getCustomerEmailByCustomerID from './getCustomerEmailByCustomerID'; -import updateUserAccount from './updateUserAccount'; +import { getAdminSupabaseClient } from '../supabase'; +import StripeHelper, { + downgradeUserAccount, + getCustomerEmailByCustomerID, +} from './strip_helper'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import type Stripe from 'stripe'; +const supabase = getAdminSupabaseClient(); dayjs.extend(utc); export default async function handleCustomerSubscriptionUpdated( @@ -12,43 +16,65 @@ export default async function handleCustomerSubscriptionUpdated( ): Promise { const stripeSubscriptionId = session.id; - if (!session.cancel_at) { - return; - } + const currentPeriodStart = dayjs + .unix(session.current_period_start) + .utc() + .toDate(); + const currentPeriodEnd = dayjs + .unix(session.current_period_end) + .utc() + .toDate(); - const cancelAtDate = dayjs.unix(session.cancel_at!).utc().toDate(); + console.log({ + currentPeriodStart, + currentPeriodEnd, + }); + const cancelAtDate = session.cancel_at + ? dayjs.unix(session.cancel_at).utc().toDate() + : null; const today = dayjs().utc().toDate(); - if (cancelAtDate < today) { + if (cancelAtDate && cancelAtDate < today) { // Downgrade to free plan if (!stripeSubscriptionId) { const customerId = session.customer as string; const email = await getCustomerEmailByCustomerID(customerId); - await updateUserAccount({ - upgrade: false, + await downgradeUserAccount({ email, }); } else { - await updateUserAccount({ - upgrade: false, + await downgradeUserAccount({ stripeSubscriptionId, }); } } else { - // Monthly Pro Plan Subscription recurring payment, extend expiration date + // Monthly Pro / Ultra Plan Subscription recurring payment, extend expiration date / change to new plan if (!stripeSubscriptionId) { - const customerId = session.customer as string; - const email = await getCustomerEmailByCustomerID(customerId); - await updateUserAccount({ - upgrade: true, - email, - }); - } else { - await updateUserAccount({ - upgrade: true, - stripeSubscriptionId, - proPlanExpirationDate: undefined, + throw new Error('Stripe subscription ID not found'); + } + + const userSubscription = + await StripeHelper.subscription.getSubscriptionById(stripeSubscriptionId); + const productId = userSubscription.items.data[0].price.product; + if (!productId || typeof productId !== 'string') { + throw new Error('The session does not have a product id', { + cause: { + session, + }, }); } + const product = await StripeHelper.product.getProductByProductId( + productId, + 'subscription', + ); + if (product.type !== 'paid_plan') return; + const { error: updatedUserError } = await supabase + .from('profiles') + .update({ + plan: product.productValue, + pro_plan_expiration_date: currentPeriodEnd, + }) + .eq('stripe_subscription_id', stripeSubscriptionId); + if (updatedUserError) throw updatedUserError; } } diff --git a/utils/server/stripe/strip_helper.ts b/utils/server/stripe/strip_helper.ts new file mode 100644 index 0000000000..f90089ff6a --- /dev/null +++ b/utils/server/stripe/strip_helper.ts @@ -0,0 +1,236 @@ +import { STRIPE_PRODUCT_LIST } from '@/utils/app/stripe/stripe_config'; + +import { PaidPlan } from '@/types/paid_plan'; + +import { getAdminSupabaseClient } from '../supabase'; + +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import Stripe from 'stripe'; + +dayjs.extend(utc); + +const supabase = getAdminSupabaseClient(); + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2022-11-15', +}); + +async function getSubscriptionById(subscriptionId: string) { + try { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + return subscription; + } catch (error) { + console.error('Error retrieving subscription:', error); + throw error; + } +} +const StripeHelper = { + subscription: { + getSubscriptionById, + }, + product: { + getProductByCheckoutSessionId: getProductByCheckoutSessionId, + getProductByProductId: getProductByProductId, + }, +}; +export default StripeHelper; + +async function getProductByCheckoutSessionId( + sessionId: string, + mode: Stripe.Checkout.Session.Mode, +) { + const productId = await getProductIdByCheckoutSessionId(sessionId); + return getProductByProductId(productId, mode); +} +async function getProductByProductId( + productId: string, + mode: Stripe.Checkout.Session.Mode, +) { + const product = STRIPE_PRODUCT_LIST.find( + (product) => product.productId === productId && product.mode === mode, + ); + if (!product) { + throw new Error('No product found in our codebase', { + cause: { + productId, + mode, + STRIPE_PRODUCT_LIST, + }, + }); + } + return product; +} + +async function getProductIdByCheckoutSessionId( + sessionId: string, +): Promise { + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['line_items'], + }); + const lineItems = session.line_items; + + const productId = lineItems?.data[0]?.price?.product; + if (!productId || typeof productId !== 'string') { + throw new Error('The session does not have a product id', { + cause: { + session, + }, + }); + } + return productId; +} + +export async function fetchSubscriptionIdByUserId( + userId: string, +): Promise { + const { data: userProfile } = await supabase + .from('profiles') + .select('stripe_subscription_id') + .eq('id', userId) + .single(); + return userProfile?.stripe_subscription_id; +} + +export async function getCustomerEmailByCustomerID( + customerID: string, +): Promise { + try { + // We get the customer id from webhook, so we know the customer is not deleted + const customer = (await stripe.customers.retrieve( + customerID, + )) as Stripe.Customer; + if (!customer.email) { + throw new Error( + `the customer does not have an email, customer id is ${customerID}`, + ); + } + return customer.email; + } catch (e) { + throw new Error(`getCustomerEmailByCustomerID failed: ${e}`); + } +} + +export async function updateUserAccountById({ + userId, + plan, + stripeSubscriptionId, + proPlanExpirationDate, +}: { + userId: string; + plan?: string; + stripeSubscriptionId?: string; + proPlanExpirationDate?: Date; +}) { + // Update user account by User ID + const { data: userProfile } = await supabase + .from('profiles') + .select('plan') + .eq('id', userId) + .single(); + + if (userProfile?.plan === 'edu') return; + + const { error: updatedUserError } = await supabase + .from('profiles') + .update({ + plan, + stripe_subscription_id: stripeSubscriptionId, + pro_plan_expiration_date: proPlanExpirationDate, + }) + .eq('id', userId); + if (updatedUserError) throw updatedUserError; + console.log(`User ${userId} updated to ${plan}`); +} + +export async function updateUserAccountByEmail({ + email, + plan, + stripeSubscriptionId, + proPlanExpirationDate, +}: { + email: string; + plan?: string; + stripeSubscriptionId?: string; + proPlanExpirationDate?: Date; +}) { + const { data: userProfile } = await supabase + .from('profiles') + .select('plan') + .eq('email', email) + .single(); + + if (userProfile?.plan === 'edu') return; + + const { error: updatedUserError } = await supabase + .from('profiles') + .update({ + plan, + stripe_subscription_id: stripeSubscriptionId, + pro_plan_expiration_date: proPlanExpirationDate, + }) + .eq('email', email); + if (updatedUserError) throw updatedUserError; + console.log(`User ${email} updated to ${plan}`); +} + +export async function downgradeUserAccount({ + email, + stripeSubscriptionId, +}: { + email?: string; + stripeSubscriptionId?: string; +}) { + if (!email && !stripeSubscriptionId) { + throw new Error('Either email or stripeSubscriptionId must be provided'); + } + + const { data: userProfile } = await supabase + .from('profiles') + .select('plan') + .eq( + stripeSubscriptionId ? 'stripe_subscription_id' : 'email', + stripeSubscriptionId || email, + ) + .single(); + + if (userProfile?.plan === 'edu') return; + + const { error: updatedUserError } = await supabase + .from('profiles') + .update({ + plan: 'free', + }) + .eq( + stripeSubscriptionId ? 'stripe_subscription_id' : 'email', + stripeSubscriptionId || email, + ); + if (updatedUserError) throw updatedUserError; + console.log(`User ${email || stripeSubscriptionId} downgraded to free plan`); +} + +export async function calculateMembershipExpirationDate( + planGivingWeeks: string | undefined, + planCode: string | undefined, + sessionCreatedDate: Date, +): Promise { + const previousDate = dayjs(sessionCreatedDate || undefined); + // If has planGivingWeeks, use it to calculate the expiration date + if (planGivingWeeks && typeof planGivingWeeks === 'string') { + return previousDate.add(+planGivingWeeks, 'week').toDate(); + } + // else extend the expiration date based on the plan code + else if (planCode === PaidPlan.ProOneTime) { + return previousDate.add(1, 'month').toDate(); + } else if (planCode === PaidPlan.ProMonthly) { + return previousDate.add(1, 'month').toDate(); + } else if (planCode === PaidPlan.UltraOneTime) { + return previousDate.add(1, 'month').toDate(); + } else if (planCode === PaidPlan.UltraMonthly) { + return previousDate.add(1, 'month').toDate(); + } else if (planCode === PaidPlan.UltraYearly) { + return previousDate.add(1, 'year').toDate(); + } + // Return undefined if no conditions are met + return undefined; +} diff --git a/utils/server/stripe/updateUserAccount.ts b/utils/server/stripe/updateUserAccount.ts deleted file mode 100644 index edb003ac2e..0000000000 --- a/utils/server/stripe/updateUserAccount.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { getAdminSupabaseClient } from '../supabase'; - -// Skip any account operation on Edu accounts -export default async function updateUserAccount(props: UpdateUserAccountProps) { - const supabase = getAdminSupabaseClient(); - - if (isUpgradeUserAccountProps(props)) { - // Update user account - const { data: userProfile } = await supabase - .from('profiles') - .select('plan') - .eq('id', props.userId) - .single(); - - if (userProfile?.plan === 'edu') return; - - const { error: updatedUserError } = await supabase - .from('profiles') - .update({ - plan: 'pro', - stripe_subscription_id: props.stripeSubscriptionId, - pro_plan_expiration_date: props.proPlanExpirationDate || null, - }) - .eq('id', props.userId); - - if (updatedUserError) throw updatedUserError; - console.log(`User ${props.userId} updated to pro`); - } else if (isUpgradeUserAccountByEmailProps(props)) { - // Update user account by Email - const { data: userProfile } = await supabase - .from('profiles') - .select('plan') - .eq('email', props.email) - .single(); - - if (userProfile?.plan === 'edu') return; - - const { error: updatedUserError } = await supabase - .from('profiles') - .update({ - plan: 'pro', - stripe_subscription_id: props.stripeSubscriptionId, - pro_plan_expiration_date: props.proPlanExpirationDate || null, - }) - .eq('email', props.email); - if (updatedUserError) throw updatedUserError; - console.log(`User ${props.email} updated to pro`); - } else if (isDowngradeUserAccountProps(props)) { - // Downgrade user account - - const { data: userProfile } = await supabase - .from('profiles') - .select('plan') - .eq('stripe_subscription_id', props.stripeSubscriptionId) - .single(); - - if (userProfile?.plan === 'edu') return; - - const { error: updatedUserError } = await supabase - .from('profiles') - .update({ - plan: 'free', - }) - .eq('stripe_subscription_id', props.stripeSubscriptionId); - if (updatedUserError) throw updatedUserError; - console.log( - `User subscription ${props.stripeSubscriptionId} downgrade back to free`, - ); - } else if (isDowngradeUserAccountByEmailProps(props)) { - // Downgrade user account - const { data: userProfile } = await supabase - .from('profiles') - .select('plan') - .eq('email', props.email) - .single(); - - if (userProfile?.plan === 'edu') return; - - const { error: updatedUserError } = await supabase - .from('profiles') - .update({ - plan: 'free', - }) - .eq('email', props.email); - if (updatedUserError) throw updatedUserError; - console.log(`User subscription ${props.email} downgrade back to free`); - } else if (isExtendProPlanProps(props)) { - // Extend pro plan - - const { data: userProfile } = await supabase - .from('profiles') - .select('plan') - .eq('stripe_subscription_id', props.stripeSubscriptionId) - .single(); - - if (userProfile?.plan === 'edu') return; - - const { error: updatedUserError } = await supabase - .from('profiles') - .update({ - pro_plan_expiration_date: props.proPlanExpirationDate, - }) - .eq('stripe_subscription_id', props.stripeSubscriptionId); - if (updatedUserError) throw updatedUserError; - console.log( - `User subscription ${props.stripeSubscriptionId} extended to ${props.proPlanExpirationDate}`, - ); - } else { - throw new Error('Invalid props object'); - } -} - -interface UpgradeUserAccountProps { - upgrade: true; - userId: string; - stripeSubscriptionId?: string; - proPlanExpirationDate?: Date; -} - -interface UpgradeUserAccountByEmailProps { - upgrade: true; - email: string; - stripeSubscriptionId?: string; - proPlanExpirationDate?: Date; -} - -interface DowngradeUserAccountProps { - upgrade: false; - stripeSubscriptionId: string; - proPlanExpirationDate?: Date; -} -interface DowngradeUserAccountByEmailProps { - upgrade: false; - email: string; - proPlanExpirationDate?: Date; -} - -interface ExtendProPlanProps { - upgrade: true; - stripeSubscriptionId: string; - proPlanExpirationDate: Date | undefined; -} - -type UpdateUserAccountProps = - | UpgradeUserAccountProps - | UpgradeUserAccountByEmailProps - | DowngradeUserAccountProps - | DowngradeUserAccountByEmailProps - | ExtendProPlanProps; - -// Type Assertion functions -function isUpgradeUserAccountProps( - props: UpdateUserAccountProps, -): props is UpgradeUserAccountProps { - return ( - props.upgrade === true && - 'userId' in props && - typeof props.userId === 'string' && - (props.proPlanExpirationDate instanceof Date || - props.proPlanExpirationDate === undefined) - ); -} - -function isUpgradeUserAccountByEmailProps( - props: UpdateUserAccountProps, -): props is UpgradeUserAccountByEmailProps { - return ( - props.upgrade === true && - 'email' in props && - typeof props.email === 'string' && - (props.proPlanExpirationDate instanceof Date || - props.proPlanExpirationDate === undefined) - ); -} - -function isDowngradeUserAccountProps( - props: UpdateUserAccountProps, -): props is DowngradeUserAccountProps { - return ( - props.upgrade === false && - 'stripeSubscriptionId' in props && - typeof props.stripeSubscriptionId === 'string' && - (props.proPlanExpirationDate === undefined || - props.proPlanExpirationDate instanceof Date) - ); -} - -function isDowngradeUserAccountByEmailProps( - props: UpdateUserAccountProps, -): props is DowngradeUserAccountByEmailProps { - return ( - props.upgrade === false && - 'email' in props && - typeof props.email === 'string' && - (props.proPlanExpirationDate === undefined || - props.proPlanExpirationDate instanceof Date) - ); -} - -function isExtendProPlanProps( - props: UpdateUserAccountProps, -): props is ExtendProPlanProps { - return ( - (props.upgrade === true && - typeof props.stripeSubscriptionId === 'string' && - props.proPlanExpirationDate instanceof Date) || - props.proPlanExpirationDate === undefined - ); -}