41 update subscription when workspace member changes 2 (#4252)

* Add loader and disabling on checkout button

* Add Stripe Subscription Item id to subscriptionItem entity

* Handle create and delete workspace members

* Update billing webhook

* Make stripe attribute private

* Fixing webhook error

* Clean migration

* Cancel subscription when deleting workspace

* Fix test

* Add freetrial

* Update navigate after signup

* Add automatic tax collection
This commit is contained in:
martmull 2024-03-01 17:29:28 +01:00 committed by GitHub
parent aa7ead3e8c
commit 8f6200be7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 436 additions and 131 deletions

View File

@ -17,7 +17,7 @@ export const useNavigateAfterSignInUp = () => {
) => { ) => {
if ( if (
billing?.isBillingEnabled && billing?.isBillingEnabled &&
currentWorkspace.subscriptionStatus !== 'active' !['active', 'trialing'].includes(currentWorkspace.subscriptionStatus)
) { ) {
navigate(AppPath.PlanRequired); navigate(AppPath.PlanRequired);
return; return;

View File

@ -8,6 +8,7 @@ import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit.ts
import { SubscriptionCard } from '@/billing/components/SubscriptionCard.tsx'; import { SubscriptionCard } from '@/billing/components/SubscriptionCard.tsx';
import { billingState } from '@/client-config/states/billingState.ts'; import { billingState } from '@/client-config/states/billingState.ts';
import { AppPath } from '@/types/AppPath.ts'; import { AppPath } from '@/types/AppPath.ts';
import { Loader } from '@/ui/feedback/loader/components/Loader.tsx';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx';
import { MainButton } from '@/ui/input/button/components/MainButton.tsx'; import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
import { CardPicker } from '@/ui/input/components/CardPicker.tsx'; import { CardPicker } from '@/ui/input/components/CardPicker.tsx';
@ -44,6 +45,8 @@ export const ChooseYourPlan = () => {
const [planSelected, setPlanSelected] = useState('month'); const [planSelected, setPlanSelected] = useState('month');
const [isSubmitting, setIsSubmitting] = useState(false);
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const { data: prices } = useGetProductPricesQuery({ const { data: prices } = useGetProductPricesQuery({
@ -77,12 +80,14 @@ export const ChooseYourPlan = () => {
}; };
const handleButtonClick = async () => { const handleButtonClick = async () => {
setIsSubmitting(true);
const { data } = await checkout({ const { data } = await checkout({
variables: { variables: {
recurringInterval: planSelected, recurringInterval: planSelected,
successUrlPath: AppPath.PlanRequiredSuccess, successUrlPath: AppPath.PlanRequiredSuccess,
}, },
}); });
setIsSubmitting(false);
if (!data?.checkout.url) { if (!data?.checkout.url) {
enqueueSnackBar( enqueueSnackBar(
'Checkout session error. Please retry or contact Twenty team', 'Checkout session error. Please retry or contact Twenty team',
@ -126,7 +131,13 @@ export const ChooseYourPlan = () => {
<SubscriptionBenefit>Frequent updates</SubscriptionBenefit> <SubscriptionBenefit>Frequent updates</SubscriptionBenefit>
<SubscriptionBenefit>And much more</SubscriptionBenefit> <SubscriptionBenefit>And much more</SubscriptionBenefit>
</StyledBenefitsContainer> </StyledBenefitsContainer>
<MainButton title="Continue" onClick={handleButtonClick} width={200} /> <MainButton
title="Continue"
onClick={handleButtonClick}
width={200}
Icon={() => isSubmitting && <Loader />}
disabled={isSubmitting}
/>
</> </>
) )
); );

View File

@ -29,7 +29,7 @@ export class BillingController {
@Res() res: Response, @Res() res: Response,
) { ) {
if (!req.rawBody) { if (!req.rawBody) {
res.status(400).send('Missing raw body'); res.status(400).end();
return; return;
} }
@ -38,27 +38,23 @@ export class BillingController {
req.rawBody, req.rawBody,
); );
if (event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED) { if (
if (event.data.object.status !== 'active') { event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED ||
res.status(402).send('Payment did not succeeded'); event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED
) {
return;
}
const workspaceId = event.data.object.metadata?.workspaceId; const workspaceId = event.data.object.metadata?.workspaceId;
if (!workspaceId) { if (!workspaceId) {
res.status(404).send('Missing workspaceId in webhook event metadata'); res.status(404).end();
return; return;
} }
await this.billingService.createBillingSubscription( await this.billingService.upsertBillingSubscription(
workspaceId, workspaceId,
event.data, event.data,
); );
res.status(200).send('Subscription successfully updated');
} }
res.status(200).end();
} }
} }

View File

@ -8,16 +8,24 @@ import { BillingSubscription } from 'src/core/billing/entities/billing-subscript
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity'; import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { BillingResolver } from 'src/core/billing/billing.resolver'; 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';
@Module({ @Module({
imports: [ imports: [
StripeModule, StripeModule,
TypeOrmModule.forFeature( TypeOrmModule.forFeature(
[BillingSubscription, BillingSubscriptionItem, Workspace], [
BillingSubscription,
BillingSubscriptionItem,
Workspace,
FeatureFlagEntity,
],
'core', 'core',
), ),
], ],
controllers: [BillingController], controllers: [BillingController],
providers: [BillingService, BillingResolver], providers: [BillingService, BillingResolver, BillingWorkspaceMemberListener],
exports: [BillingService],
}) })
export class BillingModule {} export class BillingModule {}

View File

@ -11,13 +11,13 @@ import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subsc
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity'; import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert';
export enum AvailableProduct { export enum AvailableProduct {
BasePlan = 'base-plan', BasePlan = 'base-plan',
} }
export enum WebhookEvent { export enum WebhookEvent {
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
} }
@ -42,9 +42,8 @@ export class BillingService {
} }
async getProductPrices(stripeProductId: string) { async getProductPrices(stripeProductId: string) {
const productPrices = await this.stripeService.stripe.prices.search({ const productPrices =
query: `product: '${stripeProductId}'`, await this.stripeService.getProductPrices(stripeProductId);
});
return this.formatProductPrices(productPrices.data); return this.formatProductPrices(productPrices.data);
} }
@ -74,63 +73,101 @@ export class BillingService {
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount); return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
} }
async checkout(user: User, priceId: string, successUrlPath?: string) { async getBillingSubscription(workspaceId: string) {
const frontBaseUrl = this.environmentService.getFrontBaseUrl(); return await this.billingSubscriptionRepository.findOneOrFail({
const session = await this.stripeService.stripe.checkout.sessions.create({ where: { workspaceId },
line_items: [ relations: ['billingSubscriptionItems'],
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId: user.defaultWorkspace.id,
},
},
customer_email: user.email,
success_url: successUrlPath
? frontBaseUrl + successUrlPath
: frontBaseUrl,
cancel_url: frontBaseUrl,
}); });
assert(session.url, 'Error: missing checkout.session.url');
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
return session.url;
} }
async createBillingSubscription( async getBillingSubscriptionItem(
workspaceId: string, workspaceId: string,
data: Stripe.CustomerSubscriptionUpdatedEvent.Data, stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
) { ) {
const billingSubscription = this.billingSubscriptionRepository.create({ const billingSubscription = await this.getBillingSubscription(workspaceId);
workspaceId: workspaceId,
stripeCustomerId: data.object.customer as string, const billingSubscriptionItem =
stripeSubscriptionId: data.object.id, billingSubscription.billingSubscriptionItems.filter(
status: data.object.status, (billingSubscriptionItem) =>
billingSubscriptionItem.stripeProductId === stripeProductId,
)?.[0];
if (!billingSubscriptionItem) {
throw new Error(
`Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
);
}
return billingSubscriptionItem;
}
async checkout(user: User, priceId: string, successUrlPath?: string) {
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
const successUrl = successUrlPath
? frontBaseUrl + successUrlPath
: frontBaseUrl;
return await this.stripeService.createCheckoutSession(
user,
priceId,
successUrl,
frontBaseUrl,
);
}
async deleteSubscription(workspaceId: string) {
const subscriptionToCancel =
await this.billingSubscriptionRepository.findOneBy({
workspaceId,
});
if (subscriptionToCancel) {
await this.stripeService.cancelSubscription(
subscriptionToCancel.stripeSubscriptionId,
);
await this.billingSubscriptionRepository.delete(subscriptionToCancel.id);
}
}
async upsertBillingSubscription(
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data,
) {
await this.billingSubscriptionRepository.upsert(
{
workspaceId: workspaceId,
stripeCustomerId: data.object.customer as string,
stripeSubscriptionId: data.object.id,
status: data.object.status,
},
{
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
await this.workspaceRepository.update(workspaceId, {
subscriptionStatus: data.object.status,
}); });
await this.billingSubscriptionRepository.save(billingSubscription); const billingSubscription = await this.getBillingSubscription(workspaceId);
for (const item of data.object.items.data) { await this.billingSubscriptionItemRepository.upsert(
const billingSubscriptionItem = data.object.items.data.map((item) => {
this.billingSubscriptionItemRepository.create({ return {
billingSubscriptionId: billingSubscription.id, billingSubscriptionId: billingSubscription.id,
stripeProductId: item.price.product as string, stripeProductId: item.price.product as string,
stripePriceId: item.price.id, stripePriceId: item.price.id,
stripeSubscriptionItemId: item.id,
quantity: item.quantity, quantity: item.quantity,
}); };
}),
await this.billingSubscriptionItemRepository.save( {
billingSubscriptionItem, conflictPaths: ['stripeSubscriptionItemId', 'billingSubscriptionId'],
); skipUpdateIfNoValuesChanged: true,
} },
await this.workspaceRepository.update(workspaceId, { );
subscriptionStatus: 'active',
});
} }
} }

View File

@ -4,12 +4,21 @@ import {
Entity, Entity,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Unique,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
@Entity({ name: 'billingSubscriptionItem', schema: 'core' }) @Entity({ name: 'billingSubscriptionItem', schema: 'core' })
@Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [
'billingSubscriptionId',
'stripeProductId',
])
@Unique('IndexOnBillingSubscriptionIdAndStripeSubscriptionItemIdUnique', [
'billingSubscriptionId',
'stripeSubscriptionItemId',
])
export class BillingSubscriptionItem { export class BillingSubscriptionItem {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@ -41,6 +50,9 @@ export class BillingSubscriptionItem {
@Column({ nullable: false }) @Column({ nullable: false })
stripePriceId: string; stripePriceId: string;
@Column({ nullable: false })
stripeSubscriptionItemId: string;
@Column({ nullable: false }) @Column({ nullable: false })
quantity: number; quantity: number;
} }

View File

@ -0,0 +1,59 @@
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()
export class UpdateSubscriptionJob
implements MessageQueueJob<UpdateSubscriptionJobData>
{
protected readonly logger = new Logger(UpdateSubscriptionJob.name);
constructor(
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);
if (workspaceMembersCount <= 0) {
return;
}
const billingSubscriptionItem =
await this.billingService.getBillingSubscriptionItem(data.workspaceId);
await this.stripeService.updateSubscriptionItem(
billingSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount,
);
this.logger.log(
`Updating workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members`,
);
}
}

View File

@ -0,0 +1,50 @@
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 {
UpdateSubscriptionJob,
UpdateSubscriptionJobData,
} from 'src/core/billing/jobs/update-subscription.job';
@Injectable()
export class BillingWorkspaceMemberListener {
constructor(
@Inject(MessageQueue.billingQueue)
private readonly messageQueueService: MessageQueueService,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {}
@OnEvent('workspaceMember.created')
@OnEvent('workspaceMember.deleted')
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

@ -1,12 +1,15 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert';
@Injectable() @Injectable()
export class StripeService { export class StripeService {
public readonly stripe: Stripe; protected readonly logger = new Logger(StripeService.name);
private readonly stripe: Stripe;
constructor(private readonly environmentService: EnvironmentService) { constructor(private readonly environmentService: EnvironmentService) {
this.stripe = new Stripe( this.stripe = new Stripe(
@ -25,4 +28,53 @@ export class StripeService {
webhookSecret, webhookSecret,
); );
} }
async getProductPrices(stripeProductId: string) {
return this.stripe.prices.search({
query: `product: '${stripeProductId}'`,
});
}
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
}
async cancelSubscription(stripeSubscriptionId: string) {
await this.stripe.subscriptions.cancel(stripeSubscriptionId);
}
async createCheckoutSession(
user: User,
priceId: string,
successUrl?: string,
cancelUrl?: string,
) {
const session = await this.stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId: user.defaultWorkspace.id,
},
trial_period_days:
this.environmentService.getBillingFreeTrialDurationInDays(),
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
customer_email: user.email,
success_url: successUrl,
cancel_url: cancelUrl,
});
assert(session.url, 'Error: missing checkout.session.url');
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
return session.url;
}
} }

View File

@ -42,7 +42,7 @@ export class UserWorkspace {
@UpdateDateColumn({ type: 'timestamp with time zone' }) @UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date; updatedAt: Date;
@Field() @Field({ nullable: true })
@Column('timestamp with time zone') @Column('timestamp with time zone', { nullable: true })
deletedAt: Date; deletedAt: Date;
} }

View File

@ -7,6 +7,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
@Module({ @Module({
imports: [ imports: [
@ -15,6 +16,7 @@ import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
NestjsQueryTypeOrmModule.forFeature([UserWorkspace], 'core'), NestjsQueryTypeOrmModule.forFeature([UserWorkspace], 'core'),
TypeORMModule, TypeORMModule,
DataSourceModule, DataSourceModule,
WorkspaceDataSourceModule,
], ],
services: [UserWorkspaceService], services: [UserWorkspaceService],
}), }),

View File

@ -1,4 +1,5 @@
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@ -7,6 +8,10 @@ import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service';
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 { assert } from 'src/utils/assert';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> { export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor( constructor(
@ -14,6 +19,8 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
private readonly userWorkspaceRepository: Repository<UserWorkspace>, private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly dataSourceService: DataSourceService, private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService, private readonly typeORMService: TypeORMService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private eventEmitter: EventEmitter2,
) { ) {
super(userWorkspaceRepository); super(userWorkspaceRepository);
} }
@ -43,6 +50,35 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
user.id user.id
}', '${user.email}', '${user.defaultAvatarUrl ?? ''}')`, }', '${user.email}', '${user.defaultAvatarUrl ?? ''}')`,
); );
const workspaceMember = await workspaceDataSource?.query(
`SELECT * FROM ${dataSourceMetadata.schema}."workspaceMember" WHERE "userId"='${user.id}'`,
);
assert(
workspaceMember.length === 1,
`Error while creating workspace member ${user.email} on workspace ${workspaceId}`,
);
const payload =
new ObjectRecordCreateEvent<WorkspaceMemberObjectMetadata>();
payload.workspaceId = workspaceId;
payload.createdRecord = new WorkspaceMemberObjectMetadata();
payload.createdRecord = workspaceMember[0];
this.eventEmitter.emit('workspaceMember.created', payload);
}
public async getWorkspaceMemberCount(workspaceId: string): Promise<number> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
return (
await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."workspaceMember"`,
[],
workspaceId,
)
).length;
} }
async findUserWorkspaces(userId: string): Promise<UserWorkspace[]> { async findUserWorkspaces(userId: string): Promise<UserWorkspace[]> {

View File

@ -82,24 +82,6 @@ export class UserService extends TypeOrmQueryService<User> {
); );
} }
async createWorkspaceMember(user: User) {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
user.defaultWorkspace.id,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
await workspaceDataSource?.query(
`INSERT INTO ${dataSourceMetadata.schema}."workspaceMember"
("nameFirstName", "nameLastName", "colorScheme", "userId", "userEmail", "avatarUrl")
VALUES ('${user.firstName}', '${user.lastName}', 'Light', '${
user.id
}', '${user.email}', '${user.defaultAvatarUrl ?? ''}')`,
);
}
async deleteUser(userId: string): Promise<User> { async deleteUser(userId: string): Promise<User> {
const user = await this.userRepository.findOneBy({ const user = await this.userRepository.findOneBy({
id: userId, id: userId,

View File

@ -3,7 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service'; import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service';
import { UserService } from 'src/core/user/services/user.service'; import { BillingService } from 'src/core/billing/billing.service';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { WorkspaceService } from './workspace.service'; import { WorkspaceService } from './workspace.service';
@ -23,7 +24,11 @@ describe('WorkspaceService', () => {
useValue: {}, useValue: {},
}, },
{ {
provide: UserService, provide: UserWorkspaceService,
useValue: {},
},
{
provide: BillingService,
useValue: {}, useValue: {},
}, },
], ],

View File

@ -9,15 +9,17 @@ import { Repository } from 'typeorm';
import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service'; import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { UserService } from 'src/core/user/services/user.service';
import { ActivateWorkspaceInput } from 'src/core/workspace/dtos/activate-workspace-input'; import { ActivateWorkspaceInput } from 'src/core/workspace/dtos/activate-workspace-input';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { BillingService } from 'src/core/billing/billing.service';
export class WorkspaceService extends TypeOrmQueryService<Workspace> { export class WorkspaceService extends TypeOrmQueryService<Workspace> {
constructor( constructor(
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly workspaceManagerService: WorkspaceManagerService, private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userService: UserService, private readonly userWorkspaceService: UserWorkspaceService,
private readonly billingService: BillingService,
) { ) {
super(workspaceRepository); super(workspaceRepository);
} }
@ -30,7 +32,10 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
displayName: data.displayName, displayName: data.displayName,
}); });
await this.workspaceManagerService.init(user.defaultWorkspace.id); await this.workspaceManagerService.init(user.defaultWorkspace.id);
await this.userService.createWorkspaceMember(user); await this.userWorkspaceService.createWorkspaceMember(
user.defaultWorkspace.id,
user,
);
return user.defaultWorkspace; return user.defaultWorkspace;
} }
@ -44,6 +49,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
assert(workspace, 'Workspace not found'); assert(workspace, 'Workspace not found');
await this.billingService.deleteSubscription(workspace.id);
await this.workspaceManagerService.delete(id); await this.workspaceManagerService.delete(id);
if (shouldDeleteCoreWorkspace) { if (shouldDeleteCoreWorkspace) {
await this.workspaceRepository.delete(id); await this.workspaceRepository.delete(id);

View File

@ -10,6 +10,7 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import Stripe from 'stripe';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
@ -63,7 +64,7 @@ export class Workspace {
@Field() @Field()
@Column({ default: 'incomplete' }) @Column({ default: 'incomplete' })
subscriptionStatus: 'incomplete' | 'active' | 'canceled'; subscriptionStatus: Stripe.Subscription.Status;
@Field() @Field()
activationStatus: 'active' | 'inactive'; activationStatus: 'active' | 'inactive';

View File

@ -8,7 +8,8 @@ import { WorkspaceManagerModule } from 'src/workspace/workspace-manager/workspac
import { WorkspaceResolver } from 'src/core/workspace/workspace.resolver'; import { WorkspaceResolver } from 'src/core/workspace/workspace.resolver';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
import { UserModule } from 'src/core/user/user.module'; import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module';
import { BillingModule } from 'src/core/billing/billing.module';
import { Workspace } from './workspace.entity'; import { Workspace } from './workspace.entity';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
@ -20,13 +21,14 @@ import { WorkspaceService } from './services/workspace.service';
TypeORMModule, TypeORMModule,
NestjsQueryGraphQLModule.forFeature({ NestjsQueryGraphQLModule.forFeature({
imports: [ imports: [
BillingModule,
FileModule,
NestjsQueryTypeOrmModule.forFeature( NestjsQueryTypeOrmModule.forFeature(
[Workspace, FeatureFlagEntity], [Workspace, FeatureFlagEntity],
'core', 'core',
), ),
UserWorkspaceModule,
WorkspaceManagerModule, WorkspaceManagerModule,
UserModule,
FileModule,
], ],
services: [WorkspaceService], services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts, resolvers: workspaceAutoResolverOpts,

View File

@ -1,20 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm"; import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBillingCoreTables1708535112230 implements MigrationInterface { export class AddBillingCoreTables1708535112230 implements MigrationInterface {
name = 'AddBillingCoreTables1708535112230' name = 'AddBillingCoreTables1708535112230';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "core"."billingSubscriptionItem" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "deletedAt" TIMESTAMP, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "billingSubscriptionId" uuid NOT NULL, "stripeProductId" character varying NOT NULL, "stripePriceId" character varying NOT NULL, "quantity" integer NOT NULL, CONSTRAINT "PK_0287b2d9fca488edcbf748281fc" PRIMARY KEY ("id"))`); await queryRunner.query(
await queryRunner.query(`CREATE TABLE "core"."billingSubscription" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "deletedAt" TIMESTAMP, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "workspaceId" uuid NOT NULL, "stripeCustomerId" character varying NOT NULL, "stripeSubscriptionId" character varying NOT NULL, "status" character varying NOT NULL, CONSTRAINT "UQ_9120b7586c3471463480b58d20a" UNIQUE ("stripeCustomerId"), CONSTRAINT "UQ_1a858c28c7766d429cbd25f05e8" UNIQUE ("stripeSubscriptionId"), CONSTRAINT "REL_4abfb70314c18da69e1bee1954" UNIQUE ("workspaceId"), CONSTRAINT "PK_6e9c72c32d91640b8087cb53666" PRIMARY KEY ("id"))`); `CREATE TABLE "core"."billingSubscriptionItem" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "deletedAt" TIMESTAMP, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "billingSubscriptionId" uuid NOT NULL, "stripeProductId" character varying NOT NULL, "stripePriceId" character varying NOT NULL, "quantity" integer NOT NULL, CONSTRAINT "PK_0287b2d9fca488edcbf748281fc" PRIMARY KEY ("id"))`,
await queryRunner.query(`ALTER TABLE "core"."billingSubscriptionItem" ADD CONSTRAINT "FK_a602e7c9da619b8290232f6eeab" FOREIGN KEY ("billingSubscriptionId") REFERENCES "core"."billingSubscription"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); );
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`); await queryRunner.query(
} `CREATE TABLE "core"."billingSubscription" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "deletedAt" TIMESTAMP, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "workspaceId" uuid NOT NULL, "stripeCustomerId" character varying NOT NULL, "stripeSubscriptionId" character varying NOT NULL, "status" character varying NOT NULL, CONSTRAINT "UQ_9120b7586c3471463480b58d20a" UNIQUE ("stripeCustomerId"), CONSTRAINT "UQ_1a858c28c7766d429cbd25f05e8" UNIQUE ("stripeSubscriptionId"), CONSTRAINT "REL_4abfb70314c18da69e1bee1954" UNIQUE ("workspaceId"), CONSTRAINT "PK_6e9c72c32d91640b8087cb53666" PRIMARY KEY ("id"))`,
);
public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(
await queryRunner.query(`ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_4abfb70314c18da69e1bee1954d"`); `ALTER TABLE "core"."billingSubscriptionItem" ADD CONSTRAINT "FK_a602e7c9da619b8290232f6eeab" FOREIGN KEY ("billingSubscriptionId") REFERENCES "core"."billingSubscription"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
await queryRunner.query(`ALTER TABLE "core"."billingSubscriptionItem" DROP CONSTRAINT "FK_a602e7c9da619b8290232f6eeab"`); );
await queryRunner.query(`DROP TABLE "core"."billingSubscription"`); await queryRunner.query(
await queryRunner.query(`DROP TABLE "core"."billingSubscriptionItem"`); `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"."billingSubscriptionItem" DROP CONSTRAINT "FK_a602e7c9da619b8290232f6eeab"`,
);
await queryRunner.query(`DROP TABLE "core"."billingSubscription"`);
await queryRunner.query(`DROP TABLE "core"."billingSubscriptionItem"`);
}
} }

View File

@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateBillingCoreTables1709233666080
implements MigrationInterface
{
name = 'UpdateBillingCoreTables1709233666080';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD "stripeSubscriptionItemId" character varying NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD CONSTRAINT "IndexOnBillingSubscriptionIdAndStripeSubscriptionItemIdUnique" UNIQUE ("billingSubscriptionId", "stripeSubscriptionItemId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD CONSTRAINT "IndexOnBillingSubscriptionIdAndStripeProductIdUnique" UNIQUE ("billingSubscriptionId", "stripeProductId")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP CONSTRAINT "IndexOnBillingSubscriptionIdAndStripeProductIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP CONSTRAINT "IndexOnBillingSubscriptionIdAndStripeSubscriptionItemIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP COLUMN "stripeSubscriptionItemId"`,
);
}
}

View File

@ -27,26 +27,33 @@ import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-dem
import { DataSeedDemoWorkspaceJob } from 'src/database/commands/data-seed-demo-workspace/jobs/data-seed-demo-workspace.job'; import { DataSeedDemoWorkspaceJob } from 'src/database/commands/data-seed-demo-workspace/jobs/data-seed-demo-workspace.job';
import { DeleteConnectedAccountAssociatedDataJob } from 'src/workspace/messaging/jobs/delete-connected-acount-associated-data.job'; import { DeleteConnectedAccountAssociatedDataJob } from 'src/workspace/messaging/jobs/delete-connected-acount-associated-data.job';
import { ThreadCleanerModule } from 'src/workspace/messaging/services/thread-cleaner/thread-cleaner.module'; import { ThreadCleanerModule } from 'src/workspace/messaging/services/thread-cleaner/thread-cleaner.module';
import { UpdateSubscriptionJob } from 'src/core/billing/jobs/update-subscription.job';
import { BillingModule } from 'src/core/billing/billing.module';
import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module';
import { StripeModule } from 'src/core/billing/stripe/stripe.module';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
@Module({ @Module({
imports: [ imports: [
WorkspaceDataSourceModule, BillingModule,
ObjectMetadataModule,
DataSourceModule, DataSourceModule,
HttpModule,
TypeORMModule,
MessagingModule,
UserModule,
EnvironmentModule,
TypeORMModule,
TypeOrmModule.forFeature([Workspace], 'core'),
ConnectedAccountModule, ConnectedAccountModule,
MessageParticipantModule,
CreateCompaniesAndContactsModule, CreateCompaniesAndContactsModule,
MessageChannelModule,
DataSeedDemoWorkspaceModule, DataSeedDemoWorkspaceModule,
EnvironmentModule,
HttpModule,
MessagingModule,
MessageParticipantModule,
MessageChannelModule,
ObjectMetadataModule,
StripeModule,
ThreadCleanerModule, ThreadCleanerModule,
TypeORMModule,
TypeOrmModule.forFeature([Workspace, FeatureFlagEntity], 'core'),
UserModule,
UserWorkspaceModule,
WorkspaceDataSourceModule,
], ],
providers: [ providers: [
{ {
@ -90,6 +97,7 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
provide: DeleteConnectedAccountAssociatedDataJob.name, provide: DeleteConnectedAccountAssociatedDataJob.name,
useClass: DeleteConnectedAccountAssociatedDataJob, useClass: DeleteConnectedAccountAssociatedDataJob,
}, },
{ provide: UpdateSubscriptionJob.name, useClass: UpdateSubscriptionJob },
], ],
}) })
export class JobsModule { export class JobsModule {

View File

@ -6,4 +6,5 @@ export enum MessageQueue {
webhookQueue = 'webhook-queue', webhookQueue = 'webhook-queue',
cronQueue = 'cron-queue', cronQueue = 'cron-queue',
emailQueue = 'email-queue', emailQueue = 'email-queue',
billingQueue = 'billing-queue',
} }

View File

@ -1,10 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/object-record-create.event'; import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/object-record-create.event';
import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event'; import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event';
import { objectRecordChangedProperties as objectRecordUpdateEventChangedProperties } from 'src/integrations/event-emitter/utils/object-record-changed-properties.util'; import { objectRecordChangedProperties as objectRecordUpdateEventChangedProperties } from 'src/integrations/event-emitter/utils/object-record-changed-properties.util';
@ -21,8 +17,6 @@ export class MessagingWorkspaceMemberListener {
constructor( constructor(
@Inject(MessageQueue.messagingQueue) @Inject(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService, private readonly messageQueueService: MessageQueueService,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {} ) {}
@OnEvent('workspaceMember.created') @OnEvent('workspaceMember.created')
@ -33,7 +27,7 @@ export class MessagingWorkspaceMemberListener {
return; return;
} }
this.messageQueueService.add<MatchMessageParticipantsJobData>( await this.messageQueueService.add<MatchMessageParticipantsJobData>(
MatchMessageParticipantJob.name, MatchMessageParticipantJob.name,
{ {
workspaceId: payload.workspaceId, workspaceId: payload.workspaceId,
@ -53,7 +47,7 @@ export class MessagingWorkspaceMemberListener {
payload.updatedRecord, payload.updatedRecord,
).includes('userEmail') ).includes('userEmail')
) { ) {
this.messageQueueService.add<MatchMessageParticipantsJobData>( await this.messageQueueService.add<MatchMessageParticipantsJobData>(
MatchMessageParticipantJob.name, MatchMessageParticipantJob.name,
{ {
workspaceId: payload.workspaceId, workspaceId: payload.workspaceId,