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:
+53
-5
@@ -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,
|
||||
|
||||
-15
@@ -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,
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ describe('transformStripePriceEventToDatabasePrice', () => {
|
||||
transformQuantity: undefined,
|
||||
usageType: BillingUsageType.LICENSED,
|
||||
interval: SubscriptionInterval.Month,
|
||||
metadata: {},
|
||||
currencyOptions: undefined,
|
||||
tiers: undefined,
|
||||
recurring: {
|
||||
|
||||
+1
@@ -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 ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user