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:
martmull 2024-05-13 10:23:32 +02:00 committed by GitHub
parent 92acfe57a1
commit 1ac8abb118
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 157 additions and 101 deletions

View File

@ -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 {

View File

@ -21,7 +21,7 @@ export class UpdateSubscriptionJob
const workspaceMembersCount =
await this.userWorkspaceService.getWorkspaceMemberCount(data.workspaceId);
if (workspaceMembersCount <= 0) {
if (!workspaceMembersCount || workspaceMembersCount <= 0) {
return;
}

View File

@ -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(

View File

@ -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();

View File

@ -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,
},
);
}
}
}

View File

@ -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],

View File

@ -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,
);
}
}

View File

@ -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,
},
);
}
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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);
}
}
}