feat: enforce credit limits via Stripe alerts for all billing scenarios (#16801)
## Summary This PR ensures usage alerts are created for all billing scenarios. ## Background Per [Stripe documentation](https://docs.stripe.com/billing/subscriptions/usage-based/alerts), usage alerts are **one-time per customer** - they trigger once and only consider usage reported after the alert is created. This means we need to create a new alert whenever: 1. ✅ Subscription is created (trial) - Already implemented 2. ✅ Trial ends (user becomes Active subscriber) - **Added in this PR** 3. ✅ Credit tier changes (upgrade) - **Added in this PR** 4. ✅ **New billing cycle starts** - **Critical fix in this PR!** ## The Issue Previously, the invoice webhook reset `hasReachedCurrentPeriodCap = false` at cycle end, but didn't create a new alert. This meant after the first billing period, there was no alert to trigger and users could exceed their limit without being blocked. ## Changes ### 1. Invoice Webhook (`billing-webhook-invoice.service.ts`) When invoice is finalized for `subscription_cycle`: - Reset `hasReachedCurrentPeriodCap = false` ✅ (already done) - **NEW**: Create alert at the current tier cap ### 2. End Trial Period (`billing-subscription.service.ts`) In `endTrialPeriod()`: - **NEW**: Create alert at the paid tier cap (not trial cap) ### 3. Credit Tier Upgrades (`billing-subscription.service.ts`) In `changeMeteredPrice()`, when upgrading immediately (not scheduled for period end): - **NEW**: Reset `hasReachedCurrentPeriodCap = false` - **NEW**: Create alert at new tier cap ### 4. Alert Title Update (`stripe-billing-alert.service.ts`) Changed from "Trial usage cap" to "Usage cap" since alerts are now used for all scenarios. ## Stripe Alert Limits - Max 25 alerts per meter+customer combination - Alerts only evaluate usage reported after creation - One-time alerts trigger once per customer With monthly billing cycles + occasional tier changes, we should stay well under the 25 alert limit. ## Testing - TypeScript typechecking passes - Backend services properly inject new dependencies
This commit is contained in:
@@ -52,7 +52,6 @@ export const AIChatCreditsExhaustedMessage = () => {
|
||||
const result = await endTrialPeriod();
|
||||
setIsProcessing(false);
|
||||
|
||||
// If no payment method, redirect to billing portal to add one
|
||||
if (!result.success) {
|
||||
openBillingPortal();
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
const hasError = isDefined(errorText);
|
||||
const isExpandable = isDefined(output) || hasError;
|
||||
|
||||
// Special handling for code_interpreter tool
|
||||
if (toolName === 'code_interpreter') {
|
||||
const codeInput = toolInput as { code?: string } | undefined;
|
||||
const codeOutput = output as {
|
||||
|
||||
+15
-2
@@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
|
||||
const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle';
|
||||
|
||||
@@ -16,12 +17,18 @@ export class BillingWebhookInvoiceService {
|
||||
constructor(
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
) {}
|
||||
|
||||
async processStripeEvent(data: Stripe.InvoiceFinalizedEvent.Data) {
|
||||
const { billing_reason: billingReason, subscription } = data.object;
|
||||
const {
|
||||
billing_reason: billingReason,
|
||||
subscription,
|
||||
customer,
|
||||
} = data.object;
|
||||
|
||||
const stripeSubscriptionId = subscription as string | undefined;
|
||||
const stripeCustomerId = customer as string | undefined;
|
||||
|
||||
if (
|
||||
isDefined(stripeSubscriptionId) &&
|
||||
@@ -31,6 +38,12 @@ export class BillingWebhookInvoiceService {
|
||||
{ stripeSubscriptionId },
|
||||
{ hasReachedCurrentPeriodCap: false },
|
||||
);
|
||||
|
||||
if (isDefined(stripeCustomerId)) {
|
||||
await this.billingSubscriptionService.createBillingAlertForCustomer(
|
||||
stripeCustomerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -20,6 +20,7 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
|
||||
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
||||
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@@ -164,6 +165,12 @@ describe('BillingSubscriptionService', () => {
|
||||
provide: getRepositoryToken(BillingCustomerEntity),
|
||||
useValue: repoMock<BillingCustomerEntity>(),
|
||||
},
|
||||
{
|
||||
provide: StripeBillingAlertService,
|
||||
useValue: {
|
||||
createUsageThresholdAlertForCustomerMeter: jest.fn(),
|
||||
},
|
||||
},
|
||||
BillingPriceService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
+107
-2
@@ -39,6 +39,7 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil
|
||||
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
|
||||
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
|
||||
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
|
||||
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.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 { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
@@ -81,6 +82,7 @@ export class BillingSubscriptionService {
|
||||
private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingSubscriptionEntity>,
|
||||
private readonly stripeBillingAlertService: StripeBillingAlertService,
|
||||
) {}
|
||||
|
||||
async getBillingSubscriptions(workspaceId: string) {
|
||||
@@ -221,10 +223,33 @@ export class BillingSubscriptionService {
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, {
|
||||
const subscriptionUpdate = {
|
||||
type: SubscriptionUpdateType.METERED_PRICE,
|
||||
newMeteredPriceId: meteredPriceId,
|
||||
});
|
||||
} as const;
|
||||
|
||||
const isScheduledForPeriodEnd =
|
||||
await this.shouldUpdateAtSubscriptionPeriodEnd(
|
||||
billingSubscription,
|
||||
subscriptionUpdate,
|
||||
);
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
|
||||
|
||||
if (!isScheduledForPeriodEnd) {
|
||||
await this.billingSubscriptionItemRepository.update(
|
||||
{ stripeSubscriptionId: billingSubscription.stripeSubscriptionId },
|
||||
{ hasReachedCurrentPeriodCap: false },
|
||||
);
|
||||
|
||||
const newTierCap =
|
||||
await this.getWorkflowTierCapFromPriceId(meteredPriceId);
|
||||
|
||||
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
|
||||
billingSubscription.stripeCustomerId,
|
||||
newTierCap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSwitchMeteredPrice(workspace: WorkspaceEntity): Promise<void> {
|
||||
@@ -274,6 +299,10 @@ export class BillingSubscriptionService {
|
||||
{ hasReachedCurrentPeriodCap: false },
|
||||
);
|
||||
|
||||
await this.createBillingAlertForCustomer(
|
||||
billingSubscription.stripeCustomerId,
|
||||
);
|
||||
|
||||
return {
|
||||
status: getSubscriptionStatus(updatedSubscription.status),
|
||||
hasPaymentMethod: true,
|
||||
@@ -513,6 +542,82 @@ export class BillingSubscriptionService {
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkflowTierCapForSubscription(
|
||||
subscriptionId: string,
|
||||
): Promise<number> {
|
||||
const subscription = await this.billingSubscriptionRepository.findOneOrFail(
|
||||
{
|
||||
where: { id: subscriptionId },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
'billingSubscriptionItems.billingProduct.billingPrices',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const workflowItem = subscription.billingSubscriptionItems.find(
|
||||
(item) =>
|
||||
item.billingProduct.metadata.productKey ===
|
||||
BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
);
|
||||
|
||||
if (!isDefined(workflowItem)) {
|
||||
throw new BillingException(
|
||||
'Workflow subscription item not found',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const matchingPrice = workflowItem.billingProduct.billingPrices.find(
|
||||
(price) => price.stripePriceId === workflowItem.stripePriceId,
|
||||
);
|
||||
|
||||
if (!isDefined(matchingPrice)) {
|
||||
throw new BillingException(
|
||||
`Cannot find price for product ${workflowItem.stripeProductId}`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
billingValidator.assertIsMeteredTiersSchemaOrThrow(matchingPrice.tiers);
|
||||
|
||||
return matchingPrice.tiers[0].up_to;
|
||||
}
|
||||
|
||||
async getWorkflowTierCapFromPriceId(meteredPriceId: string): Promise<number> {
|
||||
const price = await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: meteredPriceId },
|
||||
});
|
||||
|
||||
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
|
||||
|
||||
return price.tiers[0].up_to;
|
||||
}
|
||||
|
||||
async createBillingAlertForCustomer(stripeCustomerId: string): Promise<void> {
|
||||
const subscription = await this.getCurrentBillingSubscription({
|
||||
stripeCustomerId,
|
||||
});
|
||||
|
||||
if (!isDefined(subscription)) {
|
||||
this.logger.warn(
|
||||
`Cannot create billing alert: subscription not found for stripeCustomerId ${stripeCustomerId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tierCap = await this.getWorkflowTierCapForSubscription(
|
||||
subscription.id,
|
||||
);
|
||||
|
||||
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
|
||||
stripeCustomerId,
|
||||
tierCap,
|
||||
);
|
||||
}
|
||||
|
||||
private async runSubscriptionUpdate({
|
||||
stripeSubscriptionId,
|
||||
licensedStripeItemId,
|
||||
|
||||
+42
-3
@@ -7,6 +7,7 @@ import type Stripe from 'stripe';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
|
||||
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,6 +19,7 @@ export class StripeBillingAlertService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly stripeSDKService: StripeSDKService,
|
||||
private readonly stripeBillingMeterService: StripeBillingMeterService,
|
||||
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
|
||||
) {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
return;
|
||||
@@ -29,7 +31,7 @@ export class StripeBillingAlertService {
|
||||
|
||||
async createUsageThresholdAlertForCustomerMeter(
|
||||
customerId: string,
|
||||
gte: number,
|
||||
tierCap: number,
|
||||
): Promise<void> {
|
||||
const meter = (await this.stripeBillingMeterService.getAllMeters()).find(
|
||||
(meter) => {
|
||||
@@ -39,11 +41,21 @@ export class StripeBillingAlertService {
|
||||
|
||||
assertIsDefinedOrThrow(meter);
|
||||
|
||||
await this.archiveAlertsForCustomer(customerId, meter.id);
|
||||
|
||||
const cumulativeUsage =
|
||||
await this.stripeBillingMeterEventService.getTotalCumulativeUsage(
|
||||
meter.id,
|
||||
customerId,
|
||||
);
|
||||
|
||||
const dynamicThreshold = cumulativeUsage + tierCap;
|
||||
|
||||
await this.stripe.billing.alerts.create({
|
||||
alert_type: 'usage_threshold',
|
||||
title: `Trial usage cap for customer ${customerId}`,
|
||||
title: `Usage cap for customer ${customerId}`,
|
||||
usage_threshold: {
|
||||
gte,
|
||||
gte: dynamicThreshold,
|
||||
meter: meter.id,
|
||||
recurrence: 'one_time',
|
||||
filters: [
|
||||
@@ -54,5 +66,32 @@ export class StripeBillingAlertService {
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Created billing alert for customer ${customerId}: threshold=${dynamicThreshold} (cumulative=${cumulativeUsage} + tierCap=${tierCap})`,
|
||||
);
|
||||
}
|
||||
|
||||
private async archiveAlertsForCustomer(
|
||||
customerId: string,
|
||||
meterId: string,
|
||||
): Promise<void> {
|
||||
const alerts = await this.stripe.billing.alerts.list({
|
||||
meter: meterId,
|
||||
});
|
||||
|
||||
const customerAlerts = alerts.data.filter(
|
||||
(alert) =>
|
||||
alert.status === 'active' &&
|
||||
alert.usage_threshold?.filters?.some(
|
||||
(filter) =>
|
||||
filter.type === 'customer' && filter.customer === customerId,
|
||||
),
|
||||
);
|
||||
|
||||
for (const alert of customerAlerts) {
|
||||
await this.stripe.billing.alerts.archive(alert.id);
|
||||
this.logger.log(`Archived alert ${alert.id} for customer ${customerId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -79,4 +79,19 @@ export class StripeBillingMeterEventService {
|
||||
return acc + eventSummary.aggregated_value;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async getTotalCumulativeUsage(
|
||||
stripeMeterId: string,
|
||||
stripeCustomerId: string,
|
||||
): Promise<number> {
|
||||
const startTime = new Date(0);
|
||||
const endTime = new Date();
|
||||
|
||||
return this.sumMeterEvents(
|
||||
stripeMeterId,
|
||||
stripeCustomerId,
|
||||
startTime,
|
||||
endTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user