fix(billing) - fix orphaned stripe subs (#20814)

Fix sentry issues
https://twenty-v7.sentry.io/issues/7203797925/?environment=prod&project=4507072499810304&query=is%3Aunresolved%20assigned%3Ame&referrer=issue-stream

An orphaned sub is a not "canceled" stripe sub with no matching
workspace

- Clean all orphaned sub (script not included in this PR)
- Ensure to soft delete > cancel stripe sub > check for not active sub >
hard delete in every workspace deletion flow

Bonus : 
- Remove dead code
- Update doc on RLS (to improve AI chat knowledge)
This commit is contained in:
Etienne
2026-05-21 19:02:54 +02:00
committed by GitHub
parent 3c91f3f276
commit 0edd8d400c
15 changed files with 109 additions and 256 deletions
@@ -32,6 +32,7 @@ export enum BillingExceptionCode {
BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND',
BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND',
BILLING_CREDITS_EXHAUSTED = 'BILLING_CREDITS_EXHAUSTED',
BILLING_SUBSCRIPTION_NOT_CANCELED = 'BILLING_SUBSCRIPTION_NOT_CANCELED',
}
const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => {
@@ -86,6 +87,8 @@ const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => {
return msg`Multiple subscriptions found where one was expected.`;
case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
return msg`You have exhausted your credits. Please upgrade your plan to continue.`;
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED:
return msg`Workspace cannot be deleted: subscription is not yet canceled.`;
default:
assertUnreachable(code);
}
@@ -141,17 +141,29 @@ export class BillingSubscriptionService {
return billingSubscriptionItem;
}
async deleteSubscriptions(workspaceId: string) {
const subscriptionToCancel = await this.getCurrentBillingSubscription({
async cancelSubscription(workspaceId: string): Promise<void> {
const subscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (isDefined(subscriptionToCancel)) {
if (isDefined(subscription)) {
await this.stripeSubscriptionService.cancelSubscription(
subscriptionToCancel.stripeSubscriptionId,
subscription.stripeSubscriptionId,
);
}
}
async assertSubscriptionCanceledOrNone(workspaceId: string): Promise<void> {
const activeSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (isDefined(activeSubscription)) {
throw new BillingException(
`Subscription for workspace ${workspaceId} is not canceled`,
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED,
);
}
await this.billingSubscriptionRepository.delete({ workspaceId });
}
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
@@ -1,101 +0,0 @@
import { Injectable } from '@nestjs/common';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { STRIPE_BILLING_METER_EVENT_NAME } from 'src/engine/core-modules/billing/stripe/constants/stripe-billing-meter-event-name.constant';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class StripeBillingAlertService {
private readonly stripe: Stripe;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSDKService: StripeSDKService,
private readonly stripeBillingMeterService: StripeBillingMeterService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
) {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.twentyConfigService.get('BILLING_STRIPE_API_KEY'),
);
}
async createUsageThresholdAlertForCustomerMeter(
customerId: string,
tierCap: number,
creditBalance: number = 0,
periodStart?: Date,
): Promise<void> {
const meter = (await this.stripeBillingMeterService.getAllMeters()).find(
(meterItem) => {
return meterItem.event_name === STRIPE_BILLING_METER_EVENT_NAME;
},
);
assertIsDefinedOrThrow(meter);
await this.archiveAlertsForCustomer(customerId, meter.id);
// Use cumulative usage at period start to ensure consistent threshold
// regardless of when the alert is created/recreated during the period
const usageAtPeriodStart = periodStart
? await this.stripeBillingMeterEventService.getCumulativeUsageAtTime(
meter.id,
customerId,
periodStart,
)
: await this.stripeBillingMeterEventService.getTotalCumulativeUsage(
meter.id,
customerId,
);
// Threshold = usage at period start + allowance for this period
const dynamicThreshold = usageAtPeriodStart + tierCap + creditBalance;
await this.stripe.billing.alerts.create({
alert_type: 'usage_threshold',
title: `Usage cap for customer ${customerId}`,
usage_threshold: {
gte: dynamicThreshold,
meter: meter.id,
recurrence: 'one_time',
filters: [
{
type: 'customer',
customer: customerId,
},
],
},
});
}
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);
}
}
}
@@ -3,7 +3,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
@@ -39,7 +38,6 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
StripePriceService,
StripeProductService,
StripeBillingMeterEventService,
StripeBillingAlertService,
StripeCreditGrantService,
StripeInvoiceService,
],
@@ -55,7 +53,6 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
StripeProductService,
StripeBillingMeterEventService,
StripeSubscriptionScheduleService,
StripeBillingAlertService,
StripeCreditGrantService,
StripeInvoiceService,
],
@@ -39,6 +39,7 @@ export const getBillingExceptionStatusCode = (
case BillingExceptionCode.BILLING_PRICE_INVALID:
case BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND:
case BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED:
return 500;
default: {
return assertUnreachable(exception.code);