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
@@ -15,7 +15,6 @@ import {
import { type Response } from 'express';
import Stripe from 'stripe';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -46,7 +45,6 @@ export class BillingWebhookController {
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingWebhookProductService: BillingWebhookProductService,
private readonly billingWebhookPriceService: BillingWebhookPriceService,
private readonly billingWebhookAlertService: BillingWebhookAlertService,
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
private readonly billingWebhookCustomerService: BillingWebhookCustomerService,
private readonly billingWebhookSubscriptionScheduleService: BillingWebhookSubscriptionScheduleService,
@@ -118,11 +116,6 @@ export class BillingWebhookController {
event.data,
);
case BillingWebhookEvent.ALERT_TRIGGERED:
return await this.billingWebhookAlertService.processStripeEvent(
event.data,
);
case BillingWebhookEvent.INVOICE_FINALIZED:
case BillingWebhookEvent.INVOICE_PAID:
return await this.billingWebhookInvoiceService.processStripeEvent(
@@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -58,7 +57,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
providers: [
BillingWebhookProductService,
BillingWebhookPriceService,
BillingWebhookAlertService,
BillingWebhookInvoiceService,
BillingWebhookCustomerService,
BillingWebhookSubscriptionService,
@@ -1,68 +0,0 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.entity';
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';
@Injectable()
export class BillingWebhookAlertService {
protected readonly logger = new Logger(BillingWebhookAlertService.name);
constructor(
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(BillingProductEntity)
private readonly billingProductRepository: Repository<BillingProductEntity>,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
) {}
async processStripeEvent(data: Stripe.BillingAlertTriggeredEvent.Data) {
const { customer: stripeCustomerId, alert } = data.object;
const stripeMeterId = alert.usage_threshold?.meter;
assertIsDefinedOrThrow(stripeMeterId);
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ stripeCustomerId },
);
const product = await this.billingProductRepository.findOne({
where: {
billingPrices: {
stripeMeterId:
typeof stripeMeterId === 'string'
? stripeMeterId
: stripeMeterId.id,
},
},
});
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 },
);
}
}
@@ -23,9 +23,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
import { BillingSubscriptionEntity } 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 { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.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 { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -57,10 +55,8 @@ export class BillingWebhookSubscriptionService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly workspaceService: WorkspaceService,
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
private readonly stripeBillingAlertService: StripeBillingAlertService,
private readonly billingUsageService: BillingUsageService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -153,10 +149,7 @@ export class BillingWebhookSubscriptionService {
if (shouldSuspend) {
if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
suspendedAt: new Date(),
});
await this.workspaceService.suspendWorkspace(workspaceId);
} else if (
workspace.activationStatus ===
WorkspaceActivationStatus.PENDING_CREATION