diff --git a/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx b/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx
index 3cb8840592..6390b0b2cc 100644
--- a/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx
+++ b/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx
@@ -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
+
+
+Row-level permissions are a **Premium feature** available on the **Organization** plan (Cloud and Self-Hosted).
+
+
+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.
diff --git a/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx b/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx
index 0d5510508a..18ed1856df 100644
--- a/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx
+++ b/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx
@@ -60,7 +60,7 @@ For fields:
-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).
diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts
index 2a0dfdcaf4..64a5ac0499 100644
--- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts
@@ -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(
diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts
index f5f25f7141..40189a59ea 100644
--- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts
@@ -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,
diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service.ts
deleted file mode 100644
index 4b9ff2099a..0000000000
--- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service.ts
+++ /dev/null
@@ -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,
- @InjectRepository(BillingSubscriptionItemEntity)
- private readonly billingSubscriptionItemRepository: Repository,
- ) {}
-
- 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 },
- );
- }
-}
diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts
index af3748056c..bd2eddc445 100644
--- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts
@@ -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,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository,
- 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
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
index d08c217c91..c170c35be4 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
@@ -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);
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
index 04bbdb150e..c1a88b42a0 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
@@ -141,17 +141,29 @@ export class BillingSubscriptionService {
return billingSubscriptionItem;
}
- async deleteSubscriptions(workspaceId: string) {
- const subscriptionToCancel = await this.getCurrentBillingSubscription({
+ async cancelSubscription(workspaceId: string): Promise {
+ 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 {
+ 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) {
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts
deleted file mode 100644
index 38c1f95754..0000000000
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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);
- }
- }
-}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts
index 098874e640..adbbf09ba5 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts
@@ -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,
],
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts
index c91dc80571..718dcba78f 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts
@@ -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);
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts
index 82e9f4cf4c..d29d97fd4c 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts
@@ -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,
});
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
index a7f5c094d8..0e9eb8d408 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
@@ -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 {
}
}
+ 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 {
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 {
return workspace;
}
+ if (this.billingService.isBillingEnabled()) {
+ await this.billingSubscriptionService.assertSubscriptionCanceledOrNone(
+ workspace.id,
+ );
+ }
+
await this.deleteWorkspaceSyncableMetadataEntities(workspace);
await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspace.id);
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts
index 434ccb7abe..1a1575f79e 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts
@@ -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])
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
index 52fb5c319c..c242ef6f33 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
@@ -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);
+ }
}
}