mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-25 13:02:15 +03:00
5188 bug some canceled subscriptions are billed (#5254)
When user is deleting its account on a specific workspace, we remove it as if it was a workspaceMember, and if no workspaceMember remains, we delete the workspace and the associated stripe subscription
This commit is contained in:
parent
92acfe57a1
commit
1ac8abb118
@ -196,7 +196,10 @@ export class BillingService {
|
||||
? frontBaseUrl + successUrlPath
|
||||
: frontBaseUrl;
|
||||
|
||||
let quantity = 1;
|
||||
const quantity =
|
||||
(await this.userWorkspaceService.getWorkspaceMemberCount(
|
||||
user.defaultWorkspaceId,
|
||||
)) || 1;
|
||||
|
||||
const stripeCustomerId = (
|
||||
await this.billingSubscriptionRepository.findOneBy({
|
||||
@ -204,16 +207,6 @@ export class BillingService {
|
||||
})
|
||||
)?.stripeCustomerId;
|
||||
|
||||
try {
|
||||
quantity = await this.userWorkspaceService.getWorkspaceMemberCount(
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to get workspace member count for workspace ${user.defaultWorkspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const session = await this.stripeService.createCheckoutSession(
|
||||
user,
|
||||
priceId,
|
||||
@ -260,6 +253,26 @@ export class BillingService {
|
||||
| Stripe.CustomerSubscriptionCreatedEvent.Data
|
||||
| Stripe.CustomerSubscriptionDeletedEvent.Data,
|
||||
) {
|
||||
const workspace = this.workspaceRepository.find({
|
||||
where: { id: workspaceId },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.workspaceRepository.update(workspaceId, {
|
||||
subscriptionStatus: data.object.status,
|
||||
});
|
||||
|
||||
const billingSubscription = await this.getCurrentBillingSubscription({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!billingSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.billingSubscriptionRepository.upsert(
|
||||
{
|
||||
workspaceId: workspaceId,
|
||||
@ -274,18 +287,6 @@ export class BillingService {
|
||||
},
|
||||
);
|
||||
|
||||
await this.workspaceRepository.update(workspaceId, {
|
||||
subscriptionStatus: data.object.status,
|
||||
});
|
||||
|
||||
const billingSubscription = await this.getCurrentBillingSubscription({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!billingSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.billingSubscriptionItemRepository.upsert(
|
||||
data.object.items.data.map((item) => {
|
||||
return {
|
||||
|
@ -21,7 +21,7 @@ export class UpdateSubscriptionJob
|
||||
const workspaceMembersCount =
|
||||
await this.userWorkspaceService.getWorkspaceMemberCount(data.workspaceId);
|
||||
|
||||
if (workspaceMembersCount <= 0) {
|
||||
if (!workspaceMembersCount || workspaceMembersCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -70,17 +70,23 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
this.eventEmitter.emit('workspaceMember.created', payload);
|
||||
}
|
||||
|
||||
public async getWorkspaceMemberCount(workspaceId: string): Promise<number> {
|
||||
const dataSourceSchema =
|
||||
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
||||
public async getWorkspaceMemberCount(
|
||||
workspaceId: string,
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
const dataSourceSchema =
|
||||
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
||||
|
||||
return (
|
||||
await this.workspaceDataSourceService.executeRawQuery(
|
||||
`SELECT * FROM ${dataSourceSchema}."workspaceMember"`,
|
||||
[],
|
||||
workspaceId,
|
||||
)
|
||||
).length;
|
||||
return (
|
||||
await this.workspaceDataSourceService.executeRawQuery(
|
||||
`SELECT * FROM ${dataSourceSchema}."workspaceMember"`,
|
||||
[],
|
||||
workspaceId,
|
||||
)
|
||||
).length;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async checkUserWorkspaceExists(
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -31,6 +33,14 @@ describe('UserService', () => {
|
||||
provide: TypeORMService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EventEmitter2,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
@ -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';
|
||||
@ -8,17 +9,20 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
|
||||
import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata';
|
||||
import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event';
|
||||
import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
|
||||
export class UserService extends TypeOrmQueryService<User> {
|
||||
constructor(
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
) {
|
||||
super(userRepository);
|
||||
}
|
||||
@ -95,64 +99,46 @@ export class UserService extends TypeOrmQueryService<User> {
|
||||
|
||||
assert(user, 'User not found');
|
||||
|
||||
const workspaceId = user.defaultWorkspaceId;
|
||||
|
||||
const dataSourceMetadata =
|
||||
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||
user.defaultWorkspace.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.typeORMService.connectToDataSource(dataSourceMetadata);
|
||||
|
||||
const workspaceMembers = await workspaceDataSource?.query(
|
||||
`SELECT * FROM ${dataSourceMetadata.schema}."workspaceMember"`,
|
||||
);
|
||||
const workspaceMember = workspaceMembers.filter(
|
||||
(member: ObjectRecord<WorkspaceMemberObjectMetadata>) =>
|
||||
member.userId === userId,
|
||||
)?.[0];
|
||||
|
||||
assert(workspaceMember, 'WorkspaceMember not found');
|
||||
|
||||
if (workspaceMembers.length === 1) {
|
||||
await this.workspaceService.deleteWorkspace(workspaceId);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
await workspaceDataSource?.query(
|
||||
`DELETE FROM ${dataSourceMetadata.schema}."workspaceMember" WHERE "userId" = '${userId}'`,
|
||||
);
|
||||
const payload =
|
||||
new ObjectRecordDeleteEvent<WorkspaceMemberObjectMetadata>();
|
||||
|
||||
await this.userWorkspaceRepository.delete({ userId });
|
||||
payload.workspaceId = workspaceId;
|
||||
payload.properties = {
|
||||
before: workspaceMember,
|
||||
};
|
||||
payload.recordId = workspaceMember.id;
|
||||
|
||||
await this.userRepository.delete(user.id);
|
||||
this.eventEmitter.emit('workspaceMember.deleted', payload);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async handleRemoveWorkspaceMember(workspaceId: string, userId: string) {
|
||||
await this.userWorkspaceRepository.delete({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
|
||||
}
|
||||
|
||||
private async reassignOrRemoveUserDefaultWorkspace(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { userId: userId },
|
||||
});
|
||||
|
||||
if (userWorkspaces.length === 0) {
|
||||
await this.userRepository.delete({ id: userId });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${userId} not found in workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
if (user.defaultWorkspaceId === workspaceId) {
|
||||
await this.userRepository.update(
|
||||
{ id: userId },
|
||||
{
|
||||
defaultWorkspaceId: userWorkspaces[0].workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,9 +9,8 @@ import { UserResolver } from 'src/engine/core-modules/user/user.resolver';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
|
||||
import { userAutoResolverOpts } from './user.auto-resolver-opts';
|
||||
|
||||
@ -21,14 +20,14 @@ import { UserService } from './services/user.service';
|
||||
imports: [
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([User, UserWorkspace], 'core'),
|
||||
NestjsQueryTypeOrmModule.forFeature([User], 'core'),
|
||||
TypeORMModule,
|
||||
],
|
||||
resolvers: userAutoResolverOpts,
|
||||
}),
|
||||
DataSourceModule,
|
||||
FileUploadModule,
|
||||
UserWorkspaceModule,
|
||||
WorkspaceModule,
|
||||
],
|
||||
exports: [UserService],
|
||||
providers: [UserService, UserResolver, TypeORMService],
|
||||
|
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface';
|
||||
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
|
||||
export type HandleWorkspaceMemberDeletedJobData = {
|
||||
workspaceId: string;
|
||||
@ -12,11 +12,14 @@ export type HandleWorkspaceMemberDeletedJobData = {
|
||||
export class HandleWorkspaceMemberDeletedJob
|
||||
implements MessageQueueJob<HandleWorkspaceMemberDeletedJobData>
|
||||
{
|
||||
constructor(private readonly userService: UserService) {}
|
||||
constructor(private readonly workspaceService: WorkspaceService) {}
|
||||
|
||||
async handle(data: HandleWorkspaceMemberDeletedJobData): Promise<void> {
|
||||
const { workspaceId, userId } = data;
|
||||
|
||||
await this.userService.handleRemoveWorkspaceMember(workspaceId, userId);
|
||||
await this.workspaceService.handleRemoveWorkspaceMember(
|
||||
workspaceId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -13,18 +13,18 @@ import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/a
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly workspaceManagerService: WorkspaceManagerService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super(workspaceRepository);
|
||||
}
|
||||
@ -49,7 +49,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
return await this.workspaceManagerService.doesDataSourceExist(id);
|
||||
}
|
||||
|
||||
async solfDeleteWorkspace(id: string) {
|
||||
async softDeleteWorkspace(id: string) {
|
||||
const workspace = await this.workspaceRepository.findOneBy({ id });
|
||||
|
||||
assert(workspace, 'Workspace not found');
|
||||
@ -67,14 +67,12 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
workspaceId: id,
|
||||
});
|
||||
|
||||
const workspace = await this.solfDeleteWorkspace(id);
|
||||
const workspace = await this.softDeleteWorkspace(id);
|
||||
|
||||
for (const userWorkspace of userWorkspaces) {
|
||||
await this.userService.handleRemoveWorkspaceMember(
|
||||
id,
|
||||
userWorkspace.userId,
|
||||
);
|
||||
await this.handleRemoveWorkspaceMember(id, userWorkspace.userId);
|
||||
}
|
||||
|
||||
await this.workspaceRepository.delete(id);
|
||||
|
||||
return workspace;
|
||||
@ -85,4 +83,46 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
.find()
|
||||
.then((workspaces) => workspaces.map((workspace) => workspace.id));
|
||||
}
|
||||
|
||||
async handleRemoveWorkspaceMember(workspaceId: string, userId: string) {
|
||||
await this.userWorkspaceRepository.delete({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
|
||||
}
|
||||
|
||||
private async reassignOrRemoveUserDefaultWorkspace(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { userId: userId },
|
||||
});
|
||||
|
||||
if (userWorkspaces.length === 0) {
|
||||
await this.userRepository.delete({ id: userId });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${userId} not found in workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
if (user.defaultWorkspaceId === workspaceId) {
|
||||
await this.userRepository.update(
|
||||
{ id: userId },
|
||||
{
|
||||
defaultWorkspaceId: userWorkspaces[0].workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,9 +12,9 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener';
|
||||
import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
|
||||
import { Workspace } from './workspace.entity';
|
||||
@ -30,14 +30,13 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
FileUploadModule,
|
||||
WorkspaceCacheVersionModule,
|
||||
NestjsQueryTypeOrmModule.forFeature(
|
||||
[Workspace, UserWorkspace, FeatureFlagEntity],
|
||||
[User, Workspace, UserWorkspace, FeatureFlagEntity],
|
||||
'core',
|
||||
),
|
||||
UserWorkspaceModule,
|
||||
WorkspaceManagerModule,
|
||||
DataSourceModule,
|
||||
TypeORMModule,
|
||||
UserModule,
|
||||
],
|
||||
services: [WorkspaceService],
|
||||
resolvers: workspaceAutoResolverOpts,
|
||||
|
@ -16,6 +16,12 @@ import { EmailModule } from 'src/engine/integrations/email/email.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
|
||||
import { CalendarEventParticipantModule } from 'src/modules/calendar/services/calendar-event-participant/calendar-event-participant.module';
|
||||
import { GmailFetchMessageContentFromCacheModule } from 'src/modules/messaging/services/gmail-fetch-message-content-from-cache/gmail-fetch-message-content-from-cache.module';
|
||||
import { GmailFullSyncModule } from 'src/modules/messaging/services/gmail-full-sync/gmail-full-sync.module';
|
||||
import { GmailPartialSyncModule } from 'src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.module';
|
||||
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { CalendarMessagingParticipantJobModule } from 'src/modules/calendar-messaging-participant/jobs/calendar-messaging-participant-job.module';
|
||||
import { CalendarCronJobModule } from 'src/modules/calendar/crons/jobs/calendar-cron-job.module';
|
||||
import { CalendarJobModule } from 'src/modules/calendar/jobs/calendar-job.module';
|
||||
@ -34,6 +40,12 @@ import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module
|
||||
DataSeedDemoWorkspaceModule,
|
||||
BillingModule,
|
||||
UserWorkspaceModule,
|
||||
WorkspaceModule,
|
||||
GmailFullSyncModule,
|
||||
GmailFetchMessageContentFromCacheModule,
|
||||
GmailPartialSyncModule,
|
||||
CalendarEventParticipantModule,
|
||||
TimelineActivityModule,
|
||||
StripeModule,
|
||||
// JobsModules
|
||||
WorkspaceQueryRunnerJobModule,
|
||||
|
@ -83,7 +83,7 @@ export class DeleteIncompleteWorkspacesCommand extends CommandRunner {
|
||||
} name: '${incompleteWorkspace.displayName}'`,
|
||||
);
|
||||
if (!options.dryRun) {
|
||||
await this.workspaceService.solfDeleteWorkspace(incompleteWorkspace.id);
|
||||
await this.workspaceService.softDeleteWorkspace(incompleteWorkspace.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user