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; + } }