Update ChooseYourPlan page with new trial period options (#9628)

### Context
- Update /plan-required page to let users get free trial without credit
card plan
- Update usePageChangeEffectNavigateLocation to redirect paused and
canceled subscription (suspended workspace) to /settings/billing page

### To do

- [x] Update usePageChangeEffectNavigateLocation test
- [x] Update ChooseYourPlan sb test



closes #9520

---------

Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
Etienne
2025-01-16 11:10:36 +01:00
committed by GitHub
parent c79cb14132
commit 26058f3e25
40 changed files with 722 additions and 596 deletions

View File

@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Min } from 'class-validator';
@ObjectType()
export class TrialPeriodDTO {
@Field(() => Number)
@Min(0)
duration: number;
@Field(() => Boolean)
isCreditCardRequired: boolean;
}

View File

@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ -50,15 +51,15 @@ export class BillingPortalWorkspaceService {
workspaceId: workspace.id,
});
const stripeCustomerId = (
await this.billingSubscriptionRepository.findOneBy({
workspaceId: workspace.id,
})
)?.stripeCustomerId;
const subscription = await this.billingSubscriptionRepository.findOneBy({
workspaceId: workspace.id,
});
const session = await this.stripeCheckoutService.createCheckoutSession(
const stripeCustomerId = subscription?.stripeCustomerId;
const session = await this.stripeCheckoutService.createCheckoutSession({
user,
workspace.id,
workspaceId: workspace.id,
priceId,
quantity,
successUrl,
@ -66,7 +67,8 @@ export class BillingPortalWorkspaceService {
stripeCustomerId,
plan,
requirePaymentMethod,
);
withTrialPeriod: !isDefined(subscription),
});
assert(session.url, 'Error: missing checkout.session.url');

View File

@ -1,9 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ -16,15 +18,41 @@ export class BillingService {
private readonly environmentService: EnvironmentService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly isFeatureEnabledService: FeatureFlagService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
) {}
isBillingEnabled() {
return this.environmentService.get('IS_BILLING_ENABLED');
}
async hasWorkspaceActiveSubscriptionOrFreeAccessOrEntitlement(
async hasWorkspaceSubscriptionOrFreeAccess(workspaceId: string) {
const isBillingEnabled = this.isBillingEnabled();
if (!isBillingEnabled) {
return true;
}
const isFreeAccessEnabled =
await this.isFeatureEnabledService.isFeatureEnabled(
FeatureFlagKey.IsFreeAccessEnabled,
workspaceId,
);
if (isFreeAccessEnabled) {
return true;
}
const subscription = await this.billingSubscriptionRepository.findOne({
where: { workspaceId },
});
return isDefined(subscription);
}
async hasFreeAccessOrEntitlement(
workspaceId: string,
entitlementKey?: BillingEntitlementKey,
entitlementKey: BillingEntitlementKey,
) {
const isBillingEnabled = this.isBillingEnabled();
@ -42,25 +70,9 @@ export class BillingService {
return true;
}
if (entitlementKey) {
return this.billingSubscriptionService.getWorkspaceEntitlementByKey(
workspaceId,
entitlementKey,
);
}
const currentBillingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId },
);
return (
isDefined(currentBillingSubscription) &&
[
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
].includes(currentBillingSubscription.status)
return this.billingSubscriptionService.getWorkspaceEntitlementByKey(
workspaceId,
entitlementKey,
);
}
}

View File

@ -24,17 +24,29 @@ export class StripeCheckoutService {
);
}
async createCheckoutSession(
user: User,
workspaceId: string,
priceId: string,
quantity: number,
successUrl?: string,
cancelUrl?: string,
stripeCustomerId?: string,
plan: BillingPlanKey = BillingPlanKey.PRO,
async createCheckoutSession({
user,
workspaceId,
priceId,
quantity,
successUrl,
cancelUrl,
stripeCustomerId,
plan = BillingPlanKey.PRO,
requirePaymentMethod = true,
): Promise<Stripe.Checkout.Session> {
withTrialPeriod,
}: {
user: User;
workspaceId: string;
priceId: string;
quantity: number;
successUrl?: string;
cancelUrl?: string;
stripeCustomerId?: string;
plan?: BillingPlanKey;
requirePaymentMethod?: boolean;
withTrialPeriod: boolean;
}): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({
line_items: [
{
@ -48,14 +60,25 @@ export class StripeCheckoutService {
workspaceId,
plan,
},
trial_period_days: this.environmentService.get(
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
),
...(withTrialPeriod
? {
trial_period_days: this.environmentService.get(
requirePaymentMethod
? 'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS'
: 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS',
),
trial_settings: {
end_behavior: { missing_payment_method: 'pause' },
},
}
: {}),
},
automatic_tax: { enabled: !!requirePaymentMethod },
tax_id_collection: { enabled: !!requirePaymentMethod },
customer: stripeCustomerId,
customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
customer_update: stripeCustomerId
? { name: 'auto', address: 'auto' }
: undefined,
customer_email: stripeCustomerId ? undefined : user.email,
success_url: successUrl,
cancel_url: cancelUrl,

View File

@ -1,5 +1,6 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { TrialPeriodDTO } from 'src/engine/core-modules/billing/dto/trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
@ -11,8 +12,8 @@ class Billing {
@Field(() => String, { nullable: true })
billingUrl?: string;
@Field(() => Number, { nullable: true })
billingFreeTrialDurationInDays?: number;
@Field(() => [TrialPeriodDTO])
trialPeriods: TrialPeriodDTO[];
}
@ObjectType()

View File

@ -18,9 +18,20 @@ export class ClientConfigResolver {
billing: {
isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'),
billingUrl: this.environmentService.get('BILLING_PLAN_REQUIRED_LINK'),
billingFreeTrialDurationInDays: this.environmentService.get(
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
),
trialPeriods: [
{
duration: this.environmentService.get(
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
),
isCreditCardRequired: true,
},
{
duration: this.environmentService.get(
'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS',
),
isCreditCardRequired: false,
},
],
},
authProviders: {
google: this.environmentService.get('AUTH_GOOGLE_ENABLED'),

View File

@ -73,7 +73,13 @@ export class EnvironmentVariables {
@CastToPositiveNumber()
@IsOptional()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_TRIAL_DURATION_IN_DAYS = 7;
BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS = 30;
@IsNumber()
@CastToPositiveNumber()
@IsOptional()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS = 7;
@IsString()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)

View File

@ -30,7 +30,7 @@ export class OnboardingService {
private async isSubscriptionIncompleteOnboardingStatus(workspace: Workspace) {
const hasSubscription =
await this.billingService.hasWorkspaceActiveSubscriptionOrFreeAccessOrEntitlement(
await this.billingService.hasWorkspaceSubscriptionOrFreeAccess(
workspace.id,
);

View File

@ -36,7 +36,7 @@ export class SSOService {
private async isSSOEnabled(workspaceId: string) {
const isSSOBillingEnabled =
await this.billingService.hasWorkspaceActiveSubscriptionOrFreeAccessOrEntitlement(
await this.billingService.hasFreeAccessOrEntitlement(
workspaceId,
this.featureLookUpKey,
);

View File

@ -41,7 +41,10 @@ export class UserService extends TypeOrmQueryService<User> {
}
async loadWorkspaceMember(user: User, workspace: Workspace) {
if (workspace?.activationStatus !== WorkspaceActivationStatus.ACTIVE) {
if (
workspace?.activationStatus !== WorkspaceActivationStatus.ACTIVE &&
workspace?.activationStatus !== WorkspaceActivationStatus.SUSPENDED
) {
return null;
}