From 217cf650aa1b4449d3f9dff539cd7bd39697d089 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:09:09 +0200 Subject: [PATCH] Decide workspace destiny from live data and all sub (#22943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context A production customer was stuck on the billing settings page: their workspace was `SUSPENDED` (with `suspendedAt` set) while their subscription was `active` in the database. ## Problem Stripe webhook events can be delivered out of order or processed concurrently ([Stripe explicitly does not guarantee ordering](https://docs.stripe.com/webhooks#events-ordering)), and `BillingWebhookSubscriptionService.processStripeEvent` made its suspend/reactivate decision from stale and incomplete data: - The decision used the **event payload's** subscription status, while the subscription row was upserted from a **live Stripe fetch** — so the two could diverge. Around trial end, Stripe emits `active → past_due` then (once the customer pays) `past_due → active` within a short window. If the stale `past_due` event is processed last, it suspends the workspace while writing an `active` subscription to the DB. Nothing self-heals from that state: the workspace stays suspended while the cleanup cron warns and eventually soft-deletes it. - The decision only looked at the **event's own subscription**, but suspension is a workspace-level decision and a Stripe customer can hold several subscriptions (plan switch, cancel-then-resubscribe). A `customer.subscription.deleted` event for the old subscription is *genuinely* canceled — only the sibling subscription proves the customer is still paying. - The workspace snapshot was read at the top of the handler, before several slow awaits, so a concurrent event could change it mid-flight. ## Fix Every event now converges the workspace to the current Stripe state, regardless of delivery order: - **Live input**: fetch the customer's not-ended subscriptions from Stripe (`subscriptions.list` without a `status` param excludes the unbounded canceled history server-side; an explicit `NOT_ENDED_SUBSCRIPTION_STATUSES` filter additionally drops `incomplete_expired`, which would otherwise block suspension forever since it is neither suspend-worthy nor activating). The event's subscription is taken from that list, or fetched directly by id when absent (deletion events, deleted customers) — same retrieve the code used before. The DB upsert uses this live object, never the payload. - **Workspace-level decision over all live subscriptions**: suspend only when **every** subscription warrants it, reactivate as soon as **one** is activating (`active`/`trialing`), and deliberately do nothing in between — e.g. a `past_due` subscription in its payment-retry grace period blocks suspension without triggering reactivation. - **Guarded transitions (compare-and-swap)**: `WorkspaceService.suspendWorkspace`/`reactivateWorkspace` now apply their UPDATE only when the workspace is still in a state the transition is valid from, and return whether they applied. Repeated suspensions keep the first `suspendedAt` so the cleanup countdown stays anchored to the original suspension date; the deletion-warning cleanup job is only enqueued when a reactivation actually applied. The suspend path re-reads the workspace right before deciding and switches exhaustively on `activationStatus` (`assertUnreachable` in `default`), preserving the previous behavior including suspend-over-reactivate precedence. ## Tests Unit tests cover the incident scenario and its neighbors: a stale `past_due` event after payment reactivates instead of suspending; a live `unpaid` state suspends even when the payload says `active`; a canceled subscription event does not suspend (and reactivates) when a sibling `active`/`trialing` subscription exists; a sibling in `past_due` grace blocks suspension without reactivating; the direct-retrieve fallback handles subscriptions absent from the customer list; the guarded reactivation skips the cleanup job when it did not apply; and soft-deleted workspaces are never transitioned. --- ...lling-webhook-subscription.service.spec.ts | 420 ++++++++++++++++++ .../billing-webhook-subscription.service.ts | 141 +++--- ...ot-ended-subscription-statuses.constant.ts | 12 + .../stripe-subscription-schedule.service.ts | 20 + .../workspace/services/workspace.service.ts | 66 ++- 5 files changed, 592 insertions(+), 67 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing-webhook/services/__tests__/billing-webhook-subscription.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/constants/not-ended-subscription-statuses.constant.ts diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/__tests__/billing-webhook-subscription.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/__tests__/billing-webhook-subscription.service.spec.ts new file mode 100644 index 0000000000..46c2c67af4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/__tests__/billing-webhook-subscription.service.spec.ts @@ -0,0 +1,420 @@ +/* @license Enterprise */ + +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; + +import type Stripe from 'stripe'; + +import { mockStripeSubscriptionUpdatedEventWithoutUpdatedItem } from 'src/engine/core-modules/billing-webhook/__mocks__/stripe-subscription-updated-events'; +import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service'; +import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; +import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.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 { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; +import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +const WORKSPACE_ID = 'workspace-id'; + +const buildWorkspace = ( + activationStatus: WorkspaceActivationStatus, + deletedAt: Date | null = null, +) => + ({ + id: WORKSPACE_ID, + activationStatus, + deletedAt, + }) as unknown as WorkspaceEntity; + +const buildSubscriptionUpdatedEvent = ( + overrides: Partial, +): Stripe.CustomerSubscriptionUpdatedEvent => { + const baseEvent = mockStripeSubscriptionUpdatedEventWithoutUpdatedItem; + + return { + ...baseEvent, + data: { + ...baseEvent.data, + object: { + ...baseEvent.data.object, + ...overrides, + }, + }, + }; +}; + +const buildLiveSubscription = ( + overrides: Partial, +): SubscriptionWithSchedule => + ({ + ...mockStripeSubscriptionUpdatedEventWithoutUpdatedItem.data.object, + ...overrides, + }) as unknown as SubscriptionWithSchedule; + +describe('BillingWebhookSubscriptionService', () => { + let service: BillingWebhookSubscriptionService; + let workspaceRepository: { findOne: jest.Mock }; + let workspaceService: { + suspendWorkspace: jest.Mock; + reactivateWorkspace: jest.Mock; + deleteWorkspace: jest.Mock; + }; + let stripeSubscriptionScheduleService: { + listCustomerNotEndedSubscriptionsWithSchedule: jest.Mock; + getSubscriptionWithSchedule: jest.Mock; + }; + let messageQueueService: { add: jest.Mock }; + let billingSubscriptionRepository: { upsert: jest.Mock; findOne: jest.Mock }; + + beforeEach(async () => { + workspaceRepository = { findOne: jest.fn() }; + workspaceService = { + suspendWorkspace: jest.fn().mockResolvedValue(true), + reactivateWorkspace: jest.fn().mockResolvedValue(true), + deleteWorkspace: jest.fn(), + }; + stripeSubscriptionScheduleService = { + listCustomerNotEndedSubscriptionsWithSchedule: jest.fn(), + getSubscriptionWithSchedule: jest.fn(), + }; + messageQueueService = { add: jest.fn() }; + billingSubscriptionRepository = { + upsert: jest.fn(), + findOne: jest.fn().mockResolvedValue({ + id: 'billing-subscription-id', + stripeSubscriptionId: + mockStripeSubscriptionUpdatedEventWithoutUpdatedItem.data.object.id, + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillingWebhookSubscriptionService, + { + provide: StripeCustomerService, + useValue: { updateCustomerMetadataWorkspaceId: jest.fn() }, + }, + { + provide: getQueueToken(MessageQueue.workspaceQueue), + useValue: messageQueueService, + }, + { + provide: getRepositoryToken(BillingSubscriptionEntity), + useValue: billingSubscriptionRepository, + }, + { + provide: getRepositoryToken(BillingSubscriptionItemEntity), + useValue: { upsert: jest.fn(), delete: jest.fn() }, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useValue: workspaceRepository, + }, + { + provide: getWorkspaceScopedRepositoryToken(BillingCustomerEntity), + useValue: { upsert: jest.fn() }, + }, + { provide: WorkspaceService, useValue: workspaceService }, + { + provide: StripeSubscriptionScheduleService, + useValue: stripeSubscriptionScheduleService, + }, + { + provide: BillingUsageCacheService, + useValue: { flushAvailableCredits: jest.fn() }, + }, + { + provide: WorkspaceCacheService, + useValue: { invalidateAndRecompute: jest.fn() }, + }, + ], + }).compile(); + + service = module.get( + BillingWebhookSubscriptionService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('processStripeEvent', () => { + it('should not suspend and should reactivate when a stale past_due event is processed after the subscription became active', async () => { + // Trial ended one hour ago: a past_due payload within the 24h window + // previously triggered a suspension even if the customer had already paid + const trialEndOneHourAgo = Math.floor(Date.now() / 1000) - 3600; + + const staleEvent = buildSubscriptionUpdatedEvent({ + status: 'past_due', + trial_end: trialEndOneHourAgo, + }); + + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [ + buildLiveSubscription({ + status: 'active', + trial_end: trialEndOneHourAgo, + }), + ], + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.SUSPENDED), + ); + + await service.processStripeEvent(WORKSPACE_ID, staleEvent); + + expect(workspaceService.suspendWorkspace).not.toHaveBeenCalled(); + expect(workspaceService.reactivateWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + ); + expect(messageQueueService.add).toHaveBeenCalled(); + }); + + it('should suspend an active workspace when the live subscription is unpaid even if the event payload says active', async () => { + const staleEvent = buildSubscriptionUpdatedEvent({ status: 'active' }); + + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [buildLiveSubscription({ status: 'unpaid' })], + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.ACTIVE), + ); + + await service.processStripeEvent(WORKSPACE_ID, staleEvent); + + expect(workspaceService.suspendWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + ); + expect(workspaceService.reactivateWorkspace).not.toHaveBeenCalled(); + }); + + it('should attempt the guarded reactivation even when the workspace snapshot is stale', async () => { + const activeEvent = buildSubscriptionUpdatedEvent({ status: 'active' }); + + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [buildLiveSubscription({ status: 'active' })], + ); + + // The handler's snapshot says ACTIVE (e.g. a concurrent event suspends + // the workspace mid-processing): reactivation is still attempted and the + // compare-and-swap in WorkspaceService decides whether it applies + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.ACTIVE), + ); + + await service.processStripeEvent(WORKSPACE_ID, activeEvent); + + expect(workspaceService.reactivateWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + ); + expect(workspaceService.suspendWorkspace).not.toHaveBeenCalled(); + }); + + it('should not suspend when the event subscription is canceled but the customer has another live activating subscription', async () => { + const staleDeletionEvent = buildSubscriptionUpdatedEvent({ + status: 'canceled', + }); + + // The canceled event subscription is not in the not-canceled list and + // is fetched through the direct retrieve fallback instead + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [ + buildLiveSubscription({ + id: 'sub_new_active_subscription', + status: 'active', + }), + ], + ); + stripeSubscriptionScheduleService.getSubscriptionWithSchedule.mockResolvedValue( + buildLiveSubscription({ status: 'canceled' }), + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.ACTIVE), + ); + + await service.processStripeEvent(WORKSPACE_ID, staleDeletionEvent); + + expect(workspaceService.suspendWorkspace).not.toHaveBeenCalled(); + expect(workspaceService.deleteWorkspace).not.toHaveBeenCalled(); + }); + + it('should neither suspend nor reactivate when a sibling subscription is in a past_due grace period', async () => { + const deletionEvent = buildSubscriptionUpdatedEvent({ + status: 'canceled', + }); + + // The sibling is past_due outside the trial window: not suspend-worthy + // (payment retries are in progress) but not activating either, so the + // workspace must be left untouched + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [ + buildLiveSubscription({ + id: 'sub_sibling_in_grace_period', + status: 'past_due', + trial_end: null, + }), + ], + ); + stripeSubscriptionScheduleService.getSubscriptionWithSchedule.mockResolvedValue( + buildLiveSubscription({ status: 'canceled' }), + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.ACTIVE), + ); + + await service.processStripeEvent(WORKSPACE_ID, deletionEvent); + + expect(workspaceService.suspendWorkspace).not.toHaveBeenCalled(); + expect(workspaceService.deleteWorkspace).not.toHaveBeenCalled(); + expect(workspaceService.reactivateWorkspace).not.toHaveBeenCalled(); + }); + + it('should reactivate a suspended workspace on a canceled subscription event when another live activating subscription exists', async () => { + const staleDeletionEvent = buildSubscriptionUpdatedEvent({ + status: 'canceled', + }); + + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [ + buildLiveSubscription({ + id: 'sub_new_trialing_subscription', + status: 'trialing', + }), + ], + ); + stripeSubscriptionScheduleService.getSubscriptionWithSchedule.mockResolvedValue( + buildLiveSubscription({ status: 'canceled' }), + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.SUSPENDED), + ); + + await service.processStripeEvent(WORKSPACE_ID, staleDeletionEvent); + + expect(workspaceService.suspendWorkspace).not.toHaveBeenCalled(); + expect(workspaceService.reactivateWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + ); + }); + + it('should not enqueue the deletion warning cleanup job when the guarded reactivation did not apply', async () => { + const activeEvent = buildSubscriptionUpdatedEvent({ status: 'active' }); + + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [buildLiveSubscription({ status: 'active' })], + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.SUSPENDED), + ); + + // Compare-and-swap in WorkspaceService reports no transition, e.g. a + // concurrent handler already reactivated the workspace + workspaceService.reactivateWorkspace.mockResolvedValue(false); + + await service.processStripeEvent(WORKSPACE_ID, activeEvent); + + expect(workspaceService.reactivateWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + ); + expect(messageQueueService.add).not.toHaveBeenCalled(); + }); + + it('should not reactivate a suspended workspace when the live subscription is past_due outside the trial window', async () => { + const staleEvent = buildSubscriptionUpdatedEvent({ status: 'active' }); + + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [buildLiveSubscription({ status: 'past_due', trial_end: null })], + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.SUSPENDED), + ); + + await service.processStripeEvent(WORKSPACE_ID, staleEvent); + + expect(workspaceService.suspendWorkspace).not.toHaveBeenCalled(); + expect(workspaceService.reactivateWorkspace).not.toHaveBeenCalled(); + }); + + it('should fall back to a direct subscription retrieve when the customer subscription list does not contain the event subscription', async () => { + const deletionEvent = { + ...buildSubscriptionUpdatedEvent({ status: 'canceled' }), + type: 'customer.subscription.deleted', + } as unknown as Stripe.CustomerSubscriptionDeletedEvent; + + // Deleted Stripe customer: listing its subscriptions returns nothing, + // but retrieving the canceled subscription by id still works + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [], + ); + stripeSubscriptionScheduleService.getSubscriptionWithSchedule.mockResolvedValue( + buildLiveSubscription({ status: 'canceled' }), + ); + + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.ACTIVE), + ); + + await service.processStripeEvent(WORKSPACE_ID, deletionEvent); + + expect( + stripeSubscriptionScheduleService.getSubscriptionWithSchedule, + ).toHaveBeenCalledWith( + mockStripeSubscriptionUpdatedEventWithoutUpdatedItem.data.object.id, + ); + expect(workspaceService.suspendWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + ); + expect(workspaceService.reactivateWorkspace).not.toHaveBeenCalled(); + }); + + it('should not transition a workspace that was soft-deleted mid-processing', async () => { + const deletionEvent = { + ...buildSubscriptionUpdatedEvent({ status: 'canceled' }), + type: 'customer.subscription.deleted', + } as unknown as Stripe.CustomerSubscriptionDeletedEvent; + + stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule.mockResolvedValue( + [ + buildLiveSubscription({ + id: 'sub_new_active_subscription', + status: 'active', + }), + ], + ); + stripeSubscriptionScheduleService.getSubscriptionWithSchedule.mockResolvedValue( + buildLiveSubscription({ status: 'canceled' }), + ); + + // The workspace was soft-deleted mid-processing: billing must not + // suspend or delete it, and the reactivation compare-and-swap refuses + // soft-deleted workspaces (deletedAt IS NULL in its WHERE clause) + workspaceRepository.findOne.mockResolvedValue( + buildWorkspace(WorkspaceActivationStatus.SUSPENDED, new Date()), + ); + workspaceService.reactivateWorkspace.mockResolvedValue(false); + + await service.processStripeEvent(WORKSPACE_ID, deletionEvent); + + expect(workspaceService.suspendWorkspace).not.toHaveBeenCalled(); + expect(workspaceService.deleteWorkspace).not.toHaveBeenCalled(); + expect(messageQueueService.add).not.toHaveBeenCalled(); + }); + }); +}); 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 26178daabf..cd05a3aee7 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 @@ -4,7 +4,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; -import { isDefined } from 'twenty-shared/utils'; +import { assertUnreachable, isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { In, Repository } from 'typeorm'; @@ -18,15 +18,16 @@ import { BillingException, BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; +import { WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES } from 'src/engine/core-modules/billing/constants/workspace-activating-subscription-statuses.constant'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES } from 'src/engine/core-modules/billing/constants/workspace-activating-subscription-statuses.constant'; 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 { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.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 { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; 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'; @@ -80,7 +81,7 @@ export class BillingWebhookSubscriptionService { withDeleted: true, }); - if (!workspace) { + if (!isDefined(workspace)) { throw new BillingException( `Workspace not found for subscription event ${event.id} / workspaceId: ${workspaceId}`, BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND, @@ -112,12 +113,29 @@ export class BillingWebhookSubscriptionService { }, ); + const liveCustomerSubscriptions = + await this.stripeSubscriptionScheduleService.listCustomerNotEndedSubscriptionsWithSchedule( + String(data.object.customer), + ); + + const subscriptionFromList = liveCustomerSubscriptions.find( + (customerSubscription) => customerSubscription.id === data.object.id, + ); + + const subscriptionWithSchedule = isDefined(subscriptionFromList) + ? subscriptionFromList + : await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( + data.object.id, + ); + + const allLiveSubscriptions = isDefined(subscriptionFromList) + ? liveCustomerSubscriptions + : [...liveCustomerSubscriptions, subscriptionWithSchedule]; + await this.billingSubscriptionRepository.upsert( transformStripeSubscriptionEventToDatabaseSubscription( workspaceId, - await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( - data.object.id, - ), + subscriptionWithSchedule, ), { conflictPaths: ['stripeSubscriptionId'], @@ -125,15 +143,12 @@ export class BillingWebhookSubscriptionService { }, ); - const billingSubscriptions = await this.billingSubscriptionRepository.find({ - where: { workspaceId }, - }); + const updatedBillingSubscription = + await this.billingSubscriptionRepository.findOne({ + where: { workspaceId, stripeSubscriptionId: data.object.id }, + }); - const updatedBillingSubscription = billingSubscriptions.find( - (subscription) => subscription.stripeSubscriptionId === data.object.id, - ); - - if (!updatedBillingSubscription) { + if (!isDefined(updatedBillingSubscription)) { throw new BillingException( 'Billing subscription not found after upsert', BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND, @@ -151,26 +166,58 @@ export class BillingWebhookSubscriptionService { 'currentBillingSubscription', ]); - if (this.shouldSuspendWorkspace(data)) { - if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) { - await this.workspaceService.suspendWorkspace(workspaceId); - } else if ( - workspace.activationStatus === - WorkspaceActivationStatus.PENDING_CREATION - ) { - await this.workspaceService.deleteWorkspace(workspace.id); - } - } else if ( - this.shouldReactivateWorkspace(data) && - (workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED || - workspace.activationStatus === WorkspaceActivationStatus.CREATED) - ) { - await this.workspaceService.reactivateWorkspace(workspaceId); + const shouldSuspendWorkspace = allLiveSubscriptions.every( + (customerSubscription) => + this.shouldSuspendWorkspace(customerSubscription), + ); + const shouldReactivateWorkspace = allLiveSubscriptions.some( + (customerSubscription) => + this.shouldReactivateWorkspace(customerSubscription), + ); - await this.messageQueueService.add( - CleanWorkspaceDeletionWarningUserVarsJob.name, - { workspaceId }, - ); + if (shouldSuspendWorkspace) { + const refreshedWorkspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + withDeleted: true, + }); + + if (!isDefined(refreshedWorkspace)) { + throw new BillingException( + `Workspace not found on re-read for subscription event ${event.id} / workspaceId: ${workspaceId}`, + BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND, + { + userFriendlyMessage: msg`Workspace ${workspaceId} is not found.`, + }, + ); + } + + if (!isDefined(refreshedWorkspace.deletedAt)) { + switch (refreshedWorkspace.activationStatus) { + case WorkspaceActivationStatus.PENDING_CREATION: + await this.workspaceService.deleteWorkspace(workspaceId); + break; + case WorkspaceActivationStatus.ACTIVE: + await this.workspaceService.suspendWorkspace(workspaceId); + break; + case WorkspaceActivationStatus.SUSPENDED: + case WorkspaceActivationStatus.CREATED: + case WorkspaceActivationStatus.ONGOING_CREATION: + case WorkspaceActivationStatus.INACTIVE: + break; + default: + assertUnreachable(refreshedWorkspace.activationStatus); + } + } + } else if (shouldReactivateWorkspace) { + const hasBeenReactivated = + await this.workspaceService.reactivateWorkspace(workspaceId); + + if (hasBeenReactivated) { + await this.messageQueueService.add( + CleanWorkspaceDeletionWarningUserVarsJob.name, + { workspaceId }, + ); + } } await this.stripeCustomerService.updateCustomerMetadataWorkspaceId( @@ -184,13 +231,8 @@ export class BillingWebhookSubscriptionService { }; } - shouldSuspendWorkspace( - data: - | Stripe.CustomerSubscriptionUpdatedEvent.Data - | Stripe.CustomerSubscriptionCreatedEvent.Data - | Stripe.CustomerSubscriptionDeletedEvent.Data, - ): boolean { - const status = data.object.status as SubscriptionStatus; + shouldSuspendWorkspace(subscription: SubscriptionWithSchedule): boolean { + const status = subscription.status as SubscriptionStatus; const suspendedStatuses = [ SubscriptionStatus.Canceled, @@ -201,15 +243,15 @@ export class BillingWebhookSubscriptionService { return true; } - const timeSinceTrialEnd = Date.now() / 1000 - (data.object.trial_end || 0); + const timeSinceTrialEnd = Date.now() / 1000 - (subscription.trial_end || 0); const hasTrialJustEnded = timeSinceTrialEnd > 0 && timeSinceTrialEnd < 60 * 60 * 24; const canceledDuringTrial = - data.object.cancel_at_period_end && - isDefined(data.object.canceled_at) && - isDefined(data.object.trial_end) && - data.object.canceled_at <= data.object.trial_end; + subscription.cancel_at_period_end && + isDefined(subscription.canceled_at) && + isDefined(subscription.trial_end) && + subscription.canceled_at <= subscription.trial_end; return ( hasTrialJustEnded && @@ -217,13 +259,8 @@ export class BillingWebhookSubscriptionService { ); } - shouldReactivateWorkspace( - data: - | Stripe.CustomerSubscriptionUpdatedEvent.Data - | Stripe.CustomerSubscriptionCreatedEvent.Data - | Stripe.CustomerSubscriptionDeletedEvent.Data, - ): boolean { - const status = data.object.status as SubscriptionStatus; + shouldReactivateWorkspace(subscription: SubscriptionWithSchedule): boolean { + const status = subscription.status as SubscriptionStatus; return WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES.includes(status); } diff --git a/packages/twenty-server/src/engine/core-modules/billing/constants/not-ended-subscription-statuses.constant.ts b/packages/twenty-server/src/engine/core-modules/billing/constants/not-ended-subscription-statuses.constant.ts new file mode 100644 index 0000000000..93d1868426 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/constants/not-ended-subscription-statuses.constant.ts @@ -0,0 +1,12 @@ +/* @license Enterprise */ + +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; + +export const NOT_ENDED_SUBSCRIPTION_STATUSES: SubscriptionStatus[] = [ + SubscriptionStatus.Active, + SubscriptionStatus.Trialing, + SubscriptionStatus.PastDue, + SubscriptionStatus.Unpaid, + SubscriptionStatus.Incomplete, + SubscriptionStatus.Paused, +]; diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts index e0317c84ab..a0c2acf24e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts @@ -10,6 +10,8 @@ import { BillingException, BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; +import { NOT_ENDED_SUBSCRIPTION_STATUSES } from 'src/engine/core-modules/billing/constants/not-ended-subscription-statuses.constant'; +import { type SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -68,6 +70,24 @@ export class StripeSubscriptionScheduleService { })) as SubscriptionWithSchedule; } + async listCustomerNotEndedSubscriptionsWithSchedule( + stripeCustomerId: string, + ) { + const subscriptions = await this.stripe.subscriptions + .list({ + customer: stripeCustomerId, + expand: ['data.schedule'], + limit: 100, + }) + .autoPagingToArray({ limit: 1000 }); + + return subscriptions.filter((subscription) => + NOT_ENDED_SUBSCRIPTION_STATUSES.includes( + subscription.status as SubscriptionStatus, + ), + ) as SubscriptionWithSchedule[]; + } + async updateSchedule( scheduleId: string, params: Stripe.SubscriptionScheduleUpdateParams, 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 dc6cdc6c51..48707b50c6 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 @@ -7,7 +7,15 @@ import { msg } from '@lingui/core/macro'; import { PermissionFlagType } from 'twenty-shared/constants'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { DataSource, LessThan, QueryRunner, Repository } from 'typeorm'; +import { + DataSource, + In, + IsNull, + LessThan, + Not, + QueryRunner, + Repository, +} from 'typeorm'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; @@ -19,13 +27,13 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing 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 { 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 { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity'; import { EmailingDomainWorkspaceCleanupJob, type EmailingDomainWorkspaceCleanupJobData, } from 'src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job'; +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 { FileWorkspaceFolderDeletionJob, @@ -480,22 +488,50 @@ export class WorkspaceService { } } - async suspendWorkspace(id: string) { - await this.workspaceRepository.update(id, { - activationStatus: WorkspaceActivationStatus.SUSPENDED, - suspendedAt: new Date(), - }); + async suspendWorkspace(id: string): Promise { + const { affected } = await this.workspaceRepository.update( + { + id, + activationStatus: Not(WorkspaceActivationStatus.SUSPENDED), + }, + { + activationStatus: WorkspaceActivationStatus.SUSPENDED, + suspendedAt: new Date(), + }, + ); - await this.coreEntityCacheService.invalidate('workspaceEntity', id); + const hasBeenSuspended = isDefined(affected) && affected > 0; + + if (hasBeenSuspended) { + await this.coreEntityCacheService.invalidate('workspaceEntity', id); + } + + return hasBeenSuspended; } - async reactivateWorkspace(id: string) { - await this.workspaceRepository.update(id, { - activationStatus: WorkspaceActivationStatus.ACTIVE, - suspendedAt: null, - }); + async reactivateWorkspace(id: string): Promise { + const { affected } = await this.workspaceRepository.update( + { + id, + activationStatus: In([ + WorkspaceActivationStatus.SUSPENDED, + WorkspaceActivationStatus.CREATED, + ]), + deletedAt: IsNull(), + }, + { + activationStatus: WorkspaceActivationStatus.ACTIVE, + suspendedAt: null, + }, + ); - await this.coreEntityCacheService.invalidate('workspaceEntity', id); + const hasBeenReactivated = isDefined(affected) && affected > 0; + + if (hasBeenReactivated) { + await this.coreEntityCacheService.invalidate('workspaceEntity', id); + } + + return hasBeenReactivated; } async deleteWorkspace(id: string, softDelete = false) {