48 add yearly monthly sub switch (#4577)
This commit is contained in:
@ -113,11 +113,16 @@ export const PageChangeEffect = () => {
|
|||||||
) {
|
) {
|
||||||
navigate(AppPath.CreateProfile);
|
navigate(AppPath.CreateProfile);
|
||||||
} else if (
|
} else if (
|
||||||
(onboardingStatus === OnboardingStatus.Completed ||
|
onboardingStatus === OnboardingStatus.Completed &&
|
||||||
onboardingStatus === OnboardingStatus.CompletedWithoutSubscription) &&
|
|
||||||
isMatchingOnboardingRoute
|
isMatchingOnboardingRoute
|
||||||
) {
|
) {
|
||||||
navigate(AppPath.Index);
|
navigate(AppPath.Index);
|
||||||
|
} else if (
|
||||||
|
onboardingStatus === OnboardingStatus.CompletedWithoutSubscription &&
|
||||||
|
isMatchingOnboardingRoute &&
|
||||||
|
!isMatchingLocation(AppPath.PlanRequired)
|
||||||
|
) {
|
||||||
|
navigate(AppPath.Index);
|
||||||
} else if (isMatchingLocation(AppPath.Invite)) {
|
} else if (isMatchingLocation(AppPath.Invite)) {
|
||||||
const inviteHash =
|
const inviteHash =
|
||||||
matchPath({ path: '/invite/:workspaceInviteHash' }, location.pathname)
|
matchPath({ path: '/invite/:workspaceInviteHash' }, location.pathname)
|
||||||
|
|||||||
@ -68,6 +68,7 @@ export type Billing = {
|
|||||||
export type BillingSubscription = {
|
export type BillingSubscription = {
|
||||||
__typename?: 'BillingSubscription';
|
__typename?: 'BillingSubscription';
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
|
interval?: Maybe<Scalars['String']>;
|
||||||
status: Scalars['String'];
|
status: Scalars['String'];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -258,6 +259,7 @@ export type Mutation = {
|
|||||||
renewToken: AuthTokens;
|
renewToken: AuthTokens;
|
||||||
signUp: LoginToken;
|
signUp: LoginToken;
|
||||||
track: Analytics;
|
track: Analytics;
|
||||||
|
updateBillingSubscription: UpdateBillingEntity;
|
||||||
updateOneObject: Object;
|
updateOneObject: Object;
|
||||||
updatePasswordViaResetToken: InvalidatePassword;
|
updatePasswordViaResetToken: InvalidatePassword;
|
||||||
updateWorkspace: Workspace;
|
updateWorkspace: Workspace;
|
||||||
@ -660,6 +662,12 @@ export type TransientToken = {
|
|||||||
transientToken: AuthToken;
|
transientToken: AuthToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateBillingEntity = {
|
||||||
|
__typename?: 'UpdateBillingEntity';
|
||||||
|
/** Boolean that confirms query was successful */
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateWorkspaceInput = {
|
export type UpdateWorkspaceInput = {
|
||||||
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
|
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
|
||||||
displayName?: InputMaybe<Scalars['String']>;
|
displayName?: InputMaybe<Scalars['String']>;
|
||||||
@ -1045,6 +1053,11 @@ export type GetProductPricesQueryVariables = Exact<{
|
|||||||
|
|
||||||
export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'ProductPricesEntity', productPrices: Array<{ __typename?: 'ProductPriceEntity', created: number, recurringInterval: string, stripePriceId: string, unitAmount: number }> } };
|
export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'ProductPricesEntity', productPrices: Array<{ __typename?: 'ProductPriceEntity', created: number, recurringInterval: string, stripePriceId: string, unitAmount: number }> } };
|
||||||
|
|
||||||
|
export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updateBillingSubscription: { __typename?: 'UpdateBillingEntity', success: boolean } };
|
||||||
|
|
||||||
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
@ -1083,7 +1096,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
|
|||||||
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', status: string } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } };
|
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } };
|
||||||
|
|
||||||
export type RemoveWorkspaceMemberMutationVariables = Exact<{
|
export type RemoveWorkspaceMemberMutationVariables = Exact<{
|
||||||
memberId: Scalars['String'];
|
memberId: Scalars['String'];
|
||||||
@ -2006,6 +2019,38 @@ export function useGetProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookO
|
|||||||
export type GetProductPricesQueryHookResult = ReturnType<typeof useGetProductPricesQuery>;
|
export type GetProductPricesQueryHookResult = ReturnType<typeof useGetProductPricesQuery>;
|
||||||
export type GetProductPricesLazyQueryHookResult = ReturnType<typeof useGetProductPricesLazyQuery>;
|
export type GetProductPricesLazyQueryHookResult = ReturnType<typeof useGetProductPricesLazyQuery>;
|
||||||
export type GetProductPricesQueryResult = Apollo.QueryResult<GetProductPricesQuery, GetProductPricesQueryVariables>;
|
export type GetProductPricesQueryResult = Apollo.QueryResult<GetProductPricesQuery, GetProductPricesQueryVariables>;
|
||||||
|
export const UpdateBillingSubscriptionDocument = gql`
|
||||||
|
mutation UpdateBillingSubscription {
|
||||||
|
updateBillingSubscription {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type UpdateBillingSubscriptionMutationFn = Apollo.MutationFunction<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useUpdateBillingSubscriptionMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useUpdateBillingSubscriptionMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useUpdateBillingSubscriptionMutation` returns a tuple that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - An object with fields that represent the current status of the mutation's execution
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [updateBillingSubscriptionMutation, { data, loading, error }] = useUpdateBillingSubscriptionMutation({
|
||||||
|
* variables: {
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useUpdateBillingSubscriptionMutation(baseOptions?: Apollo.MutationHookOptions<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>(UpdateBillingSubscriptionDocument, options);
|
||||||
|
}
|
||||||
|
export type UpdateBillingSubscriptionMutationHookResult = ReturnType<typeof useUpdateBillingSubscriptionMutation>;
|
||||||
|
export type UpdateBillingSubscriptionMutationResult = Apollo.MutationResult<UpdateBillingSubscriptionMutation>;
|
||||||
|
export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOptions<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>;
|
||||||
export const GetClientConfigDocument = gql`
|
export const GetClientConfigDocument = gql`
|
||||||
query GetClientConfig {
|
query GetClientConfig {
|
||||||
clientConfig {
|
clientConfig {
|
||||||
@ -2225,6 +2270,7 @@ export const GetCurrentUserDocument = gql`
|
|||||||
}
|
}
|
||||||
currentBillingSubscription {
|
currentBillingSubscription {
|
||||||
status
|
status
|
||||||
|
interval
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
workspaces {
|
workspaces {
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const UPDATE_BILLING_SUBSCRIPTION = gql`
|
||||||
|
mutation UpdateBillingSubscription {
|
||||||
|
updateBillingSubscription {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -37,6 +37,7 @@ export {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconCircleDot,
|
IconCircleDot,
|
||||||
IconCircleOff,
|
IconCircleOff,
|
||||||
|
IconCircleX,
|
||||||
IconClick,
|
IconClick,
|
||||||
IconCode,
|
IconCode,
|
||||||
IconCoins,
|
IconCoins,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
H1Title,
|
H1Title,
|
||||||
H1TitleFontColor,
|
H1TitleFontColor,
|
||||||
} from '@/ui/display/typography/components/H1Title';
|
} from '@/ui/display/typography/components/H1Title';
|
||||||
import { Button } from '@/ui/input/button/components/Button';
|
import { Button, ButtonAccent } from '@/ui/input/button/components/Button';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||||
import {
|
import {
|
||||||
@ -25,6 +25,7 @@ export type ConfirmationModalProps = {
|
|||||||
deleteButtonText?: string;
|
deleteButtonText?: string;
|
||||||
confirmationPlaceholder?: string;
|
confirmationPlaceholder?: string;
|
||||||
confirmationValue?: string;
|
confirmationValue?: string;
|
||||||
|
confirmButtonAccent?: ButtonAccent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledConfirmationModal = styled(Modal)`
|
const StyledConfirmationModal = styled(Modal)`
|
||||||
@ -66,6 +67,7 @@ export const ConfirmationModal = ({
|
|||||||
deleteButtonText = 'Delete',
|
deleteButtonText = 'Delete',
|
||||||
confirmationValue,
|
confirmationValue,
|
||||||
confirmationPlaceholder,
|
confirmationPlaceholder,
|
||||||
|
confirmButtonAccent = 'danger',
|
||||||
}: ConfirmationModalProps) => {
|
}: ConfirmationModalProps) => {
|
||||||
const [inputConfirmationValue, setInputConfirmationValue] =
|
const [inputConfirmationValue, setInputConfirmationValue] =
|
||||||
useState<string>('');
|
useState<string>('');
|
||||||
@ -127,7 +129,7 @@ export const ConfirmationModal = ({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
accent="danger"
|
accent={confirmButtonAccent}
|
||||||
title={deleteButtonText}
|
title={deleteButtonText}
|
||||||
disabled={!isValidValue}
|
disabled={!isValidValue}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const GET_CURRENT_USER = gql`
|
|||||||
}
|
}
|
||||||
currentBillingSubscription {
|
currentBillingSubscription {
|
||||||
status
|
status
|
||||||
|
interval
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
workspaces {
|
workspaces {
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||||
|
|
||||||
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus.ts';
|
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus.ts';
|
||||||
|
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts';
|
||||||
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus.ts';
|
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus.ts';
|
||||||
import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx';
|
import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx';
|
||||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
import { SupportChat } from '@/support/components/SupportChat.tsx';
|
import { SupportChat } from '@/support/components/SupportChat.tsx';
|
||||||
import { AppPath } from '@/types/AppPath.ts';
|
import { AppPath } from '@/types/AppPath.ts';
|
||||||
|
import { IconCalendarEvent, IconCircleX } from '@/ui/display/icon';
|
||||||
import { IconCreditCard, IconCurrencyDollar } from '@/ui/display/icon';
|
import { IconCreditCard, IconCurrencyDollar } from '@/ui/display/icon';
|
||||||
import { Info } from '@/ui/display/info/components/Info.tsx';
|
import { Info } from '@/ui/display/info/components/Info.tsx';
|
||||||
import { H1Title } from '@/ui/display/typography/components/H1Title.tsx';
|
import { H1Title } from '@/ui/display/typography/components/H1Title.tsx';
|
||||||
import { H2Title } from '@/ui/display/typography/components/H2Title.tsx';
|
import { H2Title } from '@/ui/display/typography/components/H2Title.tsx';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx';
|
||||||
import { Button } from '@/ui/input/button/components/Button.tsx';
|
import { Button } from '@/ui/input/button/components/Button.tsx';
|
||||||
|
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal.tsx';
|
||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||||
import { Section } from '@/ui/layout/section/components/Section.tsx';
|
import { Section } from '@/ui/layout/section/components/Section.tsx';
|
||||||
import { useBillingPortalSessionQuery } from '~/generated/graphql.tsx';
|
import {
|
||||||
|
useBillingPortalSessionQuery,
|
||||||
|
useUpdateBillingSubscriptionMutation,
|
||||||
|
} from '~/generated/graphql.tsx';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
const StyledH1Title = styled(H1Title)`
|
const StyledH1Title = styled(H1Title)`
|
||||||
@ -26,9 +34,45 @@ const StyledInvisibleChat = styled.div`
|
|||||||
display: none;
|
display: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
type SwitchInfo = {
|
||||||
|
newInterval: string;
|
||||||
|
to: string;
|
||||||
|
from: string;
|
||||||
|
impact: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTHLY_SWITCH_INFO: SwitchInfo = {
|
||||||
|
newInterval: 'year',
|
||||||
|
to: 'to yearly',
|
||||||
|
from: 'from monthly to yearly',
|
||||||
|
impact: 'You will be charged immediately for the full year.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const YEARLY_SWITCH_INFO: SwitchInfo = {
|
||||||
|
newInterval: 'month',
|
||||||
|
to: 'to monthly',
|
||||||
|
from: 'from yearly to monthly',
|
||||||
|
impact: 'Your credit balance will be used to pay the monthly bills.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWITCH_INFOS = {
|
||||||
|
year: YEARLY_SWITCH_INFO,
|
||||||
|
month: MONTHLY_SWITCH_INFO,
|
||||||
|
};
|
||||||
|
|
||||||
export const SettingsBilling = () => {
|
export const SettingsBilling = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
const onboardingStatus = useOnboardingStatus();
|
const onboardingStatus = useOnboardingStatus();
|
||||||
|
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||||
|
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
|
||||||
|
const switchingInfo =
|
||||||
|
currentWorkspace?.currentBillingSubscription?.interval === 'year'
|
||||||
|
? SWITCH_INFOS.year
|
||||||
|
: SWITCH_INFOS.month;
|
||||||
|
const [isSwitchingIntervalModalOpen, setIsSwitchingIntervalModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [updateBillingSubscription] = useUpdateBillingSubscriptionMutation();
|
||||||
const { data, loading } = useBillingPortalSessionQuery({
|
const { data, loading } = useBillingPortalSessionQuery({
|
||||||
variables: {
|
variables: {
|
||||||
returnUrlPath: '/settings/billing',
|
returnUrlPath: '/settings/billing',
|
||||||
@ -54,6 +98,36 @@ export const SettingsBilling = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSwitchingIntervalModal = () => {
|
||||||
|
setIsSwitchingIntervalModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchInterval = async () => {
|
||||||
|
try {
|
||||||
|
await updateBillingSubscription();
|
||||||
|
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
|
||||||
|
const newCurrentWorkspace = {
|
||||||
|
...currentWorkspace,
|
||||||
|
currentBillingSubscription: {
|
||||||
|
...currentWorkspace?.currentBillingSubscription,
|
||||||
|
interval: switchingInfo.newInterval,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setCurrentWorkspace(newCurrentWorkspace);
|
||||||
|
}
|
||||||
|
enqueueSnackBar(`Subscription has been switched ${switchingInfo.to}`, {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
enqueueSnackBar(
|
||||||
|
`Error while switching subscription ${switchingInfo.to}.`,
|
||||||
|
{
|
||||||
|
variant: 'error',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const redirectToSubscribePage = () => {
|
const redirectToSubscribePage = () => {
|
||||||
navigate(AppPath.PlanRequired);
|
navigate(AppPath.PlanRequired);
|
||||||
};
|
};
|
||||||
@ -79,33 +153,74 @@ export const SettingsBilling = () => {
|
|||||||
onClick={redirectToSubscribePage}
|
onClick={redirectToSubscribePage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{displaySubscribeInfo && (
|
{displaySubscribeInfo ? (
|
||||||
<Info
|
<Info
|
||||||
text={'Your workspace does not have an active subscription'}
|
text={'Your workspace does not have an active subscription'}
|
||||||
buttonTitle={'Subscribe'}
|
buttonTitle={'Subscribe'}
|
||||||
accent={'danger'}
|
accent={'danger'}
|
||||||
onClick={redirectToSubscribePage}
|
onClick={redirectToSubscribePage}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
{!displaySubscribeInfo && (
|
<>
|
||||||
<Section>
|
<Section>
|
||||||
<H2Title
|
<H2Title
|
||||||
title="Manage your subscription"
|
title="Manage your subscription"
|
||||||
description="Edit payment method, see your invoices and more"
|
description="Edit payment method, see your invoices and more"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
Icon={IconCreditCard}
|
Icon={IconCreditCard}
|
||||||
title="View billing details"
|
title="View billing details"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={openBillingPortal}
|
onClick={openBillingPortal}
|
||||||
disabled={billingPortalButtonDisabled}
|
/>
|
||||||
/>
|
</Section>
|
||||||
</Section>
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title="Edit billing interval"
|
||||||
|
description={`Switch ${switchingInfo.from}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
Icon={IconCalendarEvent}
|
||||||
|
title={`Switch ${switchingInfo.to}`}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={openSwitchingIntervalModal}
|
||||||
|
disabled={billingPortalButtonDisabled}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title="Cancel your subscription"
|
||||||
|
description="Your workspace will be disabled"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
Icon={IconCircleX}
|
||||||
|
title="Cancel Plan"
|
||||||
|
variant="secondary"
|
||||||
|
accent="danger"
|
||||||
|
onClick={openBillingPortal}
|
||||||
|
disabled={billingPortalButtonDisabled}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsPageContainer>
|
</SettingsPageContainer>
|
||||||
<StyledInvisibleChat>
|
<StyledInvisibleChat>
|
||||||
<SupportChat />
|
<SupportChat />
|
||||||
</StyledInvisibleChat>
|
</StyledInvisibleChat>
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={isSwitchingIntervalModalOpen}
|
||||||
|
setIsOpen={setIsSwitchingIntervalModalOpen}
|
||||||
|
title={`Switch billing ${switchingInfo.to}`}
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
{`Are you sure that you want to change your billing interval?
|
||||||
|
${switchingInfo.impact}`}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onConfirmClick={switchInterval}
|
||||||
|
deleteButtonText={`Change ${switchingInfo.to}`}
|
||||||
|
confirmButtonAccent={'blue'}
|
||||||
|
/>
|
||||||
</SubMenuTopBarContainer>
|
</SubMenuTopBarContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddIntervalToBillingSubscription1710926613773
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddIntervalToBillingSubscription1710926613773';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."billingSubscription" ADD "interval" character varying`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."billingSubscription" DROP COLUMN "interval"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
|||||||
import { CheckoutSessionInput } from 'src/engine/core-modules/billing/dto/checkout-session.input';
|
import { CheckoutSessionInput } from 'src/engine/core-modules/billing/dto/checkout-session.input';
|
||||||
import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity';
|
import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity';
|
||||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dto/billing-session.input';
|
import { BillingSessionInput } from 'src/engine/core-modules/billing/dto/billing-session.input';
|
||||||
|
import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update-billing.entity';
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class BillingResolver {
|
export class BillingResolver {
|
||||||
@ -88,4 +89,12 @@ export class BillingResolver {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => UpdateBillingEntity)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async updateBillingSubscription(@AuthUser() user: User) {
|
||||||
|
await this.billingService.updateBillingSubscription(user);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,6 +154,32 @@ export class BillingService {
|
|||||||
return session.url;
|
return session.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateBillingSubscription(user: User) {
|
||||||
|
const billingSubscription = await this.getCurrentBillingSubscription({
|
||||||
|
workspaceId: user.defaultWorkspaceId,
|
||||||
|
});
|
||||||
|
const newInterval =
|
||||||
|
billingSubscription?.interval === 'year' ? 'month' : 'year';
|
||||||
|
const billingSubscriptionItem = await this.getBillingSubscriptionItem(
|
||||||
|
user.defaultWorkspaceId,
|
||||||
|
);
|
||||||
|
const stripeProductId = this.getProductStripeId(AvailableProduct.BasePlan);
|
||||||
|
|
||||||
|
if (!stripeProductId) {
|
||||||
|
throw new Error('Stripe product id not found for basePlan');
|
||||||
|
}
|
||||||
|
const productPrices = await this.getProductPrices(stripeProductId);
|
||||||
|
|
||||||
|
const stripePriceId = productPrices.filter(
|
||||||
|
(price) => price.recurringInterval === newInterval,
|
||||||
|
)?.[0]?.stripePriceId;
|
||||||
|
|
||||||
|
await this.stripeService.updateBillingSubscriptionItem(
|
||||||
|
billingSubscriptionItem,
|
||||||
|
stripePriceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async computeCheckoutSessionURL(
|
async computeCheckoutSessionURL(
|
||||||
user: User,
|
user: User,
|
||||||
priceId: string,
|
priceId: string,
|
||||||
@ -230,6 +256,7 @@ export class BillingService {
|
|||||||
stripeCustomerId: data.object.customer as string,
|
stripeCustomerId: data.object.customer as string,
|
||||||
stripeSubscriptionId: data.object.id,
|
stripeSubscriptionId: data.object.id,
|
||||||
status: data.object.status,
|
status: data.object.status,
|
||||||
|
interval: data.object.items.data[0].plan.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
conflictPaths: ['stripeSubscriptionId'],
|
conflictPaths: ['stripeSubscriptionId'],
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class UpdateBillingEntity {
|
||||||
|
@Field(() => Boolean, {
|
||||||
|
description: 'Boolean that confirms query was successful',
|
||||||
|
})
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
@ -51,6 +51,10 @@ export class BillingSubscription {
|
|||||||
@Column({ nullable: false })
|
@Column({ nullable: false })
|
||||||
status: Stripe.Subscription.Status;
|
status: Stripe.Subscription.Status;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@Column({ nullable: true })
|
||||||
|
interval: Stripe.Price.Recurring.Interval;
|
||||||
|
|
||||||
@OneToMany(
|
@OneToMany(
|
||||||
() => BillingSubscriptionItem,
|
() => BillingSubscriptionItem,
|
||||||
(billingSubscriptionItem) => billingSubscriptionItem.billingSubscription,
|
(billingSubscriptionItem) => billingSubscriptionItem.billingSubscription,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Stripe from 'stripe';
|
|||||||
|
|
||||||
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
|
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StripeService {
|
export class StripeService {
|
||||||
@ -105,4 +106,17 @@ export class StripeService {
|
|||||||
}
|
}
|
||||||
await this.stripe.invoices.pay(latestInvoice.id);
|
await this.stripe.invoices.pay(latestInvoice.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateBillingSubscriptionItem(
|
||||||
|
stripeSubscriptionItem: BillingSubscriptionItem,
|
||||||
|
stripePriceId: string,
|
||||||
|
) {
|
||||||
|
await this.stripe.subscriptionItems.update(
|
||||||
|
stripeSubscriptionItem.stripeSubscriptionItemId,
|
||||||
|
{
|
||||||
|
price: stripePriceId,
|
||||||
|
quantity: stripeSubscriptionItem.quantity,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
|
|||||||
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
|
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
||||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
|
||||||
|
|
||||||
import { UserService } from './services/user.service';
|
import { UserService } from './services/user.service';
|
||||||
|
|
||||||
@ -45,7 +44,6 @@ export class UserResolver {
|
|||||||
@InjectRepository(User, 'core')
|
@InjectRepository(User, 'core')
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly userWorkspaceService: UserWorkspaceService,
|
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly fileUploadService: FileUploadService,
|
private readonly fileUploadService: FileUploadService,
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user