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:
@@ -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?">
|
||||
|
||||
-7
@@ -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(
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
-68
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-8
@@ -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);
|
||||
}
|
||||
|
||||
+17
-5
@@ -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) {
|
||||
|
||||
-101
@@ -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,
|
||||
],
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+13
-37
@@ -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,
|
||||
});
|
||||
|
||||
+18
-5
@@ -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])
|
||||
|
||||
+37
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user