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:
+420
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
-52
@@ -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);
|
||||
}
|
||||
|
||||
+12
@@ -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,
|
||||
];
|
||||
+20
@@ -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,
|
||||
|
||||
+51
-15
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user