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:
Charles Bochet
2026-06-12 22:47:44 +02:00
committed by GitHub
parent adb60a3867
commit 9bb98fa5b5
13 changed files with 110 additions and 70 deletions
@@ -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);
@@ -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),
@@ -0,0 +1,3 @@
/* @license Enterprise */
export const NO_BILLING_SUBSCRIPTION = 'NO_BILLING_SUBSCRIPTION';
@@ -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,
@@ -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,
@@ -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;
@@ -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>(
@@ -94,7 +94,7 @@ describe('AiBillingService', () => {
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn().mockResolvedValue({
billingSubscription: {
currentBillingSubscription: {
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
},
}),
@@ -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> &
@@ -126,7 +126,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn().mockResolvedValue({
billingSubscription: {
currentBillingSubscription: {
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
},
}),
@@ -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>(