fix(billing): don't crash when workspace has no active subscription (#21510)
## Problem
Sentry (high severity, SLA-breaching): `Billing Subscription Not Found:
No active subscription found for workspace …`
The `billingSubscription` workspace-cache provider
(`WorkspaceBillingSubscriptionCacheService.computeForCache`) called
`getCurrentBillingSubscriptionOrThrow`. For a workspace whose
subscription is fully canceled, `getCurrentBillingSubscription` filters
out `Canceled` and returns `undefined`, so the provider **threw**
`BILLING_SUBSCRIPTION_NOT_FOUND`.
That cache key is read on every usage-recording path:
- workflow execution
(`WorkflowExecutorWorkspaceService.sendWorkflowNodeRunEvent`)
- AI usage (`AiBillingService`)
- logic-function execution (`LogicFunctionExecutorService`)
- app charges (`AppBillingService`)
- the gate `BillingUsageService.canFeatureBeUsed` /
`hasAvailableCredits` / `decrementAvailableCreditsInCache`
- the cancellation webhook
(`invalidateAndRecompute('billingSubscription')`)
So any of these throws an unhandled exception for a
no-active-subscription workspace. The intent was clearly to tolerate
this state — `canFeatureBeUsed` already guards with
`isDefined(billingSubscription)` and the workflow runner logs *"there is
no subscription for this workspace"* — but the throwing provider made
those guards unreachable.
## Fix
- `computeForCache` now returns `FlatBillingSubscription | null` via the
non-throwing `getCurrentBillingSubscription`, and the cache type allows
`null`.
- Every consumer guards the absent case (`isDefined` / optional
chaining) and no-ops: usage events still emit with an undefined
`periodStart`, credits aren't decremented, `hasAvailableCredits` returns
`false`.
- `getCurrentBillingSubscriptionOrThrow` is **left untouched** for the
many callers (resolver, subscription-update, etc.) that genuinely
require a subscription.
## Test
Adds `workspace-billing-subscription-cache.service.spec.ts`: the
provider returns `null` when there's no active subscription (regression)
and the flattened subscription when one exists.
All 142 tests across the billing / ai-billing / workflow-executor suites
pass; `oxlint --type-aware` and `oxfmt` are clean.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21510?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
+1
-1
@@ -147,7 +147,7 @@ export class BillingWebhookSubscriptionService {
|
||||
|
||||
await this.billingUsageService.flushAvailableCreditsFromCache(workspace.id);
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspace.id, [
|
||||
'billingSubscription',
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
const shouldSuspend = this.shouldSuspendWorkspace(data);
|
||||
|
||||
+9
-6
@@ -3,6 +3,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ChargeDto } from 'src/engine/core-modules/billing/app-billing/dtos/charge.dto';
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
@@ -52,13 +53,15 @@ export class AppBillingService {
|
||||
let periodStart: Date | undefined;
|
||||
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const {
|
||||
billingSubscription: { currentPeriodStart },
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
]);
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
periodStart = currentPeriodStart;
|
||||
periodStart =
|
||||
currentBillingSubscription === NO_BILLING_SUBSCRIPTION
|
||||
? undefined
|
||||
: currentBillingSubscription.currentPeriodStart;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
|
||||
@@ -33,7 +33,7 @@ import { BillingUsageCapService } from 'src/engine/core-modules/billing/services
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
|
||||
import { WorkspaceBillingSubscriptionCacheService } from 'src/engine/core-modules/billing/services/workspace-billing-subscription-cache.service';
|
||||
import { WorkspaceCurrentBillingSubscriptionCacheService } from 'src/engine/core-modules/billing/services/workspace-current-billing-subscription-cache.service';
|
||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
@@ -94,7 +94,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
BillingCreditRolloverService,
|
||||
ResourceCreditService,
|
||||
BillingGaugeService,
|
||||
WorkspaceBillingSubscriptionCacheService,
|
||||
WorkspaceCurrentBillingSubscriptionCacheService,
|
||||
provideWorkspaceScopedRepository(BillingEntitlementEntity),
|
||||
provideWorkspaceScopedRepository(BillingCustomerEntity),
|
||||
provideWorkspaceScopedRepository(BillingSubscriptionEntity),
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export const NO_BILLING_SUBSCRIPTION = 'NO_BILLING_SUBSCRIPTION';
|
||||
+24
-11
@@ -13,6 +13,7 @@ import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
import { type BillingResourceCreditUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-resource-credit-usage.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
@@ -59,14 +60,14 @@ export class BillingUsageService {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { billingSubscription } =
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
return (
|
||||
isDefined(billingSubscription) &&
|
||||
billingSubscription.status !== SubscriptionStatus.Canceled
|
||||
currentBillingSubscription !== NO_BILLING_SUBSCRIPTION &&
|
||||
currentBillingSubscription.status !== SubscriptionStatus.Canceled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,11 +289,16 @@ export class BillingUsageService {
|
||||
workspaceId: string;
|
||||
usedCredits: number;
|
||||
}): Promise<number> {
|
||||
const {
|
||||
billingSubscription: { currentPeriodStart, currentPeriodEnd },
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
]);
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
if (currentBillingSubscription === NO_BILLING_SUBSCRIPTION) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { currentPeriodStart, currentPeriodEnd } = currentBillingSubscription;
|
||||
|
||||
const cachedAvailableCredits = await this.getAvailableCreditsFromCache(
|
||||
workspaceId,
|
||||
@@ -363,10 +369,17 @@ export class BillingUsageService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { billingSubscription: subscription } =
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
if (currentBillingSubscription === NO_BILLING_SUBSCRIPTION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscription = currentBillingSubscription;
|
||||
|
||||
const cached = await this.getAvailableCreditsFromCache(
|
||||
subscription.workspaceId,
|
||||
subscription.currentPeriodStart,
|
||||
|
||||
+17
-9
@@ -1,28 +1,36 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
|
||||
import { type FlatBillingSubscription } from 'src/engine/core-modules/billing/types/flat-billing-subscription.type';
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
import { type CurrentBillingSubscription } from 'src/engine/core-modules/billing/types/flat-billing-subscription.type';
|
||||
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
||||
|
||||
@Injectable()
|
||||
@WorkspaceCache('billingSubscription')
|
||||
export class WorkspaceBillingSubscriptionCacheService extends WorkspaceCacheProvider<FlatBillingSubscription> {
|
||||
@WorkspaceCache('currentBillingSubscription')
|
||||
export class WorkspaceCurrentBillingSubscriptionCacheService extends WorkspaceCacheProvider<CurrentBillingSubscription> {
|
||||
constructor(
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(workspaceId: string): Promise<FlatBillingSubscription> {
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<CurrentBillingSubscription> {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscription({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!isDefined(subscription)) {
|
||||
return NO_BILLING_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
+5
@@ -1,5 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
import { type BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
|
||||
import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { type SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
@@ -22,3 +23,7 @@ export type FlatBillingSubscription = {
|
||||
trialEnd: Date | null;
|
||||
collectionMethod: BillingSubscriptionCollectionMethod;
|
||||
};
|
||||
|
||||
export type CurrentBillingSubscription =
|
||||
| FlatBillingSubscription
|
||||
| typeof NO_BILLING_SUBSCRIPTION;
|
||||
|
||||
+12
-10
@@ -25,6 +25,7 @@ import { FlatApplication } from 'src/engine/core-modules/application/types/flat-
|
||||
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
|
||||
import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/logic-function/logic-function-executed';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
@@ -462,18 +463,19 @@ export class LogicFunctionExecutorService {
|
||||
let periodStart: Date | undefined;
|
||||
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const {
|
||||
billingSubscription: { currentPeriodStart },
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
]);
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
periodStart = currentPeriodStart;
|
||||
if (currentBillingSubscription !== NO_BILLING_SUBSCRIPTION) {
|
||||
periodStart = currentBillingSubscription.currentPeriodStart;
|
||||
|
||||
await this.billingUsageService.decrementAvailableCreditsInCache({
|
||||
workspaceId,
|
||||
usedCredits: 100,
|
||||
});
|
||||
await this.billingUsageService.decrementAvailableCreditsInCache({
|
||||
workspaceId,
|
||||
usedCredits: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ describe('AiBillingService', () => {
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: {
|
||||
getOrRecompute: jest.fn().mockResolvedValue({
|
||||
billingSubscription: {
|
||||
currentBillingSubscription: {
|
||||
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
|
||||
},
|
||||
}),
|
||||
|
||||
+20
-16
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type LanguageModelUsage } from 'ai';
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
|
||||
@@ -139,18 +140,19 @@ export class AiBillingService {
|
||||
let periodStart: Date | undefined;
|
||||
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const {
|
||||
billingSubscription: { currentPeriodStart },
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
]);
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
periodStart = currentPeriodStart;
|
||||
if (currentBillingSubscription !== NO_BILLING_SUBSCRIPTION) {
|
||||
periodStart = currentBillingSubscription.currentPeriodStart;
|
||||
|
||||
await this.billingUsageService.decrementAvailableCreditsInCache({
|
||||
workspaceId,
|
||||
usedCredits: creditsUsedMicro,
|
||||
});
|
||||
await this.billingUsageService.decrementAvailableCreditsInCache({
|
||||
workspaceId,
|
||||
usedCredits: creditsUsedMicro,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
@@ -182,13 +184,15 @@ export class AiBillingService {
|
||||
let periodStart: Date | undefined;
|
||||
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const {
|
||||
billingSubscription: { currentPeriodStart },
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
]);
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
periodStart = currentPeriodStart;
|
||||
periodStart =
|
||||
currentBillingSubscription === NO_BILLING_SUBSCRIPTION
|
||||
? undefined
|
||||
: currentBillingSubscription.currentPeriodStart;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-executi
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
|
||||
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
|
||||
import { type FlatBillingSubscription } from 'src/engine/core-modules/billing/types/flat-billing-subscription.type';
|
||||
import { type CurrentBillingSubscription } from 'src/engine/core-modules/billing/types/flat-billing-subscription.type';
|
||||
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
|
||||
import { type FlatRoleTargetByAgentIdMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-role-target-by-agent-id-maps.type';
|
||||
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
@@ -60,7 +60,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
|
||||
apiKeyMap: 'cache:api-key-map',
|
||||
applicationVariableMaps: 'cache:application-variable',
|
||||
graphQLResolverNameMap: 'direct-execution:graphql-resolver-name-map',
|
||||
billingSubscription: 'billing:subscription',
|
||||
currentBillingSubscription: 'billing:subscription',
|
||||
} as const satisfies Record<WorkspaceCacheKeyName, string>;
|
||||
|
||||
export type AdditionalCacheDataMaps = {
|
||||
@@ -77,7 +77,7 @@ export type AdditionalCacheDataMaps = {
|
||||
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps;
|
||||
applicationVariableMaps: ApplicationVariableCacheMaps;
|
||||
graphQLResolverNameMap: Record<string, ResolverNameMapEntry>;
|
||||
billingSubscription: FlatBillingSubscription;
|
||||
currentBillingSubscription: CurrentBillingSubscription;
|
||||
};
|
||||
|
||||
export type WorkspaceCacheDataMap = AllFlatEntityMaps<true> &
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: {
|
||||
getOrRecompute: jest.fn().mockResolvedValue({
|
||||
billingSubscription: {
|
||||
currentBillingSubscription: {
|
||||
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
|
||||
},
|
||||
}),
|
||||
|
||||
+12
-10
@@ -8,6 +8,7 @@ import {
|
||||
WorkflowRunStepInfos,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
@@ -327,18 +328,19 @@ export class WorkflowExecutorWorkspaceService {
|
||||
) {
|
||||
let periodStart: Date | undefined;
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const {
|
||||
billingSubscription: { currentPeriodStart },
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
]);
|
||||
const { currentBillingSubscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
periodStart = currentPeriodStart;
|
||||
if (currentBillingSubscription !== NO_BILLING_SUBSCRIPTION) {
|
||||
periodStart = currentBillingSubscription.currentPeriodStart;
|
||||
|
||||
await this.billingUsageService.decrementAvailableCreditsInCache({
|
||||
workspaceId,
|
||||
usedCredits: 100,
|
||||
});
|
||||
await this.billingUsageService.decrementAvailableCreditsInCache({
|
||||
workspaceId,
|
||||
usedCredits: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
|
||||
Reference in New Issue
Block a user