Decide workspace destiny from live data and all sub (#22943)

## 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.
This commit is contained in:
Paul Rastoin
2026-07-16 14:09:09 +02:00
committed by GitHub
parent 7ec7774087
commit 217cf650aa
5 changed files with 592 additions and 67 deletions
@@ -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.Subscription>,
): Stripe.CustomerSubscriptionUpdatedEvent => {
const baseEvent = mockStripeSubscriptionUpdatedEventWithoutUpdatedItem;
return {
...baseEvent,
data: {
...baseEvent.data,
object: {
...baseEvent.data.object,
...overrides,
},
},
};
};
const buildLiveSubscription = (
overrides: Partial<Stripe.Subscription>,
): 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>(
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();
});
});
});
@@ -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<CleanWorkspaceDeletionWarningUserVarsJobData>(
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<CleanWorkspaceDeletionWarningUserVarsJobData>(
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);
}
@@ -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,
];
@@ -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,
@@ -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<boolean> {
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<boolean> {
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) {