From cfae440a02db1eaee75322fed5b9613d998da6a2 Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Thu, 3 Apr 2025 13:44:32 +0200
Subject: [PATCH] add stripe alert listening and cap on subscriptionItems
(#11330)
in this PR :
- reverting https://github.com/twentyhq/twenty/pull/11319 > at trial
period end, subscriptions switch to 'past_due' status if payment method
not set up
- adding cap on subscriptionItems and updating them when receiving alert
event + refreshing them when beginning a new subscription cycle
closes https://github.com/twentyhq/core-team-issues/issues/606
---
.../components/InformationBannerWrapper.tsx | 6 +-
...CapColumnInBillingSubscriptionItemTable.ts | 20 +++++
.../billing/billing.controller.ts | 14 ++++
.../core-modules/billing/billing.module.ts | 4 +
.../billing-subscription-item.entity.ts | 3 +
.../enums/billing-subscription-status.enum.ts | 2 +-
.../enums/billing-webhook-events.enum.ts | 2 +
.../billing/services/billing-plan.service.ts | 8 +-
.../billing-portal.workspace-service.ts | 11 +--
.../services/billing-subscription.service.ts | 47 ------------
.../services/stripe-checkout.service.ts | 2 +-
.../stripe-subscription-item.service.ts | 12 ---
.../services/stripe-subscription.service.ts | 7 --
.../services/billing-webhook-alert.service.ts | 68 +++++++++++++++++
.../billing-webhook-invoice.service.ts | 35 +++++++++
.../billing-webhook-subscription.service.ts | 76 +++++++------------
16 files changed, 185 insertions(+), 132 deletions(-)
create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/billing/1743577268972-addHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service.ts
diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
index d9d0a356e..4d2a8426a 100644
--- a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
@@ -6,9 +6,9 @@ import { InformationBannerReconnectAccountInsufficientPermissions } from '@/info
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
-import { SubscriptionStatus } from '~/generated-metadata/graphql';
-import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { isDefined } from 'twenty-shared/utils';
+import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
+import { SubscriptionStatus } from '~/generated-metadata/graphql';
const StyledInformationBannerWrapper = styled.div`
height: 40px;
@@ -40,7 +40,7 @@ export const InformationBannerWrapper = () => {
{displayBillingSubscriptionPausedBanner && (
-
+ // TODO: remove this once paused subscriptions are deprecated
)}
{displayBillingSubscriptionCanceledBanner && (
diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/billing/1743577268972-addHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/billing/1743577268972-addHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable.ts
new file mode 100644
index 000000000..68d6b6f20
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/core/migrations/billing/1743577268972-addHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable.ts
@@ -0,0 +1,20 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable1743577268972
+ implements MigrationInterface
+{
+ name =
+ 'AddHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable1743577268972';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscriptionItem" ADD "hasReachedCurrentPeriodCap" boolean NOT NULL DEFAULT false`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscriptionItem" DROP COLUMN "hasReachedCurrentPeriodCap"`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts
index d2ce7454c..f1cb55607 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts
@@ -22,7 +22,9 @@ import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billi
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service';
+import { BillingWebhookAlertService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service';
+import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service';
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service';
@@ -38,6 +40,8 @@ export class BillingController {
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingWebhookProductService: BillingWebhookProductService,
private readonly billingWebhookPriceService: BillingWebhookPriceService,
+ private readonly billingWebhookAlertService: BillingWebhookAlertService,
+ private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
) {}
@Post('/webhooks')
@@ -100,6 +104,16 @@ export class BillingController {
event.data,
);
+ case BillingWebhookEvent.ALERT_TRIGGERED:
+ return await this.billingWebhookAlertService.processStripeEvent(
+ event.data,
+ );
+
+ case BillingWebhookEvent.INVOICE_FINALIZED:
+ return await this.billingWebhookInvoiceService.processStripeEvent(
+ event.data,
+ );
+
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED:
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED:
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED: {
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts
index d0a7b3e4d..4ff9ae3d1 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts
@@ -24,7 +24,9 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
+import { BillingWebhookAlertService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service';
+import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service';
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service';
@@ -73,6 +75,8 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
BillingService,
BillingWebhookProductService,
BillingWebhookPriceService,
+ BillingWebhookAlertService,
+ BillingWebhookInvoiceService,
BillingRestApiExceptionFilter,
BillingSyncCustomerDataCommand,
BillingSyncPlansDataCommand,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts
index c2d3e4cc2..1fdfcb6e0 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts
@@ -63,4 +63,7 @@ export class BillingSubscriptionItem {
@Column({ nullable: true, type: 'numeric' })
quantity: number | null;
+
+ @Column({ type: 'boolean', default: false })
+ hasReachedCurrentPeriodCap: boolean;
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts
index aa411d3bf..c3a51cdbe 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts
@@ -6,7 +6,7 @@ export enum SubscriptionStatus {
Incomplete = 'incomplete',
IncompleteExpired = 'incomplete_expired',
PastDue = 'past_due',
- Paused = 'paused',
+ Paused = 'paused', // TODO: remove this once paused subscriptions are deprecated
Trialing = 'trialing',
Unpaid = 'unpaid',
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts
index e8bbb746e..713cc5d42 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts
@@ -10,4 +10,6 @@ export enum BillingWebhookEvent {
PRODUCT_UPDATED = 'product.updated',
PRICE_CREATED = 'price.created',
PRICE_UPDATED = 'price.updated',
+ ALERT_TRIGGERED = 'billing.alert.triggered',
+ INVOICE_FINALIZED = 'invoice.finalized',
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts
index 178296857..c455cf6ca 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts
@@ -3,7 +3,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { JsonContains, Repository } from 'typeorm';
import {
BillingException,
@@ -35,11 +35,11 @@ export class BillingPlanService {
}): Promise {
const products = await this.billingProductRepository.find({
where: {
- metadata: {
- planKey,
+ metadata: JsonContains({
priceUsageBased,
+ planKey,
isBaseProduct,
- },
+ }),
active: true,
},
relations: ['billingPrices'],
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts
index 97057c2bd..dad9b8706 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts
@@ -65,7 +65,6 @@ export class BillingPortalWorkspaceService {
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
- forTrialSubscription: !isDefined(subscription),
});
const checkoutSession =
@@ -128,11 +127,9 @@ export class BillingPortalWorkspaceService {
private getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
- forTrialSubscription,
}: {
quantity: number;
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
- forTrialSubscription: boolean;
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
if (billingPricesPerPlan) {
return [
@@ -140,11 +137,9 @@ export class BillingPortalWorkspaceService {
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
quantity,
},
- ...(forTrialSubscription
- ? []
- : billingPricesPerPlan.meteredProductsPrices.map((price) => ({
- price: price.stripePriceId,
- }))),
+ ...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
+ price: price.stripePriceId,
+ })),
];
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
index 01bc4cbe1..21accb26e 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
@@ -20,7 +20,6 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
-import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
@@ -199,50 +198,4 @@ export class BillingSubscriptionService {
return subscriptionItemsToUpdate;
}
-
- async convertTrialSubscriptionToSubscriptionWithMeteredProducts(
- billingSubscription: BillingSubscription,
- ) {
- const meteredProducts = (
- await this.billingProductRepository.find({
- where: {
- active: true,
- },
- relations: ['billingPrices'],
- })
- ).filter(
- (product) =>
- product.metadata.priceUsageBased === BillingUsageType.METERED,
- );
-
- // subscription update to enable metered product billing
- await this.stripeSubscriptionService.updateSubscription(
- billingSubscription.stripeSubscriptionId,
- {
- trial_settings: {
- end_behavior: {
- missing_payment_method: 'cancel',
- },
- },
- },
- );
-
- for (const meteredProduct of meteredProducts) {
- const meteredProductPrice = meteredProduct.billingPrices.find(
- (price) => price.active,
- );
-
- if (!meteredProductPrice) {
- throw new BillingException(
- `Cannot find active price for product ${meteredProduct.id}`,
- BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
- );
- }
-
- await this.stripeSubscriptionItemService.createSubscriptionItem(
- billingSubscription.stripeSubscriptionId,
- meteredProductPrice.stripePriceId,
- );
- }
- }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts
index 57b9f9199..e143aa43d 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts
@@ -64,7 +64,7 @@ export class StripeCheckoutService {
),
trial_settings: {
end_behavior: {
- missing_payment_method: 'pause',
+ missing_payment_method: 'create_invoice',
},
},
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts
index ee730a97b..1f7a40b40 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts
@@ -27,16 +27,4 @@ export class StripeSubscriptionItemService {
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
}
-
- async createSubscriptionItem(
- stripeSubscriptionId: string,
- stripePriceId: string,
- quantity?: number | undefined,
- ) {
- await this.stripe.subscriptionItems.create({
- subscription: stripeSubscriptionId,
- price: stripePriceId,
- quantity,
- });
- }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts
index 7b279f61a..0462a62e5 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts
@@ -76,11 +76,4 @@ export class StripeSubscriptionService {
items: stripeSubscriptionItemsToUpdate,
});
}
-
- async updateSubscription(
- stripeSubscriptionId: string,
- data: Stripe.SubscriptionUpdateParams,
- ) {
- await this.stripe.subscriptions.update(stripeSubscriptionId, data);
- }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts
new file mode 100644
index 000000000..b6f2b9ce8
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts
@@ -0,0 +1,68 @@
+/* @license Enterprise */
+
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import Stripe from 'stripe';
+import { isDefined } from 'twenty-shared/utils';
+import { Repository } from 'typeorm';
+
+import {
+ BillingException,
+ BillingExceptionCode,
+} from 'src/engine/core-modules/billing/billing.exception';
+import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
+import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
+import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
+import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
+
+const TRIAL_PERIOD_ALERT_TITLE = 'TRIAL_PERIOD_ALERT'; // to set in Stripe config
+
+@Injectable()
+export class BillingWebhookAlertService {
+ protected readonly logger = new Logger(BillingWebhookAlertService.name);
+ constructor(
+ @InjectRepository(BillingSubscription, 'core')
+ private readonly billingSubscriptionRepository: Repository,
+ @InjectRepository(BillingProduct, 'core')
+ private readonly billingProductRepository: Repository,
+ @InjectRepository(BillingSubscriptionItem, 'core')
+ private readonly billingSubscriptionItemRepository: Repository,
+ ) {}
+
+ async processStripeEvent(data: Stripe.BillingAlertTriggeredEvent.Data) {
+ const { customer: stripeCustomerId, alert } = data.object;
+
+ const stripeMeterId = alert.usage_threshold?.meter as string | undefined;
+
+ if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) {
+ const subscription = await this.billingSubscriptionRepository.findOne({
+ where: { stripeCustomerId, status: SubscriptionStatus.Trialing },
+ relations: ['billingSubscriptionItems'],
+ });
+
+ if (!subscription) return;
+
+ const product = await this.billingProductRepository.findOne({
+ where: {
+ billingPrices: { stripeMeterId },
+ },
+ });
+
+ if (!product) {
+ throw new BillingException(
+ `Product associated to meter ${stripeMeterId} not found`,
+ BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
+ );
+ }
+
+ await this.billingSubscriptionItemRepository.update(
+ {
+ billingSubscriptionId: subscription.id,
+ stripeProductId: product.stripeProductId,
+ },
+ { hasReachedCurrentPeriodCap: true },
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service.ts
new file mode 100644
index 000000000..5daaa34bc
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service.ts
@@ -0,0 +1,35 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import Stripe from 'stripe';
+import { isDefined } from 'twenty-shared/utils';
+import { Repository } from 'typeorm';
+
+import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
+
+const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle';
+
+@Injectable()
+export class BillingWebhookInvoiceService {
+ protected readonly logger = new Logger(BillingWebhookInvoiceService.name);
+ constructor(
+ @InjectRepository(BillingSubscriptionItem, 'core')
+ private readonly billingSubscriptionItemRepository: Repository,
+ ) {}
+
+ async processStripeEvent(data: Stripe.InvoiceFinalizedEvent.Data) {
+ const { billing_reason: billingReason, subscription } = data.object;
+
+ const stripeSubscriptionId = subscription as string | undefined;
+
+ if (
+ isDefined(stripeSubscriptionId) &&
+ billingReason === SUBSCRIPTION_CYCLE_BILLING_REASON
+ ) {
+ await this.billingSubscriptionItemRepository.update(
+ { stripeSubscriptionId },
+ { hasReachedCurrentPeriodCap: false },
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts
index 655c2c2b9..e3ad473be 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts
@@ -13,12 +13,10 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
-import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
-import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
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';
@@ -29,19 +27,6 @@ import {
CleanWorkspaceDeletionWarningUserVarsJobData,
} from 'src/engine/workspace-manager/workspace-cleaner/jobs/clean-workspace-deletion-warning-user-vars.job';
-const BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS = {
- [WorkspaceActivationStatus.ACTIVE]: [
- SubscriptionStatus.Active,
- SubscriptionStatus.Trialing,
- SubscriptionStatus.PastDue,
- ],
- [WorkspaceActivationStatus.SUSPENDED]: [
- SubscriptionStatus.Canceled,
- SubscriptionStatus.Unpaid,
- SubscriptionStatus.Paused,
- ],
-};
-
@Injectable()
export class BillingWebhookSubscriptionService {
protected readonly logger = new Logger(
@@ -61,7 +46,6 @@ export class BillingWebhookSubscriptionService {
private readonly billingCustomerRepository: Repository,
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository,
- private readonly billingSubscriptionService: BillingSubscriptionService,
) {}
async processStripeEvent(
@@ -114,13 +98,6 @@ export class BillingWebhookSubscriptionService {
throw new Error('Billing subscription not found');
}
- const hasActiveWorkspaceCompatibleSubscription = billingSubscriptions.some(
- (subscription) =>
- BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
- WorkspaceActivationStatus.ACTIVE
- ].includes(subscription.status),
- );
-
await this.billingSubscriptionItemRepository.upsert(
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
updatedBillingSubscription.id,
@@ -132,30 +109,9 @@ export class BillingWebhookSubscriptionService {
},
);
- const wasTrialOrPausedSubscription = [
- SubscriptionStatus.Trialing,
- SubscriptionStatus.Paused,
- ].includes(data.previous_attributes?.status as SubscriptionStatus);
-
- const isMeteredProductBillingEnabled =
- await this.featureFlagRepository.findOneBy({
- key: FeatureFlagKey.IsMeteredProductBillingEnabled,
- workspaceId,
- value: true,
- });
-
- if (wasTrialOrPausedSubscription && isMeteredProductBillingEnabled) {
- await this.billingSubscriptionService.convertTrialSubscriptionToSubscriptionWithMeteredProducts(
- updatedBillingSubscription,
- );
- }
-
if (
- BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
- WorkspaceActivationStatus.SUSPENDED
- ].includes(data.object.status as SubscriptionStatus) &&
- workspace.activationStatus == WorkspaceActivationStatus.ACTIVE &&
- !hasActiveWorkspaceCompatibleSubscription
+ this.shouldSuspendWorkspace(data) &&
+ workspace.activationStatus == WorkspaceActivationStatus.ACTIVE
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
@@ -163,9 +119,7 @@ export class BillingWebhookSubscriptionService {
}
if (
- BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
- WorkspaceActivationStatus.ACTIVE
- ].includes(data.object.status as SubscriptionStatus) &&
+ !this.shouldSuspendWorkspace(data) &&
workspace.activationStatus == WorkspaceActivationStatus.SUSPENDED
) {
await this.workspaceRepository.update(workspaceId, {
@@ -188,4 +142,28 @@ export class BillingWebhookSubscriptionService {
stripeCustomerId: data.object.customer,
};
}
+
+ shouldSuspendWorkspace(
+ data:
+ | Stripe.CustomerSubscriptionUpdatedEvent.Data
+ | Stripe.CustomerSubscriptionCreatedEvent.Data
+ | Stripe.CustomerSubscriptionDeletedEvent.Data,
+ ) {
+ const timeSinceTrialEnd = Date.now() / 1000 - (data.object.trial_end || 0);
+ const hasTrialJustEnded =
+ timeSinceTrialEnd < 60 * 60 * 24 && timeSinceTrialEnd > 0;
+
+ if (
+ [
+ SubscriptionStatus.Canceled,
+ SubscriptionStatus.Unpaid,
+ SubscriptionStatus.Paused, // TODO: remove this once paused subscriptions are deprecated
+ ].includes(data.object.status as SubscriptionStatus) ||
+ (hasTrialJustEnded && data.object.status === SubscriptionStatus.PastDue)
+ ) {
+ return true;
+ }
+
+ return false;
+ }
}