40 remove self billing feature flag (#4379)

* Define quantity at checkout

* Remove billing submenu when not isBillingEnabled

* Remove feature flag

* Log warning when missing subscription active workspace add or remove member

* Display subscribe cta for free usage of twenty

* Authorize all settings when subscription canceled or unpaid

* Display subscribe cta for workspace with canceled subscription

* Replace OneToOne by OneToMany

* Add a currentBillingSubscriptionField

* Handle multiple subscriptions by workspace

* Fix redirection

* Fix test

* Fix billingState
This commit is contained in:
martmull
2024-03-12 18:10:27 +01:00
committed by GitHub
parent 4476f5215b
commit 62d414ee66
23 changed files with 292 additions and 247 deletions

View File

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

View File

@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Repository } from 'typeorm';
import { Not, Repository } from 'typeorm';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { StripeService } from 'src/core/billing/stripe/stripe.service';
@ -12,6 +12,7 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
export enum AvailableProduct {
BasePlan = 'base-plan',
@ -29,6 +30,7 @@ export class BillingService {
protected readonly logger = new Logger(BillingService.name);
constructor(
private readonly stripeService: StripeService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@ -76,24 +78,38 @@ export class BillingService {
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
}
async getBillingSubscription(criteria: {
async getCurrentBillingSubscription(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}) {
return await this.billingSubscriptionRepository.findOneOrFail({
where: criteria,
relations: ['billingSubscriptionItems'],
});
const notCanceledSubscriptions =
await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not('canceled') },
relations: ['billingSubscriptionItems'],
});
assert(
notCanceledSubscriptions.length <= 1,
`More than on not canceled subscription for workspace ${criteria.workspaceId}`,
);
return notCanceledSubscriptions?.[0];
}
async getBillingSubscriptionItem(
workspaceId: string,
stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
) {
const billingSubscription = await this.getBillingSubscription({
const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (!billingSubscription) {
throw new Error(
`Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
);
}
const billingSubscriptionItem =
billingSubscription.billingSubscriptionItems.filter(
(billingSubscriptionItem) =>
@ -143,11 +159,27 @@ export class BillingService {
? frontBaseUrl + successUrlPath
: frontBaseUrl;
let quantity = 1;
const stripeCustomerId = (
await this.billingSubscriptionRepository.findOneBy({
workspaceId: user.defaultWorkspaceId,
})
)?.stripeCustomerId;
try {
quantity = await this.userWorkspaceService.getWorkspaceMemberCount(
user.defaultWorkspaceId,
);
} catch (e) {}
const session = await this.stripeService.createCheckoutSession(
user,
priceId,
quantity,
successUrl,
frontBaseUrl,
stripeCustomerId,
);
assert(session.url, 'Error: missing checkout.session.url');
@ -170,18 +202,14 @@ export class BillingService {
}
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
try {
const billingSubscription = await this.getBillingSubscription({
stripeCustomerId: data.object.customer as string,
});
const billingSubscription = await this.getCurrentBillingSubscription({
stripeCustomerId: data.object.customer as string,
});
if (billingSubscription.status === 'unpaid') {
await this.stripeService.collectLastInvoice(
billingSubscription.stripeSubscriptionId,
);
}
} catch (err) {
return;
if (billingSubscription?.status === 'unpaid') {
await this.stripeService.collectLastInvoice(
billingSubscription.stripeSubscriptionId,
);
}
}
@ -189,7 +217,8 @@ export class BillingService {
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data,
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) {
await this.billingSubscriptionRepository.upsert(
{
@ -199,7 +228,7 @@ export class BillingService {
status: data.object.status,
},
{
conflictPaths: ['workspaceId'],
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
@ -208,10 +237,14 @@ export class BillingService {
subscriptionStatus: data.object.status,
});
const billingSubscription = await this.getBillingSubscription({
const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (!billingSubscription) {
return;
}
await this.billingSubscriptionItemRepository.upsert(
data.object.items.data.map((item) => {
return {

View File

@ -1,20 +1,25 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Stripe from 'stripe';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
@Entity({ name: 'billingSubscription', schema: 'core' })
@ObjectType('BillingSubscription')
export class BillingSubscription {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid')
id: string;
@ -27,7 +32,7 @@ export class BillingSubscription {
@UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date;
@OneToOne(() => Workspace, (workspace) => workspace.billingSubscription, {
@ManyToOne(() => Workspace, (workspace) => workspace.billingSubscriptions, {
onDelete: 'CASCADE',
})
@JoinColumn()
@ -36,12 +41,13 @@ export class BillingSubscription {
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@Column({ unique: true, nullable: false })
@Column({ nullable: false })
stripeCustomerId: string;
@Column({ unique: true, nullable: false })
stripeSubscriptionId: string;
@Field()
@Column({ nullable: false })
status: Stripe.Subscription.Status;

View File

@ -1,16 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
import { BillingService } from 'src/core/billing/billing.service';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import {
FeatureFlagEntity,
FeatureFlagKeys,
} from 'src/core/feature-flag/feature-flag.entity';
import { StripeService } from 'src/core/billing/stripe/stripe.service';
export type UpdateSubscriptionJobData = { workspaceId: string };
@Injectable()
@ -22,21 +15,9 @@ export class UpdateSubscriptionJob
private readonly billingService: BillingService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly stripeService: StripeService,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {}
async handle(data: UpdateSubscriptionJobData): Promise<void> {
const isSelfBillingEnabled = await this.featureFlagRepository.findOneBy({
workspaceId: data.workspaceId,
key: FeatureFlagKeys.IsSelfBillingEnabled,
value: true,
});
if (!isSelfBillingEnabled) {
return;
}
const workspaceMembersCount =
await this.userWorkspaceService.getWorkspaceMemberCount(data.workspaceId);
@ -44,16 +25,22 @@ export class UpdateSubscriptionJob
return;
}
const billingSubscriptionItem =
await this.billingService.getBillingSubscriptionItem(data.workspaceId);
try {
const billingSubscriptionItem =
await this.billingService.getBillingSubscriptionItem(data.workspaceId);
await this.stripeService.updateSubscriptionItem(
billingSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount,
);
await this.stripeService.updateSubscriptionItem(
billingSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount,
);
this.logger.log(
`Updating workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members`,
);
this.logger.log(
`Updating workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members`,
);
} catch (e) {
this.logger.warn(
`Failed to update workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members. Error: ${e}`,
);
}
}
}

View File

@ -1,15 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { Repository } from 'typeorm';
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
import {
FeatureFlagEntity,
FeatureFlagKeys,
} from 'src/core/feature-flag/feature-flag.entity';
import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/object-record-create.event';
import { WorkspaceMemberObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/workspace-member.object-metadata';
import {
@ -22,8 +15,6 @@ export class BillingWorkspaceMemberListener {
constructor(
@Inject(MessageQueue.billingQueue)
private readonly messageQueueService: MessageQueueService,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {}
@OnEvent('workspaceMember.created')
@ -31,17 +22,6 @@ export class BillingWorkspaceMemberListener {
async handleCreateOrDeleteEvent(
payload: ObjectRecordCreateEvent<WorkspaceMemberObjectMetadata>,
) {
const isSelfBillingFeatureFlag = await this.featureFlagRepository.findOneBy(
{
key: FeatureFlagKeys.IsSelfBillingEnabled,
value: true,
workspaceId: payload.workspaceId,
},
);
if (!isSelfBillingFeatureFlag) {
return;
}
await this.messageQueueService.add<UpdateSubscriptionJobData>(
UpdateSubscriptionJob.name,
{ workspaceId: payload.workspaceId },

View File

@ -55,14 +55,16 @@ export class StripeService {
async createCheckoutSession(
user: User,
priceId: string,
quantity: number,
successUrl?: string,
cancelUrl?: string,
stripeCustomerId?: string,
): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
quantity,
},
],
mode: 'subscription',
@ -75,7 +77,9 @@ export class StripeService {
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
customer_email: user.email,
customer: stripeCustomerId,
customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
customer_email: stripeCustomerId ? undefined : user.email,
success_url: successUrl,
cancel_url: cancelUrl,
});

View File

@ -16,7 +16,6 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
export enum FeatureFlagKeys {
IsBlocklistEnabled = 'IS_BLOCKLIST_ENABLED',
IsCalendarEnabled = 'IS_CALENDAR_ENABLED',
IsSelfBillingEnabled = 'IS_SELF_BILLING_ENABLED',
}
@Entity({ name: 'featureFlag', schema: 'core' })

View File

@ -6,7 +6,6 @@ import {
CreateDateColumn,
Entity,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@ -20,6 +19,9 @@ import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
@Entity({ name: 'workspace', schema: 'core' })
@ObjectType('Workspace')
@UnPagedRelation('featureFlags', () => FeatureFlagEntity, { nullable: true })
@UnPagedRelation('billingSubscriptions', () => BillingSubscription, {
nullable: true,
})
export class Workspace {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid')
@ -72,12 +74,15 @@ export class Workspace {
@Column({ default: 'incomplete' })
subscriptionStatus: Stripe.Subscription.Status;
@Field({ nullable: true })
currentBillingSubscription: BillingSubscription;
@Field()
activationStatus: 'active' | 'inactive';
@OneToOne(
@OneToMany(
() => BillingSubscription,
(billingSubscription) => billingSubscription.workspace,
)
billingSubscription: BillingSubscription;
billingSubscriptions: BillingSubscription[];
}

View File

@ -22,6 +22,8 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
import { User } from 'src/core/user/user.entity';
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
import { ActivateWorkspaceInput } from 'src/core/workspace/dtos/activate-workspace-input';
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
import { BillingService } from 'src/core/billing/billing.service';
import { Workspace } from './workspace.entity';
@ -34,6 +36,7 @@ export class WorkspaceResolver {
private readonly workspaceService: WorkspaceService,
private readonly fileUploadService: FileUploadService,
private readonly environmentService: EnvironmentService,
private readonly billingService: BillingService,
) {}
@Query(() => Workspace)
@ -108,4 +111,13 @@ export class WorkspaceResolver {
return 'inactive';
}
@ResolveField(() => BillingSubscription)
async currentBillingSubscription(
@Parent() workspace: Workspace,
): Promise<BillingSubscription | null> {
return this.billingService.getCurrentBillingSubscription({
workspaceId: workspace.id,
});
}
}

View File

@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateBillingSubscription1709914564361
implements MigrationInterface
{
name = 'UpdateBillingSubscription1709914564361';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_4abfb70314c18da69e1bee1954d"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "REL_4abfb70314c18da69e1bee1954"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "UQ_9120b7586c3471463480b58d20a"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "FK_4abfb70314c18da69e1bee1954d" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_4abfb70314c18da69e1bee1954d"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "UQ_9120b7586c3471463480b58d20a" UNIQUE ("stripeCustomerId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "REL_4abfb70314c18da69e1bee1954" UNIQUE ("workspaceId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "FK_4abfb70314c18da69e1bee1954d" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}