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
@@ -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