5095 move onboardingstatus computation from frontend to backend (#5954)

- move front `onboardingStatus` computing to server side
- add logic to `useSetNextOnboardingStatus`
- update some missing redirections in
`usePageChangeEffectNavigateLocation`
- separate subscriptionStatus from onboardingStatus
This commit is contained in:
martmull
2024-06-28 17:32:02 +02:00
committed by GitHub
parent 1a66db5bff
commit b8f33f6f59
78 changed files with 1767 additions and 1763 deletions

View File

@ -10,13 +10,19 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@Module({
imports: [
StripeModule,
UserWorkspaceModule,
TypeOrmModule.forFeature(
[BillingSubscription, BillingSubscriptionItem, Workspace],
[
BillingSubscription,
BillingSubscriptionItem,
Workspace,
FeatureFlagEntity,
],
'core',
),
],

View File

@ -2,17 +2,25 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Not, Repository } from 'typeorm';
import { In, Not, Repository } from 'typeorm';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
BillingSubscription,
SubscriptionInterval,
SubscriptionStatus,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { assert } from 'src/utils/assert';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import {
FeatureFlagEntity,
FeatureFlagKeys,
} from 'src/engine/core-modules/feature-flag/feature-flag.entity';
export enum AvailableProduct {
BasePlan = 'base-plan',
@ -34,12 +42,45 @@ export class BillingService {
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
async getActiveSubscriptionWorkspaceIds() {
return (
await this.workspaceRepository.find({
where: this.environmentService.get('IS_BILLING_ENABLED')
? {
currentBillingSubscription: {
status: In([
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
]),
},
}
: {},
select: ['id'],
})
).map((workspace) => workspace.id);
}
async isBillingEnabledForWorkspace(workspaceId: string) {
const isFreeAccessEnabled = await this.featureFlagRepository.findOneBy({
workspaceId,
key: FeatureFlagKeys.IsFreeAccessEnabled,
value: true,
});
return (
!isFreeAccessEnabled && this.environmentService.get('IS_BILLING_ENABLED')
);
}
getProductStripeId(product: AvailableProduct) {
if (product === AvailableProduct.BasePlan) {
return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID');
@ -84,13 +125,13 @@ export class BillingService {
}) {
const notCanceledSubscriptions =
await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not('canceled') },
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
relations: ['billingSubscriptionItems'],
});
assert(
notCanceledSubscriptions.length <= 1,
`More than on not canceled subscription for workspace ${criteria.workspaceId}`,
`More than one not canceled subscription for workspace ${criteria.workspaceId}`,
);
return notCanceledSubscriptions?.[0];
@ -171,7 +212,9 @@ export class BillingService {
workspaceId: user.defaultWorkspaceId,
});
const newInterval =
billingSubscription?.interval === 'year' ? 'month' : 'year';
billingSubscription?.interval === SubscriptionInterval.Year
? SubscriptionInterval.Month
: SubscriptionInterval.Year;
const billingSubscriptionItem = await this.getBillingSubscriptionItem(
user.defaultWorkspaceId,
);
@ -265,10 +308,6 @@ export class BillingService {
return;
}
await this.workspaceRepository.update(workspaceId, {
subscriptionStatus: data.object.status,
});
await this.billingSubscriptionRepository.upsert(
{
workspaceId: workspaceId,
@ -302,5 +341,10 @@ export class BillingService {
skipUpdateIfNoValuesChanged: true,
},
);
await this.featureFlagRepository.delete({
workspaceId,
key: FeatureFlagKeys.IsFreeAccessEnabled,
});
}
}

View File

@ -3,9 +3,11 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import Stripe from 'stripe';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ArgsType()
export class CheckoutSessionInput {
@Field(() => String)
@Field(() => SubscriptionInterval)
@IsString()
@IsNotEmpty()
recurringInterval: Stripe.Price.Recurring.Interval;

View File

@ -2,9 +2,11 @@ import { Field, ObjectType } from '@nestjs/graphql';
import Stripe from 'stripe';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ObjectType()
export class ProductPriceEntity {
@Field(() => String)
@Field(() => SubscriptionInterval)
recurringInterval: Stripe.Price.Recurring.Interval;
@Field(() => Number)

View File

@ -1,4 +1,4 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import {
Column,
@ -18,6 +18,27 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
export enum SubscriptionStatus {
Active = 'active',
Canceled = 'canceled',
Incomplete = 'incomplete',
IncompleteExpired = 'incomplete_expired',
PastDue = 'past_due',
Paused = 'paused',
Trialing = 'trialing',
Unpaid = 'unpaid',
}
export enum SubscriptionInterval {
Day = 'day',
Month = 'month',
Week = 'week',
Year = 'year',
}
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' });
@Entity({ name: 'billingSubscription', schema: 'core' })
@ObjectType('BillingSubscription')
export class BillingSubscription {
@ -49,12 +70,20 @@ export class BillingSubscription {
@Column({ unique: true, nullable: false })
stripeSubscriptionId: string;
@Field(() => String)
@Column({ type: 'text', nullable: false })
@Field(() => SubscriptionStatus)
@Column({
type: 'enum',
enum: Object.values(SubscriptionStatus),
nullable: false,
})
status: Stripe.Subscription.Status;
@Field(() => String, { nullable: true })
@Column({ type: 'text', nullable: true })
@Field(() => SubscriptionInterval, { nullable: true })
@Column({
type: 'enum',
enum: Object.values(SubscriptionInterval),
nullable: true,
})
interval: Stripe.Price.Recurring.Interval;
@OneToMany(

View File

@ -9,15 +9,15 @@ import {
UpdateSubscriptionJob,
UpdateSubscriptionJobData,
} from 'src/engine/core-modules/billing/jobs/update-subscription.job';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
@Injectable()
export class BillingWorkspaceMemberListener {
constructor(
@InjectMessageQueue(MessageQueue.billingQueue)
private readonly messageQueueService: MessageQueueService,
private readonly environmentService: EnvironmentService,
private readonly billingService: BillingService,
) {}
@OnEvent('workspaceMember.created')
@ -25,7 +25,12 @@ export class BillingWorkspaceMemberListener {
async handleCreateOrDeleteEvent(
payload: ObjectRecordCreateEvent<WorkspaceMemberWorkspaceEntity>,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
const isBillingEnabledForWorkspace =
await this.billingService.isBillingEnabledForWorkspace(
payload.workspaceId,
);
if (!isBillingEnabledForWorkspace) {
return;
}