From 361b7682dd8d6d364e95aab7c6d96da556acced4 Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Mon, 7 Apr 2025 15:28:02 +0200
Subject: [PATCH] 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)
---
.../twenty-front/src/generated/graphql.tsx | 101 ++++++++++++++++--
.../graphql/endSubscriptionTrialPeriod.ts | 10 ++
.../hooks/useEndSubscriptionTrialPeriod.ts | 71 ++++++++++++
.../components/InformationBannerWrapper.tsx | 8 ++
.../InformationBannerEndTrialPeriod.tsx | 18 ++++
.../graphql/fragments/userQueryFragment.ts | 13 +++
.../useIsSomeMeteredProductCapReached.ts | 20 ++++
.../src/pages/onboarding/ChooseYourPlan.tsx | 2 +-
.../core-modules/billing/billing.exception.ts | 1 +
.../core-modules/billing/billing.resolver.ts | 12 +++
.../billing/dtos/billing-product.dto.ts | 12 +--
.../billing-end-trial-period.output.ts | 19 ++++
.../billing-subscription-item.output.ts | 20 ++++
.../billing-subscription-item.entity.ts | 9 ++
.../entities/billing-subscription.entity.ts | 2 +
.../billing/enums/billing-product-key.enum.ts | 7 ++
.../billing/enums/billing-usage-type.enum.ts | 7 ++
.../filters/billing-api-exception.filter.ts | 1 +
.../services/billing-subscription.service.ts | 44 +++++++-
.../services/stripe-customer.service.ts | 7 ++
.../services/stripe-subscription.service.ts | 7 ++
.../types/billing-product-metadata.type.ts | 23 ++--
...tabase-product-to-graphql-dto.util.spec.ts | 20 +++-
...at-database-product-to-graphql-dto.util.ts | 15 ++-
.../billing-webhook-product.service.ts | 27 -----
...ion-event-to-database-subscription.util.ts | 2 +-
26 files changed, 414 insertions(+), 64 deletions(-)
create mode 100644 packages/twenty-front/src/modules/billing/graphql/endSubscriptionTrialPeriod.ts
create mode 100644 packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts
create mode 100644 packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx
create mode 100644 packages/twenty-front/src/modules/workspace/hooks/useIsSomeMeteredProductCapReached.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output.ts
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index 04ccfde9c..e0f840c73 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -137,6 +137,14 @@ export type Billing = {
trialPeriods: Array;
};
+export type BillingEndTrialPeriodOutput = {
+ __typename?: 'BillingEndTrialPeriodOutput';
+ /** Boolean that confirms if a payment method was found */
+ hasPaymentMethod: Scalars['Boolean'];
+ /** Updated subscription status */
+ status?: Maybe;
+};
+
/** The different billing plans available */
export enum BillingPlanKey {
ENTERPRISE = 'ENTERPRISE',
@@ -145,9 +153,9 @@ export enum BillingPlanKey {
export type BillingPlanOutput = {
__typename?: 'BillingPlanOutput';
- baseProduct: BillingProductDto;
- meteredProducts: Array;
- otherLicensedProducts: Array;
+ baseProduct: BillingProduct;
+ meteredProducts: Array;
+ otherLicensedProducts: Array;
planKey: BillingPlanKey;
};
@@ -183,13 +191,26 @@ export enum BillingPriceTiersMode {
export type BillingPriceUnionDto = BillingPriceLicensedDto | BillingPriceMeteredDto;
-export type BillingProductDto = {
- __typename?: 'BillingProductDTO';
+export type BillingProduct = {
+ __typename?: 'BillingProduct';
description: Scalars['String'];
images?: Maybe>;
+ metadata: BillingProductMetadata;
name: Scalars['String'];
- prices: Array;
- type: BillingUsageType;
+ prices?: Maybe>;
+};
+
+/** The different billing products available */
+export enum BillingProductKey {
+ BASE_PRODUCT = 'BASE_PRODUCT',
+ WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION'
+}
+
+export type BillingProductMetadata = {
+ __typename?: 'BillingProductMetadata';
+ planKey: BillingPlanKey;
+ priceUsageBased: BillingUsageType;
+ productKey: BillingProductKey;
};
export type BillingSessionOutput = {
@@ -199,11 +220,19 @@ export type BillingSessionOutput = {
export type BillingSubscription = {
__typename?: 'BillingSubscription';
+ billingSubscriptionItems?: Maybe>;
id: Scalars['UUID'];
interval?: Maybe;
status: SubscriptionStatus;
};
+export type BillingSubscriptionItem = {
+ __typename?: 'BillingSubscriptionItem';
+ billingProduct?: Maybe;
+ hasReachedCurrentPeriodCap: Scalars['Boolean'];
+ id: Scalars['UUID'];
+};
+
export type BillingTrialPeriodDto = {
__typename?: 'BillingTrialPeriodDTO';
duration: Scalars['Float'];
@@ -828,6 +857,7 @@ export type Mutation = {
editSSOIdentityProvider: EditSsoOutput;
emailPasswordResetLink: EmailPasswordResetLink;
enablePostgresProxy: PostgresCredentials;
+ endSubscriptionTrialPeriod: BillingEndTrialPeriodOutput;
executeOneServerlessFunction: ServerlessFunctionExecutionResult;
generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken;
@@ -2505,7 +2535,7 @@ export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePa
export type BillingBaseProductPricesQueryVariables = Exact<{ [key: string]: never; }>;
-export type BillingBaseProductPricesQuery = { __typename?: 'Query', plans: Array<{ __typename?: 'BillingPlanOutput', planKey: BillingPlanKey, baseProduct: { __typename?: 'BillingProductDTO', name: string, prices: Array<{ __typename?: 'BillingPriceLicensedDTO', unitAmount: number, stripePriceId: string, recurringInterval: SubscriptionInterval } | { __typename?: 'BillingPriceMeteredDTO' }> } }> };
+export type BillingBaseProductPricesQuery = { __typename?: 'Query', plans: Array<{ __typename?: 'BillingPlanOutput', planKey: BillingPlanKey, baseProduct: { __typename?: 'BillingProduct', name: string, prices?: Array<{ __typename?: 'BillingPriceLicensedDTO', unitAmount: number, stripePriceId: string, recurringInterval: SubscriptionInterval } | { __typename?: 'BillingPriceMeteredDTO' }> | null } }> };
export type BillingPortalSessionQueryVariables = Exact<{
returnUrlPath?: InputMaybe;
@@ -2524,6 +2554,11 @@ export type CheckoutSessionMutationVariables = Exact<{
export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'BillingSessionOutput', url?: string | null } };
+export type EndSubscriptionTrialPeriodMutationVariables = Exact<{ [key: string]: never; }>;
+
+
+export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endSubscriptionTrialPeriod: { __typename?: 'BillingEndTrialPeriodOutput', status?: SubscriptionStatus | null, hasPaymentMethod: boolean } };
+
export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>;
@@ -2695,7 +2730,7 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
-export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
+export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@@ -2712,7 +2747,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
-export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
+export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@@ -3036,6 +3071,19 @@ export const UserQueryFragmentFragmentDoc = gql`
id
status
interval
+ billingSubscriptionItems {
+ id
+ hasReachedCurrentPeriodCap
+ billingProduct {
+ name
+ description
+ metadata {
+ planKey
+ priceUsageBased
+ productKey
+ }
+ }
+ }
}
billingSubscriptions {
id
@@ -4133,6 +4181,39 @@ export function useCheckoutSessionMutation(baseOptions?: Apollo.MutationHookOpti
export type CheckoutSessionMutationHookResult = ReturnType;
export type CheckoutSessionMutationResult = Apollo.MutationResult;
export type CheckoutSessionMutationOptions = Apollo.BaseMutationOptions;
+export const EndSubscriptionTrialPeriodDocument = gql`
+ mutation EndSubscriptionTrialPeriod {
+ endSubscriptionTrialPeriod {
+ status
+ hasPaymentMethod
+ }
+}
+ `;
+export type EndSubscriptionTrialPeriodMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useEndSubscriptionTrialPeriodMutation__
+ *
+ * To run a mutation, you first call `useEndSubscriptionTrialPeriodMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useEndSubscriptionTrialPeriodMutation` 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 [endSubscriptionTrialPeriodMutation, { data, loading, error }] = useEndSubscriptionTrialPeriodMutation({
+ * variables: {
+ * },
+ * });
+ */
+export function useEndSubscriptionTrialPeriodMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(EndSubscriptionTrialPeriodDocument, options);
+ }
+export type EndSubscriptionTrialPeriodMutationHookResult = ReturnType;
+export type EndSubscriptionTrialPeriodMutationResult = Apollo.MutationResult;
+export type EndSubscriptionTrialPeriodMutationOptions = Apollo.BaseMutationOptions;
export const UpdateBillingSubscriptionDocument = gql`
mutation UpdateBillingSubscription {
updateBillingSubscription {
diff --git a/packages/twenty-front/src/modules/billing/graphql/endSubscriptionTrialPeriod.ts b/packages/twenty-front/src/modules/billing/graphql/endSubscriptionTrialPeriod.ts
new file mode 100644
index 000000000..6adc85f97
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/graphql/endSubscriptionTrialPeriod.ts
@@ -0,0 +1,10 @@
+import { gql } from '@apollo/client';
+
+export const END_SUBSCRIPTION_TRIAL_PERIOD = gql`
+ mutation EndSubscriptionTrialPeriod {
+ endSubscriptionTrialPeriod {
+ status
+ hasPaymentMethod
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts b/packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts
new file mode 100644
index 000000000..cd1f7f3d2
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts
@@ -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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
index 4d2a8426a..00cb4cb3d 100644
--- a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
@@ -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 (
@@ -46,6 +53,7 @@ export const InformationBannerWrapper = () => {
)}
{displayFailPaymentInfoBanner && }
+ {displayEndTrialPeriodBanner && }
);
};
diff --git a/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx b/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx
new file mode 100644
index 000000000..48755263a
--- /dev/null
+++ b/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx
@@ -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 (
+ await endTrialPeriod()}
+ isButtonDisabled={isLoading}
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts
index 9f4e0f2a8..e836d4b6d 100644
--- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts
+++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts
@@ -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
diff --git a/packages/twenty-front/src/modules/workspace/hooks/useIsSomeMeteredProductCapReached.ts b/packages/twenty-front/src/modules/workspace/hooks/useIsSomeMeteredProductCapReached.ts
new file mode 100644
index 000000000..344f1e65a
--- /dev/null
+++ b/packages/twenty-front/src/modules/workspace/hooks/useIsSomeMeteredProductCapReached.ts
@@ -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
+ );
+ });
+};
diff --git a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx
index 00e198045..04d0af2eb 100644
--- a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx
+++ b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx
@@ -125,7 +125,7 @@ export const ChooseYourPlan = () => {
(plan) => plan.planKey === currentPlan,
)?.baseProduct;
- const baseProductPrice = baseProduct?.prices.find(
+ const baseProductPrice = baseProduct?.prices?.find(
(price): price is BillingPriceLicensedDto =>
isBillingPriceLicensed(price) &&
price.recurringInterval === SubscriptionInterval.Month,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
index 35b26ea27..6589b9c9d 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
@@ -19,4 +19,5 @@ export enum BillingExceptionCode {
BILLING_MISSING_REQUEST_BODY = 'BILLING_MISSING_REQUEST_BODY',
BILLING_UNHANDLED_ERROR = 'BILLING_UNHANDLED_ERROR',
BILLING_STRIPE_ERROR = 'BILLING_STRIPE_ERROR',
+ BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD = 'BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD',
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts
index 411ec039a..82a6b1b69 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts
@@ -7,6 +7,7 @@ import { isDefined } from 'twenty-shared/utils';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
+import { BillingEndTrialPeriodOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output';
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
@@ -130,6 +131,17 @@ export class BillingResolver {
return plans.map(formatBillingDatabaseProductToGraphqlDTO);
}
+ @Mutation(() => BillingEndTrialPeriodOutput)
+ @UseGuards(
+ WorkspaceAuthGuard,
+ SettingsPermissionsGuard(SettingPermissionType.WORKSPACE),
+ )
+ async endSubscriptionTrialPeriod(
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ return await this.billingSubscriptionService.endTrialPeriod(workspace);
+ }
+
private async validateCanCheckoutSessionPermissionOrThrow({
workspaceId,
userWorkspaceId,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts
index 619583f80..ce4ad253e 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-product.dto.ts
@@ -5,9 +5,9 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { BillingPriceUnionDTO } from 'src/engine/core-modules/billing/dtos/billing-price-union.dto';
-import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
+import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
-@ObjectType()
+@ObjectType('BillingProduct')
export class BillingProductDTO {
@Field(() => String)
name: string;
@@ -18,9 +18,9 @@ export class BillingProductDTO {
@Field(() => [String], { nullable: true })
images: string[];
- @Field(() => BillingUsageType)
- type: BillingUsageType;
-
- @Field(() => [BillingPriceUnionDTO])
+ @Field(() => [BillingPriceUnionDTO], { nullable: true })
prices: Array | Array;
+
+ @Field(() => BillingProductMetadata)
+ metadata: BillingProductMetadata;
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output.ts
new file mode 100644
index 000000000..2ec23854d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output.ts
@@ -0,0 +1,19 @@
+/* @license Enterprise */
+
+import { Field, ObjectType } from '@nestjs/graphql';
+
+import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
+
+@ObjectType()
+export class BillingEndTrialPeriodOutput {
+ @Field(() => SubscriptionStatus, {
+ description: 'Updated subscription status',
+ nullable: true,
+ })
+ status: SubscriptionStatus | undefined;
+
+ @Field(() => Boolean, {
+ description: 'Boolean that confirms if a payment method was found',
+ })
+ hasPaymentMethod: boolean;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output.ts
new file mode 100644
index 000000000..4bc90bf8f
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output.ts
@@ -0,0 +1,20 @@
+/* @license Enterprise */
+
+import { Field, ObjectType } from '@nestjs/graphql';
+
+import { IDField } from '@ptc-org/nestjs-query-graphql';
+
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
+
+@ObjectType('BillingSubscriptionItem')
+export class BillingSubscriptionItemDTO {
+ @IDField(() => UUIDScalarType)
+ id: string;
+
+ @Field(() => Boolean)
+ hasReachedCurrentPeriodCap: boolean;
+
+ @Field(() => BillingProductDTO, { nullable: true })
+ billingProduct: BillingProductDTO;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts
index 1fdfcb6e0..96084e843 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts
@@ -5,6 +5,7 @@ import {
Column,
CreateDateColumn,
Entity,
+ JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
@@ -12,6 +13,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
+import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@Entity({ name: 'billingSubscriptionItem', schema: 'core' })
@Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [
@@ -52,6 +54,13 @@ export class BillingSubscriptionItem {
)
billingSubscription: Relation;
+ @ManyToOne(() => BillingProduct)
+ @JoinColumn({
+ name: 'stripeProductId',
+ referencedColumnName: 'stripeProductId',
+ })
+ billingProduct: Relation;
+
@Column({ nullable: false })
stripeProductId: string;
diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts
index b08f0f06e..6dafd27f5 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts
@@ -18,6 +18,7 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
@@ -72,6 +73,7 @@ export class BillingSubscription {
})
interval: Stripe.Price.Recurring.Interval;
+ @Field(() => [BillingSubscriptionItemDTO], { nullable: true })
@OneToMany(
() => BillingSubscriptionItem,
(billingSubscriptionItem) => billingSubscriptionItem.billingSubscription,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-product-key.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-product-key.enum.ts
index 6d1af2d98..206b4bc38 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-product-key.enum.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-product-key.enum.ts
@@ -1,6 +1,13 @@
/* @license Enterprise */
+import { registerEnumType } from '@nestjs/graphql';
+
export enum BillingProductKey {
BASE_PRODUCT = 'BASE_PRODUCT',
WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION',
}
+
+registerEnumType(BillingProductKey, {
+ name: 'BillingProductKey',
+ description: 'The different billing products available',
+});
diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-usage-type.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-usage-type.enum.ts
index 83fc9ebc7..1254771c7 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-usage-type.enum.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-usage-type.enum.ts
@@ -1,6 +1,13 @@
/* @license Enterprise */
+import { registerEnumType } from '@nestjs/graphql';
+
export enum BillingUsageType {
METERED = 'METERED',
LICENSED = 'LICENSED',
}
+
+registerEnumType(BillingUsageType, {
+ name: 'BillingUsageType',
+ description: 'The different billing usage types',
+});
diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
index 444605e1a..df039ae75 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
@@ -48,6 +48,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
404,
);
case BillingExceptionCode.BILLING_METER_EVENT_FAILED:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD:
return this.httpExceptionHandlerService.handleError(
exception,
response,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
index 126a7899c..52a92b12f 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
@@ -21,15 +21,15 @@ import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/bill
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
-import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
+import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
+import { getSubscriptionStatus } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class BillingSubscriptionService {
protected readonly logger = new Logger(BillingSubscriptionService.name);
constructor(
- private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly billingPlanService: BillingPlanService,
private readonly billingProductService: BillingProductService,
@@ -37,6 +37,7 @@ export class BillingSubscriptionService {
private readonly billingEntitlementRepository: Repository,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository,
+ private readonly stripeCustomerService: StripeCustomerService,
) {}
async getCurrentBillingSubscriptionOrThrow(criteria: {
@@ -46,7 +47,10 @@ export class BillingSubscriptionService {
const notCanceledSubscriptions =
await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
- relations: ['billingSubscriptionItems'],
+ relations: [
+ 'billingSubscriptionItems',
+ 'billingSubscriptionItems.billingProduct',
+ ],
});
assert(
@@ -195,4 +199,38 @@ export class BillingSubscriptionService {
return subscriptionItemsToUpdate;
}
+
+ async endTrialPeriod(workspace: Workspace) {
+ const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow(
+ { workspaceId: workspace.id },
+ );
+
+ if (billingSubscription.status !== SubscriptionStatus.Trialing) {
+ throw new BillingException(
+ 'Billing subscription is not in trial period',
+ BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD,
+ );
+ }
+
+ const hasPaymentMethod = await this.stripeCustomerService.hasPaymentMethod(
+ billingSubscription.stripeCustomerId,
+ );
+
+ if (!hasPaymentMethod) {
+ return { hasPaymentMethod: false, status: undefined };
+ }
+
+ const updatedSubscription =
+ await this.stripeSubscriptionService.updateSubscription(
+ billingSubscription.stripeSubscriptionId,
+ {
+ trial_end: 'now',
+ },
+ );
+
+ return {
+ status: getSubscriptionStatus(updatedSubscription.status),
+ hasPaymentMethod: true,
+ };
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts
index bb19b1cb1..0e500862d 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts
@@ -32,4 +32,11 @@ export class StripeCustomerService {
metadata: { workspaceId: workspaceId },
});
}
+
+ async hasPaymentMethod(stripeCustomerId: string) {
+ const { data: paymentMethods } =
+ await this.stripe.customers.listPaymentMethods(stripeCustomerId);
+
+ return paymentMethods.length > 0;
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts
index 0462a62e5..60df5eebe 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts
@@ -76,4 +76,11 @@ export class StripeSubscriptionService {
items: stripeSubscriptionItemsToUpdate,
});
}
+
+ async updateSubscription(
+ stripeSubscriptionId: string,
+ updateData: Stripe.SubscriptionUpdateParams,
+ ): Promise {
+ return this.stripe.subscriptions.update(stripeSubscriptionId, updateData);
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts
index 789a855ef..2983f5ee9 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts
@@ -1,14 +1,21 @@
/* @license Enterprise */
+import { Field, ObjectType } from '@nestjs/graphql';
+
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
-export type BillingProductMetadata =
- | {
- planKey: BillingPlanKey;
- priceUsageBased: BillingUsageType;
- productKey: BillingProductKey;
- [key: string]: string;
- }
- | Record;
+@ObjectType('BillingProductMetadata')
+export class BillingProductMetadata {
+ @Field(() => BillingPlanKey)
+ planKey: BillingPlanKey;
+
+ @Field(() => BillingUsageType)
+ priceUsageBased: BillingUsageType;
+
+ @Field(() => BillingProductKey)
+ productKey: BillingProductKey;
+
+ [key: string]: string;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts
index fe3a2ef4d..2d0e2d7ac 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/format-database-product-to-graphql-dto.util.spec.ts
@@ -68,6 +68,9 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
planKey: BillingPlanKey.PRO,
baseProduct: {
id: 'base-1',
+ metadata: {
+ priceUsageBased: BillingUsageType.LICENSED,
+ },
name: 'Base Product',
billingPrices: [
{
@@ -77,7 +80,6 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
priceUsageType: BillingUsageType.LICENSED,
},
],
- type: BillingUsageType.LICENSED,
prices: [
{
recurringInterval: SubscriptionInterval.Month,
@@ -90,6 +92,9 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
otherLicensedProducts: [
{
id: 'licensed-1',
+ metadata: {
+ priceUsageBased: BillingUsageType.LICENSED,
+ },
name: 'Licensed Product',
billingPrices: [
{
@@ -99,7 +104,6 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
priceUsageType: BillingUsageType.LICENSED,
},
],
- type: BillingUsageType.LICENSED,
prices: [
{
recurringInterval: SubscriptionInterval.Year,
@@ -113,6 +117,9 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
meteredProducts: [
{
id: 'metered-1',
+ metadata: {
+ priceUsageBased: BillingUsageType.METERED,
+ },
name: 'Metered Product',
billingPrices: [
{
@@ -129,7 +136,6 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
priceUsageType: BillingUsageType.METERED,
},
],
- type: BillingUsageType.METERED,
prices: [
{
tiersMode: BillingPriceTiersMode.GRADUATED,
@@ -191,6 +197,9 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
planKey: 'empty-plan',
baseProduct: {
id: 'base-1',
+ metadata: {
+ priceUsageBased: BillingUsageType.LICENSED,
+ },
name: 'Base Product',
billingPrices: [
{
@@ -200,7 +209,6 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
priceUsageType: BillingUsageType.LICENSED,
},
],
- type: BillingUsageType.LICENSED,
prices: [
{
recurringInterval: SubscriptionInterval.Month,
@@ -214,6 +222,9 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
meteredProducts: [
{
id: 'metered-1',
+ metadata: {
+ priceUsageBased: BillingUsageType.METERED,
+ },
name: 'Metered Product',
billingPrices: [
{
@@ -224,7 +235,6 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
priceUsageType: BillingUsageType.METERED,
},
],
- type: BillingUsageType.METERED,
prices: [
{
tiersMode: null,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts
index 07e0a15e2..719e73b3d 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util.ts
@@ -16,7 +16,10 @@ export const formatBillingDatabaseProductToGraphqlDTO = (
planKey: plan.planKey,
baseProduct: {
...plan.baseProduct,
- type: BillingUsageType.LICENSED,
+ metadata: {
+ ...plan.baseProduct.metadata,
+ priceUsageBased: BillingUsageType.LICENSED,
+ },
prices: plan.baseProduct.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
@@ -24,7 +27,10 @@ export const formatBillingDatabaseProductToGraphqlDTO = (
otherLicensedProducts: plan.otherLicensedProducts.map((product) => {
return {
...product,
- type: BillingUsageType.LICENSED,
+ metadata: {
+ ...product.metadata,
+ priceUsageBased: BillingUsageType.LICENSED,
+ },
prices: product.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
@@ -33,7 +39,10 @@ export const formatBillingDatabaseProductToGraphqlDTO = (
meteredProducts: plan.meteredProducts.map((product) => {
return {
...product,
- type: BillingUsageType.METERED,
+ metadata: {
+ ...product.metadata,
+ priceUsageBased: BillingUsageType.METERED,
+ },
prices: product.billingPrices.map(
formatBillingDatabasePriceToMeteredPriceDTO,
),
diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service.ts
index 633a74afb..333e88033 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service.ts
@@ -7,9 +7,6 @@ import Stripe from 'stripe';
import { Repository } from 'typeorm';
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
-import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
-import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
-import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util';
import { transformStripeProductEventToDatabaseProduct } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-product-event-to-database-product.util';
@Injectable()
@@ -40,28 +37,4 @@ export class BillingWebhookProductService {
stripeProductId: data.object.id,
};
}
-
- isStripeValidProductMetadata(
- metadata: Stripe.Metadata,
- ): metadata is BillingProductMetadata {
- if (Object.keys(metadata).length === 0) {
- return true;
- }
- const hasBillingPlanKey = this.isValidBillingPlanKey(metadata?.planKey);
- const hasPriceUsageBased = this.isValidPriceUsageBased(
- metadata?.priceUsageBased,
- );
-
- return hasBillingPlanKey && hasPriceUsageBased;
- }
-
- isValidBillingPlanKey(planKey?: string) {
- return Object.values(BillingPlanKey).includes(planKey as BillingPlanKey);
- }
-
- isValidPriceUsageBased(priceUsageBased?: string) {
- return Object.values(BillingUsageType).includes(
- priceUsageBased as BillingUsageType,
- );
- }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts
index 529d46e61..a75d9a3f3 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util.ts
@@ -53,7 +53,7 @@ export const transformStripeSubscriptionEventToDatabaseSubscription = (
};
};
-const getSubscriptionStatus = (status: Stripe.Subscription.Status) => {
+export const getSubscriptionStatus = (status: Stripe.Subscription.Status) => {
switch (status) {
case 'active':
return SubscriptionStatus.Active;