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:
@ -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',
|
||||
),
|
||||
],
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user