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
@@ -15,6 +15,8 @@ import { mockedPublicWorkspaceDataBySubdomain } from '~/testing/mock-data/public
import { mockedUserData } from '~/testing/mock-data/users';
import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain';
import { BILLING_PORTAL_SESSION } from '@/settings/billing/graphql/queries/billingPortalSession';
import { GET_METERED_PRODUCTS_USAGE } from '@/settings/billing/graphql/queries/getMeteredProductsUsage';
import { LIST_PLANS } from '@/settings/billing/graphql/queries/listPlans';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { mockBillingPlans } from '~/testing/mock-data/billing-plans';
@@ -528,6 +530,42 @@ export const graphqlMocks = {
data: mockBillingPlans,
});
}),
graphql.query(getOperationName(GET_METERED_PRODUCTS_USAGE) ?? '', () => {
return HttpResponse.json({
data: {
getMeteredProductsUsage: [
{
__typename: 'BillingMeteredProductUsage',
productKey: 'WORKFLOW_NODE_EXECUTION',
usedCredits: 1000,
grantedCredits: 500000,
rolloverCredits: 0,
totalGrantedCredits: 500000,
unitPriceCents: 1,
},
{
__typename: 'BillingMeteredProductUsage',
productKey: 'RESOURCE_CREDIT',
usedCredits: 0,
grantedCredits: 10000,
rolloverCredits: 0,
totalGrantedCredits: 10000,
unitPriceCents: 1,
},
],
},
});
}),
graphql.query(getOperationName(BILLING_PORTAL_SESSION) ?? '', () => {
return HttpResponse.json({
data: {
billingPortalSession: {
__typename: 'BillingSession',
url: 'https://billing.stripe.com/p/mock-portal-session',
},
},
});
}),
http.get('https://chat-assets.frontapp.com/v1/chat.bundle.js', () => {
return HttpResponse.text(
`
@@ -5,7 +5,7 @@ export const mockBillingPlans = {
{
__typename: 'BillingPlan',
planKey: 'PRO',
licensedProducts: [
baseProducts: [
{
__typename: 'BillingLicensedProduct',
name: 'Pro Plan',
@@ -24,6 +24,7 @@ export const mockBillingPlans = {
unitAmount: 1200,
recurringInterval: 'Month',
priceUsageType: 'LICENSED',
creditAmount: null,
},
{
__typename: 'BillingPriceLicensed',
@@ -31,10 +32,12 @@ export const mockBillingPlans = {
unitAmount: 10800,
recurringInterval: 'Year',
priceUsageType: 'LICENSED',
creditAmount: null,
},
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
__typename: 'BillingMeteredProduct',
@@ -295,7 +298,7 @@ export const mockBillingPlans = {
{
__typename: 'BillingPlan',
planKey: 'ENTERPRISE',
licensedProducts: [
baseProducts: [
{
__typename: 'BillingLicensedProduct',
name: 'Organization Plan',
@@ -314,6 +317,7 @@ export const mockBillingPlans = {
unitAmount: 2500,
recurringInterval: 'Month',
priceUsageType: 'LICENSED',
creditAmount: null,
},
{
__typename: 'BillingPriceLicensed',
@@ -321,10 +325,12 @@ export const mockBillingPlans = {
unitAmount: 22800,
recurringInterval: 'Year',
priceUsageType: 'LICENSED',
creditAmount: null,
},
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
__typename: 'BillingMeteredProduct',
@@ -49,7 +49,7 @@ export const workspaceLogoUrl =
// Extract Pro monthly base product from mockBillingPlans to use in workspace billing mocks
const PRO_PLAN = mockBillingPlans.listPlans.find((p) => p.planKey === 'PRO')!;
const PRO_BASE_LICENSED_PRODUCT = PRO_PLAN?.licensedProducts?.[0]!;
const PRO_BASE_LICENSED_PRODUCT = PRO_PLAN?.baseProducts?.[0]!;
const PRO_BASE_MONTHLY_PRICE = PRO_BASE_LICENSED_PRODUCT?.prices?.find(
(pr) => pr.recurringInterval === 'Month',
)!;