fix(billing) - fix orphaned stripe subs (#20814)

Fix sentry issues
https://twenty-v7.sentry.io/issues/7203797925/?environment=prod&project=4507072499810304&query=is%3Aunresolved%20assigned%3Ame&referrer=issue-stream

An orphaned sub is a not "canceled" stripe sub with no matching
workspace

- Clean all orphaned sub (script not included in this PR)
- Ensure to soft delete > cancel stripe sub > check for not active sub >
hard delete in every workspace deletion flow

Bonus : 
- Remove dead code
- Update doc on RLS (to improve AI chat knowledge)
This commit is contained in:
Etienne
2026-05-21 19:02:54 +02:00
committed by GitHub
parent 3c91f3f276
commit 0edd8d400c
15 changed files with 109 additions and 256 deletions
@@ -95,6 +95,21 @@ Click **+ Add rule** and select an object to create an exception.
| Opportunities → disable "See Records" | Intern cannot see the Opportunities object at all |
| People → enable "Edit Records" | Intern can edit People records (but not other objects) |
### Row-Level Permissions
<Note>
Row-level permissions are a **Premium feature** available on the **Organization** plan (Cloud and Self-Hosted).
</Note>
Row-level permissions let you restrict which individual records a role can see or edit, based on dynamic criteria. Unlike object permissions (which apply to an entire object type), row-level permissions evaluate each record independently.
**Example use cases:**
- Sales reps can only see their own opportunities
- Managers can see all records in their region
- Support agents can only view tickets assigned to them
To configure row-level permissions, open a role, navigate to the **Objects** tab, and use the **Row-Level** section to define filter conditions for a specific object.
### Field Permissions
Within each object-level rule, you can go further and configure **field-level permissions** to control access to specific fields.
@@ -60,7 +60,7 @@ For fields:
</Accordion>
<Accordion title="Can I restrict access to specific records (row-level permissions)?">
Row-level permissions will be available on the **Organization** plan by Q1 2026. This allows you to restrict access to specific records based on criteria (e.g., only see your own opportunities).
Row-level permissions are available on the **Organization** plan. This allows you to restrict access to specific records based on criteria (e.g., only see your own opportunities).
</Accordion>
<Accordion title="How do I make a field read-only for certain users?">
@@ -15,7 +15,6 @@ import {
import { type Response } from 'express';
import Stripe from 'stripe';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -46,7 +45,6 @@ export class BillingWebhookController {
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingWebhookProductService: BillingWebhookProductService,
private readonly billingWebhookPriceService: BillingWebhookPriceService,
private readonly billingWebhookAlertService: BillingWebhookAlertService,
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
private readonly billingWebhookCustomerService: BillingWebhookCustomerService,
private readonly billingWebhookSubscriptionScheduleService: BillingWebhookSubscriptionScheduleService,
@@ -118,11 +116,6 @@ export class BillingWebhookController {
event.data,
);
case BillingWebhookEvent.ALERT_TRIGGERED:
return await this.billingWebhookAlertService.processStripeEvent(
event.data,
);
case BillingWebhookEvent.INVOICE_FINALIZED:
case BillingWebhookEvent.INVOICE_PAID:
return await this.billingWebhookInvoiceService.processStripeEvent(
@@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -58,7 +57,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
providers: [
BillingWebhookProductService,
BillingWebhookPriceService,
BillingWebhookAlertService,
BillingWebhookInvoiceService,
BillingWebhookCustomerService,
BillingWebhookSubscriptionService,
@@ -1,68 +0,0 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
@Injectable()
export class BillingWebhookAlertService {
protected readonly logger = new Logger(BillingWebhookAlertService.name);
constructor(
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(BillingProductEntity)
private readonly billingProductRepository: Repository<BillingProductEntity>,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
) {}
async processStripeEvent(data: Stripe.BillingAlertTriggeredEvent.Data) {
const { customer: stripeCustomerId, alert } = data.object;
const stripeMeterId = alert.usage_threshold?.meter;
assertIsDefinedOrThrow(stripeMeterId);
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ stripeCustomerId },
);
const product = await this.billingProductRepository.findOne({
where: {
billingPrices: {
stripeMeterId:
typeof stripeMeterId === 'string'
? stripeMeterId
: stripeMeterId.id,
},
},
});
if (!product) {
throw new BillingException(
`Product associated to meter ${stripeMeterId} not found`,
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
}
await this.billingSubscriptionItemRepository.update(
{
billingSubscriptionId: subscription.id,
stripeProductId: product.stripeProductId,
},
{ hasReachedCurrentPeriodCap: true },
);
}
}
@@ -23,9 +23,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -57,10 +55,8 @@ export class BillingWebhookSubscriptionService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly workspaceService: WorkspaceService,
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
private readonly stripeBillingAlertService: StripeBillingAlertService,
private readonly billingUsageService: BillingUsageService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -153,10 +149,7 @@ export class BillingWebhookSubscriptionService {
if (shouldSuspend) {
if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
suspendedAt: new Date(),
});
await this.workspaceService.suspendWorkspace(workspaceId);
} else if (
workspace.activationStatus ===
WorkspaceActivationStatus.PENDING_CREATION
@@ -32,6 +32,7 @@ export enum BillingExceptionCode {
BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND',
BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND',
BILLING_CREDITS_EXHAUSTED = 'BILLING_CREDITS_EXHAUSTED',
BILLING_SUBSCRIPTION_NOT_CANCELED = 'BILLING_SUBSCRIPTION_NOT_CANCELED',
}
const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => {
@@ -86,6 +87,8 @@ const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => {
return msg`Multiple subscriptions found where one was expected.`;
case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
return msg`You have exhausted your credits. Please upgrade your plan to continue.`;
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED:
return msg`Workspace cannot be deleted: subscription is not yet canceled.`;
default:
assertUnreachable(code);
}
@@ -141,17 +141,29 @@ export class BillingSubscriptionService {
return billingSubscriptionItem;
}
async deleteSubscriptions(workspaceId: string) {
const subscriptionToCancel = await this.getCurrentBillingSubscription({
async cancelSubscription(workspaceId: string): Promise<void> {
const subscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (isDefined(subscriptionToCancel)) {
if (isDefined(subscription)) {
await this.stripeSubscriptionService.cancelSubscription(
subscriptionToCancel.stripeSubscriptionId,
subscription.stripeSubscriptionId,
);
}
}
async assertSubscriptionCanceledOrNone(workspaceId: string): Promise<void> {
const activeSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (isDefined(activeSubscription)) {
throw new BillingException(
`Subscription for workspace ${workspaceId} is not canceled`,
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED,
);
}
await this.billingSubscriptionRepository.delete({ workspaceId });
}
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
@@ -1,101 +0,0 @@
import { Injectable } from '@nestjs/common';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { STRIPE_BILLING_METER_EVENT_NAME } from 'src/engine/core-modules/billing/stripe/constants/stripe-billing-meter-event-name.constant';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class StripeBillingAlertService {
private readonly stripe: Stripe;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSDKService: StripeSDKService,
private readonly stripeBillingMeterService: StripeBillingMeterService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
) {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.twentyConfigService.get('BILLING_STRIPE_API_KEY'),
);
}
async createUsageThresholdAlertForCustomerMeter(
customerId: string,
tierCap: number,
creditBalance: number = 0,
periodStart?: Date,
): Promise<void> {
const meter = (await this.stripeBillingMeterService.getAllMeters()).find(
(meterItem) => {
return meterItem.event_name === STRIPE_BILLING_METER_EVENT_NAME;
},
);
assertIsDefinedOrThrow(meter);
await this.archiveAlertsForCustomer(customerId, meter.id);
// Use cumulative usage at period start to ensure consistent threshold
// regardless of when the alert is created/recreated during the period
const usageAtPeriodStart = periodStart
? await this.stripeBillingMeterEventService.getCumulativeUsageAtTime(
meter.id,
customerId,
periodStart,
)
: await this.stripeBillingMeterEventService.getTotalCumulativeUsage(
meter.id,
customerId,
);
// Threshold = usage at period start + allowance for this period
const dynamicThreshold = usageAtPeriodStart + tierCap + creditBalance;
await this.stripe.billing.alerts.create({
alert_type: 'usage_threshold',
title: `Usage cap for customer ${customerId}`,
usage_threshold: {
gte: dynamicThreshold,
meter: meter.id,
recurrence: 'one_time',
filters: [
{
type: 'customer',
customer: customerId,
},
],
},
});
}
private async archiveAlertsForCustomer(
customerId: string,
meterId: string,
): Promise<void> {
const alerts = await this.stripe.billing.alerts.list({
meter: meterId,
});
const customerAlerts = alerts.data.filter(
(alert) =>
alert.status === 'active' &&
alert.usage_threshold?.filters?.some(
(filter) =>
filter.type === 'customer' && filter.customer === customerId,
),
);
for (const alert of customerAlerts) {
await this.stripe.billing.alerts.archive(alert.id);
}
}
}
@@ -3,7 +3,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
@@ -39,7 +38,6 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
StripePriceService,
StripeProductService,
StripeBillingMeterEventService,
StripeBillingAlertService,
StripeCreditGrantService,
StripeInvoiceService,
],
@@ -55,7 +53,6 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
StripeProductService,
StripeBillingMeterEventService,
StripeSubscriptionScheduleService,
StripeBillingAlertService,
StripeCreditGrantService,
StripeInvoiceService,
],
@@ -39,6 +39,7 @@ export const getBillingExceptionStatusCode = (
case BillingExceptionCode.BILLING_PRICE_INVALID:
case BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND:
case BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED:
return 500;
default: {
return assertUnreachable(exception.code);
@@ -3,27 +3,21 @@ import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service';
import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
@@ -31,7 +25,6 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -66,18 +59,6 @@ describe('WorkspaceService', () => {
delete: jest.fn(),
},
},
{
provide: getRepositoryToken(ApprovedAccessDomainEntity),
useValue: {
findOneBy: jest.fn(),
},
},
{
provide: ObjectMetadataService,
useValue: {
deleteWorkspaceAllObjectMetadata: jest.fn(),
},
},
{
provide: getRepositoryToken(UserWorkspaceEntity),
useValue: {
@@ -101,30 +82,19 @@ describe('WorkspaceService', () => {
{
provide: BillingSubscriptionService,
useValue: {
deleteSubscriptions: jest.fn(),
},
},
{
provide: AuditService,
useValue: {
createContext: jest.fn(),
cancelSubscription: jest.fn(),
assertSubscriptionCanceledOrNone: jest.fn(),
},
},
...[
WorkspaceManagerService,
UserWorkspaceService,
UserService,
DnsManagerService,
CustomDomainManagerService,
SubdomainManagerService,
TwentyConfigService,
EmailService,
OnboardingService,
WorkspaceInvitationService,
PermissionsService,
FeatureFlagService,
ExceptionHandlerService,
PermissionsService,
FeatureFlagService,
FileCorePictureService,
AiModelRegistryService,
ApplicationService,
@@ -251,6 +221,7 @@ describe('WorkspaceService', () => {
expect(userWorkspaceRepository.delete).not.toHaveBeenCalled();
expect(userRepository.softDelete).toHaveBeenCalledWith('user-id');
});
it('should destroy the user workspace record', async () => {
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([
{
@@ -308,7 +279,7 @@ describe('WorkspaceService', () => {
});
describe('deleteWorkspace', () => {
it('should delete the workspace', async () => {
it('should hard delete the workspace', async () => {
const mockWorkspace = {
id: 'workspace-id',
metadataVersion: 0,
@@ -321,12 +292,16 @@ describe('WorkspaceService', () => {
await service.deleteWorkspace(mockWorkspace.id, false);
expect(workspaceRepository.softDelete).not.toHaveBeenCalled();
expect(
billingSubscriptionService.assertSubscriptionCanceledOrNone,
).toHaveBeenCalledWith(mockWorkspace.id);
expect(workspaceCacheStorageService.flush).toHaveBeenCalledWith(
mockWorkspace.id,
mockWorkspace.metadataVersion,
);
expect(messageQueueService.add).toHaveBeenCalled();
expect(workspaceRepository.delete).toHaveBeenCalledWith(mockWorkspace.id);
expect(workspaceRepository.softDelete).not.toHaveBeenCalled();
});
it('should soft delete the workspace', async () => {
@@ -342,8 +317,9 @@ describe('WorkspaceService', () => {
await service.deleteWorkspace(mockWorkspace.id, true);
expect(billingSubscriptionService.deleteSubscriptions).toHaveBeenCalled();
expect(
billingSubscriptionService.cancelSubscription,
).toHaveBeenCalledWith(mockWorkspace.id);
expect(workspaceRepository.softDelete).toHaveBeenCalledWith({
id: mockWorkspace.id,
});
@@ -30,6 +30,7 @@ import {
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
@@ -58,7 +59,6 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-companies.util';
import { prefillDashboards } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-dashboards.util';
@@ -439,6 +439,13 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
}
}
async suspendWorkspace(id: string) {
await this.workspaceRepository.update(id, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
suspendedAt: new Date(),
});
}
async deleteWorkspace(id: string, softDelete = false) {
const workspace = await this.workspaceRepository.findOne({
where: { id },
@@ -465,11 +472,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
this.logger.log(`workspace ${id} cache flushed`);
if (this.billingService.isBillingEnabled()) {
await this.billingSubscriptionService.deleteSubscriptions(workspace.id);
}
if (softDelete) {
if (this.billingService.isBillingEnabled()) {
await this.billingSubscriptionService.cancelSubscription(workspace.id);
}
await this.workspaceRepository.softDelete({ id });
await this.coreEntityCacheService.invalidate('workspaceEntity', id);
@@ -478,6 +485,12 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
return workspace;
}
if (this.billingService.isBillingEnabled()) {
await this.billingSubscriptionService.assertSubscriptionCanceledOrNone(
workspace.id,
);
}
await this.deleteWorkspaceSyncableMetadataEntities(workspace);
await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspace.id);
@@ -14,6 +14,7 @@ import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
@@ -30,7 +31,6 @@ import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/servic
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -70,7 +70,6 @@ import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { getRequest } from 'src/utils/extract-request';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
const OriginHeader = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => {
const request = getRequest(ctx);
@@ -91,7 +90,6 @@ export class WorkspaceResolver {
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly twentyConfigService: TwentyConfigService,
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly featureFlagService: FeatureFlagService,
@@ -171,7 +169,8 @@ export class WorkspaceResolver {
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
async deleteCurrentWorkspace(@AuthWorkspace() { id }: WorkspaceEntity) {
return this.workspaceService.deleteWorkspace(id);
await this.workspaceService.suspendWorkspace(id);
return this.workspaceService.deleteWorkspace(id, true);
}
@ResolveField(() => [BillingSubscriptionEntity])
@@ -291,27 +291,49 @@ export class CleanerWorkspaceService {
});
if (workspaces.length !== 0) {
if (!dryRun) {
for (const workspace of workspaces) {
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
workspaceId: workspace.id,
},
withDeleted: true,
});
for (const workspace of workspaces) {
if (!isDefined(workspace.deletedAt)) {
this.logger.log(
`${dryRun ? 'DRY RUN - ' : ''}Soft deleting onboarding workspace ${workspace.id}`,
);
for (const userWorkspace of userWorkspaces) {
await this.workspaceService.handleRemoveWorkspaceMember(
workspace.id,
userWorkspace.userId,
);
if (!dryRun) {
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
workspaceId: workspace.id,
},
withDeleted: true,
});
for (const userWorkspace of userWorkspaces) {
await this.workspaceService.handleRemoveWorkspaceMember(
workspace.id,
userWorkspace.userId,
);
}
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
await this.billingSubscriptionService.cancelSubscription(
workspace.id,
);
}
await this.workspaceService.deleteWorkspace(workspace.id, true);
}
} else {
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
await this.billingSubscriptionService.deleteSubscriptions(
await this.billingSubscriptionService.assertSubscriptionCanceledOrNone(
workspace.id,
);
}
await this.workspaceRepository.delete(workspace.id);
this.logger.log(
`${dryRun ? 'DRY RUN - ' : ''}Hard deleting onboarding workspace ${workspace.id}`,
);
if (!dryRun) {
await this.workspaceService.deleteWorkspace(workspace.id);
}
}
}