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
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { addMonths, addYears } from 'date-fns';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type Repository } from 'typeorm';
@@ -24,6 +25,7 @@ import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/se
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle';
@@ -43,6 +45,7 @@ export class BillingWebhookInvoiceService {
private readonly billingCreditRolloverService: BillingCreditRolloverService,
private readonly meteredCreditService: MeteredCreditService,
private readonly stripeInvoiceService: StripeInvoiceService,
private readonly featureFlagService: FeatureFlagService,
private readonly auditService: AuditService,
) {}
@@ -100,7 +103,18 @@ export class BillingWebhookInvoiceService {
return;
}
if (periodStart) {
const trialEnd = isDefined(subscription.trialEnd)
? Math.floor(subscription.trialEnd.getTime() / 1000)
: undefined;
const TRIAL_END_TOLERANCE_SECONDS = 60;
const isFirstPeriodAfterTrial =
isDefined(trialEnd) &&
isDefined(periodStart) &&
Math.abs(periodStart - trialEnd) <= TRIAL_END_TOLERANCE_SECONDS;
if (periodStart && !isFirstPeriodAfterTrial) {
await this.processRollover(
subscription,
new Date(periodStart * 1000),
@@ -108,11 +122,18 @@ export class BillingWebhookInvoiceService {
);
}
// Pass the new period start (which is the invoiced period's end) for alert threshold calculation
await this.meteredCreditService.recreateBillingAlertForSubscription(
subscription,
new Date(periodEnd * 1000),
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (!isV2) {
// Pass the new period start (which is the invoiced period's end) for alert threshold calculation
await this.meteredCreditService.recreateBillingAlertForSubscription(
subscription,
new Date(periodEnd * 1000),
);
}
}
private async processRollover(
@@ -120,6 +141,33 @@ export class BillingWebhookInvoiceService {
invoicedPeriodStart: Date,
invoicedPeriodEnd: Date,
): Promise<void> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (isV2) {
const v2Params =
await this.meteredCreditService.getResourceCreditRolloverParameters(
subscription.id,
);
if (!isDefined(v2Params)) {
return;
}
await this.billingCreditRolloverService.processRolloverOnPeriodTransitionV2(
{
workspaceId: subscription.workspaceId,
stripeCustomerId: subscription.stripeCustomerId,
tierQuantity: v2Params.tierQuantity,
previousPeriodStart: invoicedPeriodStart,
},
);
return;
}
const rolloverParams =
await this.meteredCreditService.getMeteredRolloverParameters(
subscription.id,
@@ -182,21 +182,6 @@ export class BillingWebhookSubscriptionService {
workspaceId,
);
if (event.type === BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED) {
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
updatedBillingSubscription.id,
);
const gte =
this.billingSubscriptionService.getTrialPeriodFreeWorkflowCredits(
updatedBillingSubscription,
);
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
updatedBillingSubscription.stripeCustomerId,
gte,
);
}
return {
stripeSubscriptionId: data.object.id,
stripeCustomerId: data.object.customer,
@@ -50,6 +50,7 @@ describe('transformStripePriceEventToDatabasePrice', () => {
transformQuantity: undefined,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Month,
metadata: {},
currencyOptions: undefined,
tiers: undefined,
recurring: {
@@ -38,6 +38,7 @@ export const transformStripePriceEventToDatabasePrice = (
data.currency_options === null ? undefined : data.currency_options,
tiers: data.tiers === null ? undefined : data.tiers,
recurring: data.recurring === null ? undefined : data.recurring,
metadata: data.metadata ?? {},
};
};