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:
@@ -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);
|
||||
}
|
||||
|
||||
+17
-5
@@ -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) {
|
||||
|
||||
-101
@@ -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,
|
||||
],
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user