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
This commit is contained in:
Etienne
2025-04-03 13:44:32 +02:00
committed by GitHub
parent 144a326709
commit cfae440a02
16 changed files with 185 additions and 132 deletions

View File

@ -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 = () => {
<InformationBannerReconnectAccountInsufficientPermissions />
<InformationBannerReconnectAccountEmailAliases />
{displayBillingSubscriptionPausedBanner && (
<InformationBannerBillingSubscriptionPaused />
<InformationBannerBillingSubscriptionPaused /> // TODO: remove this once paused subscriptions are deprecated
)}
{displayBillingSubscriptionCanceledBanner && (
<InformationBannerNoBillingSubscription />

View File

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable1743577268972
implements MigrationInterface
{
name =
'AddHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable1743577268972';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD "hasReachedCurrentPeriodCap" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP COLUMN "hasReachedCurrentPeriodCap"`,
);
}
}

View File

@ -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: {

View File

@ -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,

View File

@ -63,4 +63,7 @@ export class BillingSubscriptionItem {
@Column({ nullable: true, type: 'numeric' })
quantity: number | null;
@Column({ type: 'boolean', default: false })
hasReachedCurrentPeriodCap: boolean;
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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<BillingProduct[]> {
const products = await this.billingProductRepository.find({
where: {
metadata: {
planKey,
metadata: JsonContains({
priceUsageBased,
planKey,
isBaseProduct,
},
}),
active: true,
},
relations: ['billingPrices'],

View File

@ -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,
})),
];
}

View File

@ -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,
);
}
}
}

View File

@ -64,7 +64,7 @@ export class StripeCheckoutService {
),
trial_settings: {
end_behavior: {
missing_payment_method: 'pause',
missing_payment_method: 'create_invoice',
},
},
}

View File

@ -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,
});
}
}

View File

@ -76,11 +76,4 @@ export class StripeSubscriptionService {
items: stripeSubscriptionItemsToUpdate,
});
}
async updateSubscription(
stripeSubscriptionId: string,
data: Stripe.SubscriptionUpdateParams,
) {
await this.stripe.subscriptions.update(stripeSubscriptionId, data);
}
}

View File

@ -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<BillingSubscription>,
@InjectRepository(BillingProduct, 'core')
private readonly billingProductRepository: Repository<BillingProduct>,
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
) {}
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 },
);
}
}
}

View File

@ -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<BillingSubscriptionItem>,
) {}
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 },
);
}
}
}

View File

@ -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<BillingCustomer>,
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>,
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;
}
}