From 50ef231704548e9aae1a6174999968de9d677002 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:17:27 +0100 Subject: [PATCH] Billing - Fix inactivity (#17703) - Add suspendedAt column on workspace table - Update suspendedAt field when workspace suspended or re-activated - Compute inactivity on suspendedAt Tested : - Cancel subscription - Cancel subscription + Subscribe again - Cleaning command --- ...36-addSuspendedAtColumnOnWorkspaceTable.ts | 19 +++++++++ .../billing-webhook-subscription.service.ts | 4 +- .../workspace/workspace.entity.ts | 5 ++- .../services/cleaner.workspace-service.ts | 40 ++++++------------- 4 files changed, 38 insertions(+), 30 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1770198374736-addSuspendedAtColumnOnWorkspaceTable.ts diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1770198374736-addSuspendedAtColumnOnWorkspaceTable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770198374736-addSuspendedAtColumnOnWorkspaceTable.ts new file mode 100644 index 0000000000..3af2c357f6 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770198374736-addSuspendedAtColumnOnWorkspaceTable.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddSuspendedAtColumnOnWorkspaceTable1770198374736 + implements MigrationInterface +{ + name = 'AddSuspendedAtColumnOnWorkspaceTable1770198374736'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "suspendedAt" TIMESTAMP WITH TIME ZONE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "suspendedAt"`, + ); + } +} 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 5340bb379f..6405c0f10b 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 @@ -3,10 +3,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { msg } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { In, Repository } from 'typeorm'; -import { msg } from '@lingui/core/macro'; import type Stripe from 'stripe'; @@ -145,6 +145,7 @@ export class BillingWebhookSubscriptionService { if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) { await this.workspaceRepository.update(workspaceId, { activationStatus: WorkspaceActivationStatus.SUSPENDED, + suspendedAt: new Date(), }); } else if ( workspace.activationStatus === @@ -157,6 +158,7 @@ export class BillingWebhookSubscriptionService { ) { await this.workspaceRepository.update(workspaceId, { activationStatus: WorkspaceActivationStatus.ACTIVE, + suspendedAt: null, }); await this.messageQueueService.add( diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 1d4b9be582..3163216b1d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -31,7 +31,6 @@ import { PostgresCredentialsEntity } from 'src/engine/core-modules/postgres-cred import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity'; import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { DEFAULT_FAST_MODEL, @@ -51,6 +50,7 @@ import { ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sor import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity'; import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; +import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity'; registerEnumType(WorkspaceActivationStatus, { name: 'WorkspaceActivationStatus', @@ -159,6 +159,9 @@ export class WorkspaceEntity { @Index('IDX_WORKSPACE_ACTIVATION_STATUS') activationStatus: WorkspaceActivationStatus; + @Column({ type: 'timestamptz', nullable: true }) + suspendedAt: Date | null; + @OneToMany( () => PostgresCredentialsEntity, (postgresCredentials) => postgresCredentials.workspace, 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 5ae551fb86..972279f178 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 @@ -79,30 +79,14 @@ export class CleanerWorkspaceService { ); } - async computeDaysSinceSubscriptionUnpaid( + async computeDaysSinceSuspended( workspace: WorkspaceEntity, ): Promise { - const lastSubscription = - await this.billingSubscriptionRepository.findOneOrFail({ - where: { - workspaceId: workspace.id, - }, - order: { updatedAt: 'DESC' }, - }); - - if ( - lastSubscription.status !== SubscriptionStatus.Unpaid && - lastSubscription.status !== SubscriptionStatus.Canceled - ) { - return null; + if (isDefined(workspace.suspendedAt)) { + return differenceInDays(new Date(), workspace.suspendedAt); } - const daysSinceSubscriptionUnpaid = differenceInDays( - new Date(), - lastSubscription.currentPeriodStart, - ); - - return daysSinceSubscriptionUnpaid; + return null; } async checkIfAtLeastOneWorkspaceMemberWarned( @@ -425,20 +409,20 @@ export class CleanerWorkspaceService { continue; } - const workspaceInactivity = - await this.computeDaysSinceSubscriptionUnpaid(workspace); + const inactiveDaysSinceSuspended = + await this.computeDaysSinceSuspended(workspace); - if (workspaceInactivity === null) { + if (inactiveDaysSinceSuspended === null) { continue; } if ( (!isDefined(onlyOperation) || onlyOperation === 'soft-delete') && - workspaceInactivity > this.inactiveDaysBeforeSoftDelete + inactiveDaysSinceSuspended > this.inactiveDaysBeforeSoftDelete ) { await this.informWorkspaceMembersAndSoftDeleteWorkspace( workspace, - workspaceInactivity, + inactiveDaysSinceSuspended, dryRun, ); @@ -447,12 +431,12 @@ export class CleanerWorkspaceService { if ( (!isDefined(onlyOperation) || onlyOperation === 'warn') && - workspaceInactivity > this.inactiveDaysBeforeWarn && - workspaceInactivity <= this.inactiveDaysBeforeSoftDelete + inactiveDaysSinceSuspended > this.inactiveDaysBeforeWarn && + inactiveDaysSinceSuspended <= this.inactiveDaysBeforeSoftDelete ) { await this.warnWorkspaceMembers( workspace, - workspaceInactivity, + inactiveDaysSinceSuspended, dryRun, ); }