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