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:
Félix Malfait
2025-12-24 16:32:31 +01:00
committed by GitHub
parent 66daf69a5d
commit 6a4974e285
7 changed files with 186 additions and 9 deletions
@@ -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 {
@@ -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,
);
}
}
}
}
@@ -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();
@@ -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,
@@ -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}`);
}
}
}
@@ -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,
);
}
}