Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,39 +1,57 @@
import { useMemo } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import { useUserSignUp } from '@qovery/domains/users-sign-up/feature'
import { SETTINGS_BILLING_SUMMARY_URL, SETTINGS_URL } from '@qovery/shared/routes'
import { Banner } from '@qovery/shared/ui'
import { useSupportChat } from '@qovery/shared/util-hooks'
import { pluralize } from '@qovery/shared/util-js'
import useClusterCreationRestriction from '../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction'

const FREE_TRIAL_ADD_CREDIT_CARD_MESSAGE =
'You are on a free trial. Add a credit card to unlock managed cluster creation. If you need help, please contact us.'

export function FreeTrialBanner() {
const { organizationId = '' } = useParams()
const { pathname } = useLocation()
const { data: userSignUp } = useUserSignUp()
const hasDxAuth = Boolean(userSignUp?.dx_auth)
const { isInActiveFreeTrial, remainingTrialDays } = useClusterCreationRestriction({
const {
isClusterCreationRestricted: hasRestriction,
isNoCreditCardRestriction,
isInActiveFreeTrial,
remainingTrialDays,
hasNoCreditCard,
} = useClusterCreationRestriction({
organizationId,
dxAuth: hasDxAuth,
})
const { showChat } = useSupportChat()

const isOnOrganizationBillingSummaryPage = pathname.includes(
SETTINGS_URL(organizationId) + SETTINGS_BILLING_SUMMARY_URL
)

// Show the banner when there is any billing restriction or an active free trial
const shouldShowBanner = hasRestriction || isInActiveFreeTrial

const shouldHideBanner = useMemo(
() => !isInActiveFreeTrial || isOnOrganizationBillingSummaryPage || hasDxAuth,
[isInActiveFreeTrial, isOnOrganizationBillingSummaryPage, hasDxAuth]
() => !shouldShowBanner || isOnOrganizationBillingSummaryPage,
[shouldShowBanner, isOnOrganizationBillingSummaryPage]
)

if (shouldHideBanner) {
return null
}

// Add + 1 because Chargebee return 0 when the trial is ending today
// Generic restriction (not NO_CREDIT_CARD): deployments are blocked
if (hasRestriction && !isNoCreditCardRestriction) {
return (
<Banner color="red" buttonIconRight="arrow-right" buttonLabel="Contact us" onClickButton={() => showChat()}>
Deployments are restricted on your organization. Please contact support to resolve this issue.
</Banner>
)
}

// Free trial: ask to add card only when billing restricts cluster creation (NO_CREDIT_CARD), otherwise show expiry countdown
const days = (remainingTrialDays ?? 0) + 1
const message = `Your free trial plan expires ${days} ${pluralize(days, 'day')} from now. If you need help, please contact us.`
const expiryMessage = `Your free trial plan expires ${days} ${pluralize(days, 'day')} from now. If you need help, please contact us.`
const message = hasNoCreditCard && isNoCreditCardRestriction ? FREE_TRIAL_ADD_CREDIT_CARD_MESSAGE : expiryMessage

return (
<Banner color="brand" buttonIconRight="arrow-right" buttonLabel="Need help" onClickButton={() => showChat()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export function useAddCreditCard() {
queryClient.invalidateQueries({
queryKey: queries.organizations.creditCards({ organizationId }).queryKey,
})
queryClient.invalidateQueries({
queryKey: queries.organizations.details({ organizationId }).queryKey,
})
},
meta: {
notifyOnSuccess: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,60 +1,55 @@
import { useMemo } from 'react'
import useCreditCards from '../use-credit-cards/use-credit-cards'
import useCurrentCost from '../use-current-cost/use-current-cost'
import useOrganization from '../use-organization/use-organization'

export interface UseClusterCreationRestrictionProps {
organizationId: string
/** When true, cluster creation is never restricted (e.g. DX auth users). */
dxAuth?: boolean
}

/**
* Hook to determine if cluster creation should be restricted.
*
* Clusters (except demo) are restricted when:
* - User is not dxAuth (dxAuth users are never restricted)
* - AND user is in an active free trial (inverse of the free-trial-banner hide condition)
* - AND user has no credit card registered
*
* @see https://qovery.slack.com/archives/C02P3MA2NKT/p1768564947277349
* Uses the backend-provided `billing_deployment_restriction` field on the organization:
* - null → no restriction
* - 'NO_CREDIT_CARD' → free trial restriction (blocks managed cluster creation, allows demo)
* - any other string → blocks all deployments
*/
export function useClusterCreationRestriction({ organizationId, dxAuth }: UseClusterCreationRestrictionProps) {
export function useClusterCreationRestriction({ organizationId }: UseClusterCreationRestrictionProps) {
const { data: organization, isFetched: isFetchedOrganization } = useOrganization({ organizationId })
const { data: currentCost, isFetched: isFetchedCurrentCost } = useCurrentCost({ organizationId })
const { data: creditCards, isFetched: isFetchedCreditCards } = useCreditCards({ organizationId })
const { data: creditCards = [], isFetched: isFetchedCreditCards } = useCreditCards({ organizationId })

const remainingTrialDays = currentCost?.remaining_trial_day

// Check if user is in active free trial
// This is the inverse of the condition in free-trial-banner.tsx that hides the banner
// Original condition (to hide banner):
// remainingTrialDays === undefined || remainingTrialDays <= 0 || remainingTrialDays > 90 || !isFetchedCurrentCost
// Inverse (user is in active trial):
// remainingTrialDays is defined AND > 0 AND <= 90 AND data is fetched
const billingDeploymentRestriction = organization?.billing_deployment_restriction

// Check if user is in active free trial (used by free-trial-banner)
const isInActiveFreeTrial = useMemo(
() =>
isFetchedCurrentCost && remainingTrialDays !== undefined && remainingTrialDays > 0 && remainingTrialDays <= 90,
[isFetchedCurrentCost, remainingTrialDays]
)

// Check if user has no credit card
const hasNoCreditCard = useMemo(
() => isFetchedCreditCards && (!creditCards || creditCards.length === 0),
[isFetchedCreditCards, creditCards]
)

// Do not restrict when dxAuth is true (e.g. DX auth users bypass trial/credit-card rules)
// Cluster creation is restricted when the backend sets a billing deployment restriction
const isClusterCreationRestricted = useMemo(
() => !dxAuth && isInActiveFreeTrial && hasNoCreditCard,
[dxAuth, isInActiveFreeTrial, hasNoCreditCard]
() => isFetchedOrganization && billingDeploymentRestriction != null,
[isFetchedOrganization, billingDeploymentRestriction]
)

const isLoading = !isFetchedCurrentCost || !isFetchedCreditCards
const isNoCreditCardRestriction = billingDeploymentRestriction === 'NO_CREDIT_CARD'

const hasNoCreditCard = isFetchedCreditCards && creditCards.length === 0

const isLoading = !isFetchedOrganization || !isFetchedCurrentCost

return {
isClusterCreationRestricted,
isNoCreditCardRestriction,
hasNoCreditCard,
isLoading,
isInActiveFreeTrial,
hasNoCreditCard,
billingDeploymentRestriction,
remainingTrialDays,
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { NavLink, useParams } from 'react-router-dom'
import { match } from 'ts-pattern'
import { ClusterInstallationGuideModal } from '@qovery/domains/clusters/feature'
import { useClusterCreationRestriction } from '@qovery/domains/organizations/feature'
import { useUserSignUp } from '@qovery/domains/users-sign-up/feature'
import { AddCreditCardModalFeature } from '@qovery/shared/console-shared'
import { CLUSTERS_TEMPLATE_CREATION_URL, CLUSTERS_URL, SETTINGS_BILLING_URL, SETTINGS_URL } from '@qovery/shared/routes'
import { Button, Callout, Heading, Icon, Link, Section, useModal } from '@qovery/shared/ui'
Expand Down Expand Up @@ -391,12 +390,8 @@ export function PageNewFeature() {
const { organizationId = '' } = useParams()
useDocumentTitle('Create new cluster - Qovery')
const { openModal, closeModal } = useModal()
const { data: userSignUp } = useUserSignUp()
const hasDxAuth = Boolean(userSignUp?.dx_auth)

const { isClusterCreationRestricted } = useClusterCreationRestriction({
const { isClusterCreationRestricted, isNoCreditCardRestriction } = useClusterCreationRestriction({
organizationId,
dxAuth: hasDxAuth,
})

const openInstallationGuideModal = ({ isDemo = false }: { isDemo?: boolean } = {}) =>
Expand Down Expand Up @@ -608,38 +603,52 @@ export function PageNewFeature() {
<Heading>Or choose your hosting mode</Heading>
<p className="text-sm text-neutral-350">Manage your infrastructure across different hosting mode.</p>
</div>
{isClusterCreationRestricted && (
<Callout.Root color="sky" className="mb-5">
<Callout.Icon>
<Icon iconName="circle-info" iconStyle="regular" />
</Callout.Icon>
<Callout.Text>
<Callout.TextHeading>Add a credit card to create a cluster</Callout.TextHeading>
<Callout.TextDescription>
You need to add a credit card to your account before creating a cluster on a cloud provider. You won’t
be charged until your trial ends.
<br />
<Link
as="button"
color="neutral"
variant="outline"
className="mt-2"
to={SETTINGS_URL(organizationId) + SETTINGS_BILLING_URL}
>
Add credit card
<Icon iconName="arrow-right" className="ml-1" iconStyle="regular" />
</Link>
</Callout.TextDescription>
</Callout.Text>
</Callout.Root>
)}
{isClusterCreationRestricted &&
(isNoCreditCardRestriction ? (
<Callout.Root color="sky" className="mb-5">
<Callout.Icon>
<Icon iconName="circle-info" iconStyle="regular" />
</Callout.Icon>
<Callout.Text>
<Callout.TextHeading>Add a credit card to create a cluster</Callout.TextHeading>
<Callout.TextDescription>
You need to add a credit card to your account before creating a cluster on a cloud provider. You
won't be charged until your trial ends.
<br />
<Link
as="button"
color="neutral"
variant="outline"
className="mt-2"
to={SETTINGS_URL(organizationId) + SETTINGS_BILLING_URL}
>
Add credit card
<Icon iconName="arrow-right" className="ml-1" iconStyle="regular" />
</Link>
</Callout.TextDescription>
</Callout.Text>
</Callout.Root>
) : (
<Callout.Root color="red" className="mb-5">
<Callout.Icon>
<Icon iconName="circle-exclamation" iconStyle="regular" />
</Callout.Icon>
<Callout.Text>
<Callout.TextHeading>Cluster creation is restricted</Callout.TextHeading>
<Callout.TextDescription>
Your organization has a billing restriction that prevents cluster creation. Please contact support
to resolve this issue.
</Callout.TextDescription>
</Callout.Text>
</Callout.Root>
))}
<div className="flex w-[calc(100%+20px)] flex-wrap gap-5">
{cloudProviders.slice(1).map((props, index) => (
<CardCluster
key={props.title}
index={index}
disabled={isClusterCreationRestricted}
onDisabledClick={openCreditCardModal}
onDisabledClick={isNoCreditCardRestriction ? openCreditCardModal : undefined}
{...props}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,8 @@ export function PageOrganizationBillingSummary(props: PageOrganizationBillingSum
// It's not so accurate, but it's a good enough approximation for now
const billingRecurrence = getBillingRecurrenceStr(props.currentCost?.renewal_at)
const remainingTrialDay = props.currentCost?.remaining_trial_day ?? 0
const hasDxAuth = Boolean(userSignUp?.dx_auth)
const showTrialCallout =
remainingTrialDay !== undefined && remainingTrialDay > 0 && !props.creditCardLoading && !hasDxAuth
const showErrorCallout = (props.hasCreditCard ?? Boolean(props.creditCard)) || hasDxAuth
const showTrialCallout = remainingTrialDay !== undefined && remainingTrialDay > 0 && !props.creditCardLoading
const hasNoCreditCard = !(props.hasCreditCard ?? Boolean(props.creditCard)) && !userSignUp?.dx_auth

// This function is used to get the trial start date based on the remaining trial days from the API
const trialStartDate = useMemo(() => {
Expand All @@ -83,31 +81,31 @@ export function PageOrganizationBillingSummary(props: PageOrganizationBillingSum
<div className="flex w-full max-w-[832px] flex-col justify-between">
<Section className="p-8">
{showTrialCallout && (
<Callout.Root color={showErrorCallout ? 'yellow' : 'red'} className="mb-8 items-center">
<Callout.Root color={hasNoCreditCard ? 'red' : 'yellow'} className="mb-8 items-center">
<Callout.Text>
<Callout.TextHeading>
{/* Add + 1 because Chargebee return 0 when the trial is ending today */}
{showErrorCallout
? `Your free trial plan expires ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')} from now`
: `No credit card registered, your account will be blocked at the end your trial in ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')}`}
{hasNoCreditCard
? `No credit card registered, your account will be blocked at the end your trial in ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')}`
: `Your free trial plan expires ${remainingTrialDay + 1} ${pluralize(remainingTrialDay + 1, 'day')} from now`}
</Callout.TextHeading>
{showErrorCallout ? (
{hasNoCreditCard ? (
<>Add a payment method to avoid service interruption at the end of your trial.</>
) : (
<>
You have contracted a free 14-days trial on{' '}
{trialStartDate ? format(trialStartDate, 'MMMM d, yyyy') : '...'}. At the end of this plan your user
subscription will start. You cancel your trial by deleting your organization.
</>
) : (
<>Add a payment method to avoid service interruption at the end of your trial.</>
)}
</Callout.Text>
<Button
size="sm"
variant="solid"
color={showErrorCallout ? 'yellow' : 'red'}
onClick={() => (showErrorCallout ? props.onCancelTrialClick?.() : props.onAddCreditCardClick?.())}
color={hasNoCreditCard ? 'red' : 'yellow'}
onClick={() => (hasNoCreditCard ? props.onAddCreditCardClick?.() : props.onCancelTrialClick?.())}
>
{showErrorCallout ? 'Cancel free trial' : 'Add credit card'}
{hasNoCreditCard ? 'Add credit card' : 'Cancel free trial'}
</Button>
</Callout.Root>
)}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"mermaid": "^11.6.0",
"monaco-editor": "0.53.0",
"posthog-js": "^1.260.1",
"qovery-typescript-axios": "1.1.830",
"qovery-typescript-axios": "1.1.832",
"react": "18.3.1",
"react-country-flag": "^3.0.2",
"react-datepicker": "^4.12.0",
Expand Down
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5451,7 +5451,7 @@ __metadata:
prettier: ^3.2.5
prettier-plugin-tailwindcss: ^0.5.14
pretty-quick: ^4.0.0
qovery-typescript-axios: 1.1.830
qovery-typescript-axios: 1.1.832
qovery-ws-typescript-axios: ^0.1.420
react: 18.3.1
react-country-flag: ^3.0.2
Expand Down Expand Up @@ -24269,12 +24269,12 @@ __metadata:
languageName: node
linkType: hard

"qovery-typescript-axios@npm:1.1.830":
version: 1.1.830
resolution: "qovery-typescript-axios@npm:1.1.830"
"qovery-typescript-axios@npm:1.1.832":
version: 1.1.832
resolution: "qovery-typescript-axios@npm:1.1.832"
dependencies:
axios: 1.12.2
checksum: 7545153eac1d9b3e8192c2ef6288fee203714ec81404581e1b6871a69937335d03c7820d5c2c8934f73eed96f0ac0e5bda4c7149d88ee23df26a56f8822e3128
checksum: 1337d725e15ff6c5ed82f183a2e0b2bd0cbfa86ff0826df327da8a1778f05ff363279b0dd74e9a9f450eb9f09fd63fc35988c9928960466fbc7767b9d4639ddd
languageName: node
linkType: hard

Expand Down