Billing - Migrate from Stripe metering (#20298)

**Overall strategy**
**1. Introduce “Billing V2” behind a workspace flag**
Gate the new model with FeatureFlagKey.IS_BILLING_V2_ENABLED so existing
workspaces stay on the old behavior until they’re migrated or explicitly
on V2.

**2. Replace workflow metered SKUs with a resource-credit product**
Conceptually, billable “workflow execution” usage is not the primary
subscription line item anymore. Add a RESOURCE_CREDIT product (and keep
WORKFLOW_NODE_EXECUTION as deprecated for the transition). Usage and
limits are expressed through credit buckets (e.g. price metadata like
credit_amount), so one product can represent pooled credits instead of a
narrow workflow-only meter.

**3. Migrate subscriptions in two layers**
Schema/catalog: persist extra price metadata (instance upgrade) so the
server knows credit amounts and can match Stripe prices to the new
model.
Per workspace: the registered workspace command
upgrade:2-2:migrate-to-billing-v2 finds subscriptions that still have
WORKFLOW_NODE_EXECUTION, swaps those items to the right RESOURCE_CREDIT
prices (using existing Stripe schedule +
BillingSubscriptionUpdateService stack), then treats the workspace as V2
(flag). Workspaces without that legacy item or without a subscription
are skipped.

**4. Unify subscription lifecycle + usage on the server**

**5. Refresh the product surface in Settings**

Test : 

- [x] Subscribe v1 +  Update subscribe + Migrate
- [x]  Subscribe v2 + Update subscribe
This commit is contained in:
Etienne
2026-05-07 17:42:11 +02:00
committed by GitHub
parent ca58c7f15e
commit 9fc5be1c4c
93 changed files with 2939 additions and 514 deletions
@@ -3,10 +3,16 @@ import { Module } from '@nestjs/common';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@Module({
imports: [WorkspaceEventEmitterModule, AiModelsModule, BillingModule],
imports: [
WorkspaceEventEmitterModule,
AiModelsModule,
BillingModule,
WorkspaceCacheModule,
],
providers: [AiBillingService],
exports: [AiBillingService],
})
@@ -9,6 +9,7 @@ import { BillingUsageService } from 'src/engine/core-modules/billing/services/bi
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
describe('AiBillingService', () => {
@@ -87,6 +88,16 @@ describe('AiBillingService', () => {
decrementAvailableCredits: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn().mockResolvedValue({
billingSubscription: {
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
},
}),
},
},
],
}).compile();
@@ -14,6 +14,7 @@ import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
export type BillingUsageInput = {
@@ -30,6 +31,7 @@ export class AiBillingService {
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
calculateCost(modelId: ModelId, billingInput: BillingUsageInput): number {
@@ -104,6 +106,23 @@ export class AiBillingService {
`Native web search billing: ${nativeWebSearchCallCount} calls, $${costInDollars.toFixed(4)}`,
);
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -114,17 +133,11 @@ export class AiBillingService {
quantity: nativeWebSearchCallCount,
unit: UsageUnit.INVOCATION,
userWorkspaceId: userWorkspaceId || null,
periodStart,
},
],
workspaceId,
);
if (this.billingService.isBillingEnabled()) {
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
}
private async emitAiTokenUsageEvent(
@@ -136,6 +149,23 @@ export class AiBillingService {
agentId?: string | null,
userWorkspaceId?: string | null,
): Promise<void> {
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -148,15 +178,10 @@ export class AiBillingService {
resourceId: agentId || null,
resourceContext: modelId,
userWorkspaceId: userWorkspaceId || null,
periodStart,
},
],
workspaceId,
);
if (this.billingService.isBillingEnabled()) {
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
}
}