add trial period ending banner + server logic (#11389)

Solution : 
- if user reaches his workflow usage cap + in trial period > display
banner
- end trial period if user has payment method (if not, not possible)

<img width="941" alt="Screenshot 2025-04-04 at 10 27 32"
src="https://github.com/user-attachments/assets/d7a1d5f7-9b12-4a92-a7c7-15ef8847c790"
/>
This commit is contained in:
Etienne
2025-04-07 15:28:02 +02:00
committed by GitHub
parent a627eff5c2
commit 361b7682dd
26 changed files with 414 additions and 64 deletions

View File

@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const END_SUBSCRIPTION_TRIAL_PERIOD = gql`
mutation EndSubscriptionTrialPeriod {
endSubscriptionTrialPeriod {
status
hasPaymentMethod
}
}
`;

View File

@ -0,0 +1,71 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useEndSubscriptionTrialPeriodMutation } from '~/generated/graphql';
export const useEndSubscriptionTrialPeriod = () => {
const { enqueueSnackBar } = useSnackBar();
const [endSubscriptionTrialPeriod] = useEndSubscriptionTrialPeriodMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const [isLoading, setIsLoading] = useState(false);
const endTrialPeriod = async () => {
try {
setIsLoading(true);
const { data } = await endSubscriptionTrialPeriod();
const endTrialPeriodOutput = data?.endSubscriptionTrialPeriod;
const hasPaymentMethod = endTrialPeriodOutput?.hasPaymentMethod;
if (isDefined(hasPaymentMethod) && hasPaymentMethod === false) {
enqueueSnackBar(
t`No payment method found. Please update your billing details.`,
{
variant: SnackBarVariant.Error,
},
);
return;
}
const updatedSubscriptionStatus = endTrialPeriodOutput?.status;
if (
isDefined(updatedSubscriptionStatus) &&
isDefined(currentWorkspace?.currentBillingSubscription)
) {
setCurrentWorkspace({
...currentWorkspace,
currentBillingSubscription: {
...currentWorkspace?.currentBillingSubscription,
status: updatedSubscriptionStatus,
},
});
}
enqueueSnackBar(t`Subscription activated.`, {
variant: SnackBarVariant.Success,
});
} catch {
enqueueSnackBar(
t`Error while ending trial period. Please contact Twenty team.`,
{
variant: SnackBarVariant.Error,
},
);
} finally {
setIsLoading(false);
}
};
return {
endTrialPeriod,
isLoading,
};
};

View File

@ -1,8 +1,10 @@
import { InformationBannerBillingSubscriptionPaused } from '@/information-banner/components/billing/InformationBannerBillingSubscriptionPaused';
import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod';
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
import { useIsSomeMeteredProductCapReached } from '@/workspace/hooks/useIsSomeMeteredProductCapReached';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
@ -24,6 +26,7 @@ export const InformationBannerWrapper = () => {
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED,
);
const isSomeMeteredProductCapReached = useIsSomeMeteredProductCapReached();
const displayBillingSubscriptionPausedBanner =
isWorkspaceSuspended && subscriptionStatus === SubscriptionStatus.Paused;
@ -35,6 +38,10 @@ export const InformationBannerWrapper = () => {
subscriptionStatus === SubscriptionStatus.PastDue ||
subscriptionStatus === SubscriptionStatus.Unpaid;
const displayEndTrialPeriodBanner =
isSomeMeteredProductCapReached &&
subscriptionStatus === SubscriptionStatus.Trialing;
return (
<StyledInformationBannerWrapper>
<InformationBannerReconnectAccountInsufficientPermissions />
@ -46,6 +53,7 @@ export const InformationBannerWrapper = () => {
<InformationBannerNoBillingSubscription />
)}
{displayFailPaymentInfoBanner && <InformationBannerFailPaymentInfo />}
{displayEndTrialPeriodBanner && <InformationBannerEndTrialPeriod />}
</StyledInformationBannerWrapper>
);
};

View File

@ -0,0 +1,18 @@
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useLingui } from '@lingui/react/macro';
export const InformationBannerEndTrialPeriod = () => {
const { endTrialPeriod, isLoading } = useEndSubscriptionTrialPeriod();
const { t } = useLingui();
return (
<InformationBanner
variant="danger"
message={t`No free workflow executions left. End trial period and activate your billing to continue.`}
buttonTitle={t`Activate`}
buttonOnClick={async () => await endTrialPeriod()}
isButtonDisabled={isLoading}
/>
);
};

View File

@ -53,6 +53,19 @@ export const USER_QUERY_FRAGMENT = gql`
id
status
interval
billingSubscriptionItems {
id
hasReachedCurrentPeriodCap
billingProduct {
name
description
metadata {
planKey
priceUsageBased
productKey
}
}
}
}
billingSubscriptions {
id

View File

@ -0,0 +1,20 @@
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { BillingProductKey } from '~/generated/graphql';
export const useIsSomeMeteredProductCapReached = (): boolean => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const meteredProductKeys = Object.values(BillingProductKey).filter(
(productKey) => productKey !== BillingProductKey.BASE_PRODUCT,
);
return meteredProductKeys.some((productKey) => {
return (
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems?.find(
(item) => item.billingProduct?.metadata.productKey === productKey,
)?.hasReachedCurrentPeriodCap ?? false
);
});
};