diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ff0e0ef5d9..ace0b07ac2 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -388,6 +388,8 @@ export type Billing = { export type BillingEndTrialPeriodOutput = { __typename?: 'BillingEndTrialPeriodOutput'; + /** Billing portal URL for payment method update (returned when no payment method exists) */ + billingPortalUrl?: Maybe; /** Boolean that confirms if a payment method was found */ hasPaymentMethod: Scalars['Boolean']; /** Updated subscription status */ @@ -5440,7 +5442,7 @@ export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession export type EndSubscriptionTrialPeriodMutationVariables = Exact<{ [key: string]: never; }>; -export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endSubscriptionTrialPeriod: { __typename?: 'BillingEndTrialPeriodOutput', status?: SubscriptionStatus | null, hasPaymentMethod: boolean } }; +export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endSubscriptionTrialPeriod: { __typename?: 'BillingEndTrialPeriodOutput', status?: SubscriptionStatus | null, hasPaymentMethod: boolean, billingPortalUrl?: string | null } }; export type SetMeteredSubscriptionPriceMutationVariables = Exact<{ priceId: Scalars['String']; @@ -9129,6 +9131,7 @@ export const EndSubscriptionTrialPeriodDocument = gql` endSubscriptionTrialPeriod { status hasPaymentMethod + billingPortalUrl } } `; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index c0b13c2a24..3e93948067 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -388,6 +388,8 @@ export type Billing = { export type BillingEndTrialPeriodOutput = { __typename?: 'BillingEndTrialPeriodOutput'; + /** Billing portal URL for payment method update (returned when no payment method exists) */ + billingPortalUrl?: Maybe; /** Boolean that confirms if a payment method was found */ hasPaymentMethod: Scalars['Boolean']; /** Updated subscription status */ diff --git a/packages/twenty-front/src/modules/billing/graphql/mutations/endSubscriptionTrialPeriod.ts b/packages/twenty-front/src/modules/billing/graphql/mutations/endSubscriptionTrialPeriod.ts index 6adc85f974..3dc2dcce45 100644 --- a/packages/twenty-front/src/modules/billing/graphql/mutations/endSubscriptionTrialPeriod.ts +++ b/packages/twenty-front/src/modules/billing/graphql/mutations/endSubscriptionTrialPeriod.ts @@ -5,6 +5,7 @@ export const END_SUBSCRIPTION_TRIAL_PERIOD = gql` endSubscriptionTrialPeriod { status hasPaymentMethod + billingPortalUrl } } `; diff --git a/packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts b/packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts index 0d4633ed61..de9493caf7 100644 --- a/packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts +++ b/packages/twenty-front/src/modules/billing/hooks/useEndSubscriptionTrialPeriod.ts @@ -1,4 +1,5 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { t } from '@lingui/core/macro'; import { useState } from 'react'; @@ -13,6 +14,7 @@ export const useEndSubscriptionTrialPeriod = () => { currentWorkspaceState, ); const [isLoading, setIsLoading] = useState(false); + const { redirect } = useRedirect(); const endTrialPeriod = async () => { try { @@ -24,6 +26,14 @@ export const useEndSubscriptionTrialPeriod = () => { const hasPaymentMethod = endTrialPeriodOutput?.hasPaymentMethod; if (isDefined(hasPaymentMethod) && hasPaymentMethod === false) { + const billingPortalUrl = endTrialPeriodOutput?.billingPortalUrl; + + if (isDefined(billingPortalUrl)) { + redirect(billingPortalUrl); + + return { success: false }; + } + enqueueErrorSnackBar({ message: t`No payment method found. Please update your billing details.`, }); 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 f9a2a5f5a3..10ed4e4d35 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 @@ -266,7 +266,28 @@ export class BillingResolver { async endSubscriptionTrialPeriod( @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - return await this.billingSubscriptionService.endTrialPeriod(workspace); + const result = + await this.billingSubscriptionService.endTrialPeriod(workspace); + + if (!result.hasPaymentMethod && result.stripeCustomerId) { + const billingPortalUrl = + await this.billingPortalWorkspaceService.computeBillingPortalSessionURLForPaymentMethodUpdate( + workspace, + result.stripeCustomerId, + '/settings/billing', + ); + + return { + hasPaymentMethod: false, + status: undefined, + billingPortalUrl, + }; + } + + return { + hasPaymentMethod: result.hasPaymentMethod, + status: result.status, + }; } @Query(() => [BillingMeteredProductUsageOutput]) 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 index 2ec23854d1..20f3001ebd 100644 --- 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 @@ -16,4 +16,11 @@ export class BillingEndTrialPeriodOutput { description: 'Boolean that confirms if a payment method was found', }) hasPaymentMethod: boolean; + + @Field(() => String, { + description: + 'Billing portal URL for payment method update (returned when no payment method exists)', + nullable: true, + }) + billingPortalUrl?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index 5f55f2d99d..dc789839f7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -3,7 +3,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { findOrThrow, isDefined, isNonEmptyArray } from 'twenty-shared/utils'; +import { + assertIsDefinedOrThrow, + findOrThrow, + isDefined, + isNonEmptyArray, +} from 'twenty-shared/utils'; import { Not, Repository } from 'typeorm'; import type Stripe from 'stripe'; @@ -26,7 +31,6 @@ import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-mod import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { assert } from 'src/utils/assert'; @Injectable() export class BillingPortalWorkspaceService { @@ -73,7 +77,13 @@ export class BillingPortalWorkspaceService { !isDefined(customer) || customer.billingSubscriptions.length === 0, }); - assert(checkoutSession.url, 'Error: missing checkout.session.url'); + assertIsDefinedOrThrow( + checkoutSession.url, + new BillingException( + 'Error: missing checkout.session.url', + BillingExceptionCode.BILLING_STRIPE_ERROR, + ), + ); return checkoutSession.url; } @@ -209,7 +219,44 @@ export class BillingPortalWorkspaceService { returnUrl, ); - assert(session.url, 'Error: missing billingPortal.session.url'); + assertIsDefinedOrThrow( + session.url, + new BillingException( + 'Error: missing billingPortal.session.url', + BillingExceptionCode.BILLING_STRIPE_ERROR, + ), + ); + + return session.url; + } + + async computeBillingPortalSessionURLForPaymentMethodUpdate( + workspace: WorkspaceEntity, + stripeCustomerId: string, + returnUrlPath?: string, + ) { + const frontBaseUrl = this.workspaceDomainsService.buildWorkspaceURL({ + workspace, + }); + + if (returnUrlPath) { + frontBaseUrl.pathname = returnUrlPath; + } + const returnUrl = frontBaseUrl.toString(); + + const session = + await this.stripeBillingPortalService.createBillingPortalSessionForPaymentMethodUpdate( + stripeCustomerId, + returnUrl, + ); + + assertIsDefinedOrThrow( + session.url, + new BillingException( + 'Error: missing billingPortal.session.url', + BillingExceptionCode.BILLING_STRIPE_ERROR, + ), + ); return session.url; } 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 9ab1bbdcf3..00980e72c1 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 @@ -205,7 +205,11 @@ export class BillingSubscriptionService { ); if (!hasPaymentMethod) { - return { hasPaymentMethod: false, status: undefined }; + return { + hasPaymentMethod: false, + status: undefined, + stripeCustomerId: billingSubscription.stripeCustomerId, + }; } const updatedSubscription = diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts index e580f105ab..e6b07d1abc 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts @@ -36,4 +36,18 @@ export class StripeBillingPortalService { returnUrl ?? this.domainServerConfigService.getBaseUrl().toString(), }); } + + async createBillingPortalSessionForPaymentMethodUpdate( + stripeCustomerId: string, + returnUrl?: string, + ): Promise { + return await this.stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: + returnUrl ?? this.domainServerConfigService.getBaseUrl().toString(), + flow_data: { + type: 'payment_method_update', + }, + }); + } }