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) Screenshot 2025-04-04 at 10 27 32 --- .../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;