diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts
index ec243bb326..1b93836b03 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts
@@ -17,7 +17,7 @@ export const useNavigateAfterSignInUp = () => {
) => {
if (
billing?.isBillingEnabled &&
- currentWorkspace.subscriptionStatus !== 'active'
+ !['active', 'trialing'].includes(currentWorkspace.subscriptionStatus)
) {
navigate(AppPath.PlanRequired);
return;
diff --git a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx
index 321e305aa7..f5a8afdca2 100644
--- a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx
+++ b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx
@@ -8,6 +8,7 @@ import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit.ts
import { SubscriptionCard } from '@/billing/components/SubscriptionCard.tsx';
import { billingState } from '@/client-config/states/billingState.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 { MainButton } from '@/ui/input/button/components/MainButton.tsx';
import { CardPicker } from '@/ui/input/components/CardPicker.tsx';
@@ -44,6 +45,8 @@ export const ChooseYourPlan = () => {
const [planSelected, setPlanSelected] = useState('month');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
const { enqueueSnackBar } = useSnackBar();
const { data: prices } = useGetProductPricesQuery({
@@ -77,12 +80,14 @@ export const ChooseYourPlan = () => {
};
const handleButtonClick = async () => {
+ setIsSubmitting(true);
const { data } = await checkout({
variables: {
recurringInterval: planSelected,
successUrlPath: AppPath.PlanRequiredSuccess,
},
});
+ setIsSubmitting(false);
if (!data?.checkout.url) {
enqueueSnackBar(
'Checkout session error. Please retry or contact Twenty team',
@@ -126,7 +131,13 @@ export const ChooseYourPlan = () => {
Frequent updates
And much more
-
+ isSubmitting && }
+ disabled={isSubmitting}
+ />
>
)
);
diff --git a/packages/twenty-server/src/core/billing/billing.controller.ts b/packages/twenty-server/src/core/billing/billing.controller.ts
index c986deee13..e49533b846 100644
--- a/packages/twenty-server/src/core/billing/billing.controller.ts
+++ b/packages/twenty-server/src/core/billing/billing.controller.ts
@@ -29,7 +29,7 @@ export class BillingController {
@Res() res: Response,
) {
if (!req.rawBody) {
- res.status(400).send('Missing raw body');
+ res.status(400).end();
return;
}
@@ -38,27 +38,23 @@ export class BillingController {
req.rawBody,
);
- if (event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED) {
- if (event.data.object.status !== 'active') {
- res.status(402).send('Payment did not succeeded');
-
- return;
- }
-
+ if (
+ event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED ||
+ event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED
+ ) {
const workspaceId = event.data.object.metadata?.workspaceId;
if (!workspaceId) {
- res.status(404).send('Missing workspaceId in webhook event metadata');
+ res.status(404).end();
return;
}
- await this.billingService.createBillingSubscription(
+ await this.billingService.upsertBillingSubscription(
workspaceId,
event.data,
);
-
- res.status(200).send('Subscription successfully updated');
}
+ res.status(200).end();
}
}
diff --git a/packages/twenty-server/src/core/billing/billing.module.ts b/packages/twenty-server/src/core/billing/billing.module.ts
index 550f9252c3..25e63d966e 100644
--- a/packages/twenty-server/src/core/billing/billing.module.ts
+++ b/packages/twenty-server/src/core/billing/billing.module.ts
@@ -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 { 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';
@Module({
imports: [
StripeModule,
TypeOrmModule.forFeature(
- [BillingSubscription, BillingSubscriptionItem, Workspace],
+ [
+ BillingSubscription,
+ BillingSubscriptionItem,
+ Workspace,
+ FeatureFlagEntity,
+ ],
'core',
),
],
controllers: [BillingController],
- providers: [BillingService, BillingResolver],
+ providers: [BillingService, BillingResolver, BillingWorkspaceMemberListener],
+ exports: [BillingService],
})
export class BillingModule {}
diff --git a/packages/twenty-server/src/core/billing/billing.service.ts b/packages/twenty-server/src/core/billing/billing.service.ts
index 46e643dad1..e888be1b1f 100644
--- a/packages/twenty-server/src/core/billing/billing.service.ts
+++ b/packages/twenty-server/src/core/billing/billing.service.ts
@@ -11,13 +11,13 @@ import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subsc
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';
export enum AvailableProduct {
BasePlan = 'base-plan',
}
export enum WebhookEvent {
+ CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
}
@@ -42,9 +42,8 @@ export class BillingService {
}
async getProductPrices(stripeProductId: string) {
- const productPrices = await this.stripeService.stripe.prices.search({
- query: `product: '${stripeProductId}'`,
- });
+ const productPrices =
+ await this.stripeService.getProductPrices(stripeProductId);
return this.formatProductPrices(productPrices.data);
}
@@ -74,63 +73,101 @@ export class BillingService {
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
}
- async checkout(user: User, priceId: string, successUrlPath?: string) {
- const frontBaseUrl = this.environmentService.getFrontBaseUrl();
- const session = await this.stripeService.stripe.checkout.sessions.create({
- line_items: [
- {
- 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,
+ async getBillingSubscription(workspaceId: string) {
+ return await this.billingSubscriptionRepository.findOneOrFail({
+ where: { workspaceId },
+ relations: ['billingSubscriptionItems'],
});
-
- 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,
- data: Stripe.CustomerSubscriptionUpdatedEvent.Data,
+ stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
) {
- const billingSubscription = this.billingSubscriptionRepository.create({
- workspaceId: workspaceId,
- stripeCustomerId: data.object.customer as string,
- stripeSubscriptionId: data.object.id,
- status: data.object.status,
+ const billingSubscription = await this.getBillingSubscription(workspaceId);
+
+ const billingSubscriptionItem =
+ billingSubscription.billingSubscriptionItems.filter(
+ (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) {
- const billingSubscriptionItem =
- this.billingSubscriptionItemRepository.create({
+ await this.billingSubscriptionItemRepository.upsert(
+ data.object.items.data.map((item) => {
+ return {
billingSubscriptionId: billingSubscription.id,
stripeProductId: item.price.product as string,
stripePriceId: item.price.id,
+ stripeSubscriptionItemId: item.id,
quantity: item.quantity,
- });
-
- await this.billingSubscriptionItemRepository.save(
- billingSubscriptionItem,
- );
- }
- await this.workspaceRepository.update(workspaceId, {
- subscriptionStatus: 'active',
- });
+ };
+ }),
+ {
+ conflictPaths: ['stripeSubscriptionItemId', 'billingSubscriptionId'],
+ skipUpdateIfNoValuesChanged: true,
+ },
+ );
}
}
diff --git a/packages/twenty-server/src/core/billing/entities/billing-subscription-item.entity.ts b/packages/twenty-server/src/core/billing/entities/billing-subscription-item.entity.ts
index b2ca09ce80..4d30e50a43 100644
--- a/packages/twenty-server/src/core/billing/entities/billing-subscription-item.entity.ts
+++ b/packages/twenty-server/src/core/billing/entities/billing-subscription-item.entity.ts
@@ -4,12 +4,21 @@ import {
Entity,
ManyToOne,
PrimaryGeneratedColumn,
+ Unique,
UpdateDateColumn,
} from 'typeorm';
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
@Entity({ name: 'billingSubscriptionItem', schema: 'core' })
+@Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [
+ 'billingSubscriptionId',
+ 'stripeProductId',
+])
+@Unique('IndexOnBillingSubscriptionIdAndStripeSubscriptionItemIdUnique', [
+ 'billingSubscriptionId',
+ 'stripeSubscriptionItemId',
+])
export class BillingSubscriptionItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -41,6 +50,9 @@ export class BillingSubscriptionItem {
@Column({ nullable: false })
stripePriceId: string;
+ @Column({ nullable: false })
+ stripeSubscriptionItemId: string;
+
@Column({ nullable: false })
quantity: number;
}
diff --git a/packages/twenty-server/src/core/billing/jobs/update-subscription.job.ts b/packages/twenty-server/src/core/billing/jobs/update-subscription.job.ts
new file mode 100644
index 0000000000..876944a97b
--- /dev/null
+++ b/packages/twenty-server/src/core/billing/jobs/update-subscription.job.ts
@@ -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
+{
+ 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,
+ ) {}
+
+ async handle(data: UpdateSubscriptionJobData): Promise {
+ 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`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/core/billing/listeners/billing-workspace-member.listener.ts b/packages/twenty-server/src/core/billing/listeners/billing-workspace-member.listener.ts
new file mode 100644
index 0000000000..1cb1db606b
--- /dev/null
+++ b/packages/twenty-server/src/core/billing/listeners/billing-workspace-member.listener.ts
@@ -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,
+ ) {}
+
+ @OnEvent('workspaceMember.created')
+ @OnEvent('workspaceMember.deleted')
+ async handleCreateOrDeleteEvent(
+ payload: ObjectRecordCreateEvent,
+ ) {
+ const isSelfBillingFeatureFlag = await this.featureFlagRepository.findOneBy(
+ {
+ key: FeatureFlagKeys.IsSelfBillingEnabled,
+ value: true,
+ workspaceId: payload.workspaceId,
+ },
+ );
+
+ if (!isSelfBillingFeatureFlag) {
+ return;
+ }
+ await this.messageQueueService.add(
+ UpdateSubscriptionJob.name,
+ { workspaceId: payload.workspaceId },
+ );
+ }
+}
diff --git a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
index 38bb9b23a5..74835770f2 100644
--- a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
+++ b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
@@ -1,12 +1,15 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
+import { User } from 'src/core/user/user.entity';
+import { assert } from 'src/utils/assert';
@Injectable()
export class StripeService {
- public readonly stripe: Stripe;
+ protected readonly logger = new Logger(StripeService.name);
+ private readonly stripe: Stripe;
constructor(private readonly environmentService: EnvironmentService) {
this.stripe = new Stripe(
@@ -25,4 +28,53 @@ export class StripeService {
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;
+ }
}
diff --git a/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts b/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts
index adb1e0e065..b6e4bf08b3 100644
--- a/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts
+++ b/packages/twenty-server/src/core/user-workspace/user-workspace.entity.ts
@@ -42,7 +42,7 @@ export class UserWorkspace {
@UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date;
- @Field()
- @Column('timestamp with time zone')
+ @Field({ nullable: true })
+ @Column('timestamp with time zone', { nullable: true })
deletedAt: Date;
}
diff --git a/packages/twenty-server/src/core/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/core/user-workspace/user-workspace.module.ts
index 6ce5ba8915..836e83c517 100644
--- a/packages/twenty-server/src/core/user-workspace/user-workspace.module.ts
+++ b/packages/twenty-server/src/core/user-workspace/user-workspace.module.ts
@@ -7,6 +7,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
+import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
NestjsQueryTypeOrmModule.forFeature([UserWorkspace], 'core'),
TypeORMModule,
DataSourceModule,
+ WorkspaceDataSourceModule,
],
services: [UserWorkspaceService],
}),
diff --git a/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts
index bb4d7a83c2..5f87165740 100644
--- a/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts
+++ b/packages/twenty-server/src/core/user-workspace/user-workspace.service.ts
@@ -1,4 +1,5 @@
import { InjectRepository } from '@nestjs/typeorm';
+import { EventEmitter2 } from '@nestjs/event-emitter';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-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 { DataSourceService } from 'src/metadata/data-source/data-source.service';
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 {
constructor(
@@ -14,6 +19,8 @@ export class UserWorkspaceService extends TypeOrmQueryService {
private readonly userWorkspaceRepository: Repository,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
+ private readonly workspaceDataSourceService: WorkspaceDataSourceService,
+ private eventEmitter: EventEmitter2,
) {
super(userWorkspaceRepository);
}
@@ -43,6 +50,35 @@ export class UserWorkspaceService extends TypeOrmQueryService {
user.id
}', '${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();
+
+ payload.workspaceId = workspaceId;
+ payload.createdRecord = new WorkspaceMemberObjectMetadata();
+ payload.createdRecord = workspaceMember[0];
+
+ this.eventEmitter.emit('workspaceMember.created', payload);
+ }
+
+ public async getWorkspaceMemberCount(workspaceId: string): Promise {
+ const dataSourceSchema =
+ this.workspaceDataSourceService.getSchemaName(workspaceId);
+
+ return (
+ await this.workspaceDataSourceService.executeRawQuery(
+ `SELECT * FROM ${dataSourceSchema}."workspaceMember"`,
+ [],
+ workspaceId,
+ )
+ ).length;
}
async findUserWorkspaces(userId: string): Promise {
diff --git a/packages/twenty-server/src/core/user/services/user.service.ts b/packages/twenty-server/src/core/user/services/user.service.ts
index 8cb85e93b1..3ffbf816ab 100644
--- a/packages/twenty-server/src/core/user/services/user.service.ts
+++ b/packages/twenty-server/src/core/user/services/user.service.ts
@@ -82,24 +82,6 @@ export class UserService extends TypeOrmQueryService {
);
}
- 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 {
const user = await this.userRepository.findOneBy({
id: userId,
diff --git a/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts
index 46350109df..35894c9fef 100644
--- a/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts
+++ b/packages/twenty-server/src/core/workspace/services/workspace.service.spec.ts
@@ -3,7 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Workspace } from 'src/core/workspace/workspace.entity';
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';
@@ -23,7 +24,11 @@ describe('WorkspaceService', () => {
useValue: {},
},
{
- provide: UserService,
+ provide: UserWorkspaceService,
+ useValue: {},
+ },
+ {
+ provide: BillingService,
useValue: {},
},
],
diff --git a/packages/twenty-server/src/core/workspace/services/workspace.service.ts b/packages/twenty-server/src/core/workspace/services/workspace.service.ts
index 18166c4cc5..ea5bd61636 100644
--- a/packages/twenty-server/src/core/workspace/services/workspace.service.ts
+++ b/packages/twenty-server/src/core/workspace/services/workspace.service.ts
@@ -9,15 +9,17 @@ import { Repository } from 'typeorm';
import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service';
import { Workspace } from 'src/core/workspace/workspace.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 { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
+import { BillingService } from 'src/core/billing/billing.service';
export class WorkspaceService extends TypeOrmQueryService {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository,
private readonly workspaceManagerService: WorkspaceManagerService,
- private readonly userService: UserService,
+ private readonly userWorkspaceService: UserWorkspaceService,
+ private readonly billingService: BillingService,
) {
super(workspaceRepository);
}
@@ -30,7 +32,10 @@ export class WorkspaceService extends TypeOrmQueryService {
displayName: data.displayName,
});
await this.workspaceManagerService.init(user.defaultWorkspace.id);
- await this.userService.createWorkspaceMember(user);
+ await this.userWorkspaceService.createWorkspaceMember(
+ user.defaultWorkspace.id,
+ user,
+ );
return user.defaultWorkspace;
}
@@ -44,6 +49,8 @@ export class WorkspaceService extends TypeOrmQueryService {
assert(workspace, 'Workspace not found');
+ await this.billingService.deleteSubscription(workspace.id);
+
await this.workspaceManagerService.delete(id);
if (shouldDeleteCoreWorkspace) {
await this.workspaceRepository.delete(id);
diff --git a/packages/twenty-server/src/core/workspace/workspace.entity.ts b/packages/twenty-server/src/core/workspace/workspace.entity.ts
index 2ea41f0139..d3efe36d30 100644
--- a/packages/twenty-server/src/core/workspace/workspace.entity.ts
+++ b/packages/twenty-server/src/core/workspace/workspace.entity.ts
@@ -10,6 +10,7 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
+import Stripe from 'stripe';
import { User } from 'src/core/user/user.entity';
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
@@ -63,7 +64,7 @@ export class Workspace {
@Field()
@Column({ default: 'incomplete' })
- subscriptionStatus: 'incomplete' | 'active' | 'canceled';
+ subscriptionStatus: Stripe.Subscription.Status;
@Field()
activationStatus: 'active' | 'inactive';
diff --git a/packages/twenty-server/src/core/workspace/workspace.module.ts b/packages/twenty-server/src/core/workspace/workspace.module.ts
index 049f773c7b..6b2759f35e 100644
--- a/packages/twenty-server/src/core/workspace/workspace.module.ts
+++ b/packages/twenty-server/src/core/workspace/workspace.module.ts
@@ -8,7 +8,8 @@ import { WorkspaceManagerModule } from 'src/workspace/workspace-manager/workspac
import { WorkspaceResolver } from 'src/core/workspace/workspace.resolver';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
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 { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
@@ -20,13 +21,14 @@ import { WorkspaceService } from './services/workspace.service';
TypeORMModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
+ BillingModule,
+ FileModule,
NestjsQueryTypeOrmModule.forFeature(
[Workspace, FeatureFlagEntity],
'core',
),
+ UserWorkspaceModule,
WorkspaceManagerModule,
- UserModule,
- FileModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,
diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1708535112230-addBillingCoreTables.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1708535112230-addBillingCoreTables.ts
index c35dd97682..d9f0c74af7 100644
--- a/packages/twenty-server/src/database/typeorm/core/migrations/1708535112230-addBillingCoreTables.ts
+++ b/packages/twenty-server/src/database/typeorm/core/migrations/1708535112230-addBillingCoreTables.ts
@@ -1,20 +1,31 @@
-import { MigrationInterface, QueryRunner } from "typeorm";
+import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBillingCoreTables1708535112230 implements MigrationInterface {
- name = 'AddBillingCoreTables1708535112230'
+ name = 'AddBillingCoreTables1708535112230';
- public async up(queryRunner: QueryRunner): Promise {
- 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(`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"))`);
- 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`);
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- 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"`);
- }
+ public async up(queryRunner: QueryRunner): Promise {
+ 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(
+ `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"))`,
+ );
+ 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`,
+ );
+ }
+ public async down(queryRunner: QueryRunner): Promise {
+ 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"`);
+ }
}
diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1709233666080-updateBillingCoreTables.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1709233666080-updateBillingCoreTables.ts
new file mode 100644
index 0000000000..fea0f40c44
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/core/migrations/1709233666080-updateBillingCoreTables.ts
@@ -0,0 +1,31 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class UpdateBillingCoreTables1709233666080
+ implements MigrationInterface
+{
+ name = 'UpdateBillingCoreTables1709233666080';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ 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 {
+ 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"`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/integrations/message-queue/jobs.module.ts b/packages/twenty-server/src/integrations/message-queue/jobs.module.ts
index c59b21eaf5..d38b47fab4 100644
--- a/packages/twenty-server/src/integrations/message-queue/jobs.module.ts
+++ b/packages/twenty-server/src/integrations/message-queue/jobs.module.ts
@@ -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 { 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 { 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 { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
@Module({
imports: [
- WorkspaceDataSourceModule,
- ObjectMetadataModule,
+ BillingModule,
DataSourceModule,
- HttpModule,
- TypeORMModule,
- MessagingModule,
- UserModule,
- EnvironmentModule,
- TypeORMModule,
- TypeOrmModule.forFeature([Workspace], 'core'),
ConnectedAccountModule,
- MessageParticipantModule,
CreateCompaniesAndContactsModule,
- MessageChannelModule,
DataSeedDemoWorkspaceModule,
+ EnvironmentModule,
+ HttpModule,
+ MessagingModule,
+ MessageParticipantModule,
+ MessageChannelModule,
+ ObjectMetadataModule,
+ StripeModule,
ThreadCleanerModule,
+ TypeORMModule,
+ TypeOrmModule.forFeature([Workspace, FeatureFlagEntity], 'core'),
+ UserModule,
+ UserWorkspaceModule,
+ WorkspaceDataSourceModule,
],
providers: [
{
@@ -90,6 +97,7 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
provide: DeleteConnectedAccountAssociatedDataJob.name,
useClass: DeleteConnectedAccountAssociatedDataJob,
},
+ { provide: UpdateSubscriptionJob.name, useClass: UpdateSubscriptionJob },
],
})
export class JobsModule {
diff --git a/packages/twenty-server/src/integrations/message-queue/message-queue.constants.ts b/packages/twenty-server/src/integrations/message-queue/message-queue.constants.ts
index 228311c507..56af4ba6f4 100644
--- a/packages/twenty-server/src/integrations/message-queue/message-queue.constants.ts
+++ b/packages/twenty-server/src/integrations/message-queue/message-queue.constants.ts
@@ -6,4 +6,5 @@ export enum MessageQueue {
webhookQueue = 'webhook-queue',
cronQueue = 'cron-queue',
emailQueue = 'email-queue',
+ billingQueue = 'billing-queue',
}
diff --git a/packages/twenty-server/src/workspace/messaging/listeners/messaging-workspace-member.listener.ts b/packages/twenty-server/src/workspace/messaging/listeners/messaging-workspace-member.listener.ts
index 78cfbc83ce..726ab378a5 100644
--- a/packages/twenty-server/src/workspace/messaging/listeners/messaging-workspace-member.listener.ts
+++ b/packages/twenty-server/src/workspace/messaging/listeners/messaging-workspace-member.listener.ts
@@ -1,10 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
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 { 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';
@@ -21,8 +17,6 @@ export class MessagingWorkspaceMemberListener {
constructor(
@Inject(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
- @InjectRepository(FeatureFlagEntity, 'core')
- private readonly featureFlagRepository: Repository,
) {}
@OnEvent('workspaceMember.created')
@@ -33,7 +27,7 @@ export class MessagingWorkspaceMemberListener {
return;
}
- this.messageQueueService.add(
+ await this.messageQueueService.add(
MatchMessageParticipantJob.name,
{
workspaceId: payload.workspaceId,
@@ -53,7 +47,7 @@ export class MessagingWorkspaceMemberListener {
payload.updatedRecord,
).includes('userEmail')
) {
- this.messageQueueService.add(
+ await this.messageQueueService.add(
MatchMessageParticipantJob.name,
{
workspaceId: payload.workspaceId,