fix(billing): refactor and update subscription phase handling logic (#14783)

This commit is contained in:
Antoine Moreaux
2025-09-30 23:25:21 +02:00
committed by GitHub
parent 3c0a197525
commit 1184c59c94
9 changed files with 1744 additions and 1622 deletions
@@ -5,7 +5,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingAddWorkflowSubscriptionItemCommand } from 'src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
import { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-data.command';
import { BillingUpdateSubscriptionPriceCommand } from 'src/engine/core-modules/billing/commands/billing-update-subscription-price.command';
@@ -35,6 +34,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
@Module({
imports: [
@@ -72,8 +72,8 @@ import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing
BillingSyncCustomerDataCommand,
BillingUpdateSubscriptionPriceCommand,
BillingSyncPlansDataCommand,
BillingAddWorkflowSubscriptionItemCommand,
BillingUsageService,
BillingPriceService,
],
exports: [
BillingSubscriptionService,
@@ -1,180 +0,0 @@
/* @license Enterprise */
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'billing:add-workflow-subscription-item',
description: 'Add workflow subscription item to all workspaces subscriptions',
})
export class BillingAddWorkflowSubscriptionItemCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(BillingSubscription)
protected readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingProduct)
protected readonly billingProductRepository: Repository<BillingProduct>,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly twentyConfigService: TwentyConfigService,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const billingProducts = await this.billingProductRepository.find({
relations: ['billingPrices'],
});
const subscription = await this.billingSubscriptionRepository.findOneOrFail(
{
where: {
workspaceId,
},
relations: ['billingSubscriptionItems'],
},
);
const { basedProduct, basedPrice } =
this.getSubscriptionBasedProductAndPrice(billingProducts, subscription);
const associatedWorkflowMeteredPrice =
this.getAssociatedWorkflowMeteredBillingPrice(
billingProducts,
basedProduct,
basedPrice,
);
const hasAlreadyWorkflowSubscriptionItem =
subscription.billingSubscriptionItems.some(
(item) =>
item.stripePriceId === associatedWorkflowMeteredPrice.stripePriceId,
);
if (hasAlreadyWorkflowSubscriptionItem) {
this.logger.log(
`Workflow subscription item with price ${associatedWorkflowMeteredPrice.stripePriceId} already exists for ${workspaceId}`,
);
return;
}
if (!options.dryRun) {
await this.stripeSubscriptionService.updateSubscription(
subscription.stripeSubscriptionId,
{
trial_settings: {
end_behavior: {
missing_payment_method: 'create_invoice',
},
},
},
);
await this.stripeSubscriptionItemService.createSubscriptionItem(
subscription.stripeSubscriptionId,
associatedWorkflowMeteredPrice.stripePriceId,
);
await this.stripeSubscriptionService.updateSubscription(
subscription.stripeSubscriptionId,
{
billing_thresholds: {
amount_gte: this.twentyConfigService.get(
'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT',
),
reset_billing_cycle_anchor: false,
},
},
);
}
this.logger.log(
`Adding workflow subscription item with price ${associatedWorkflowMeteredPrice.stripePriceId} to ${workspaceId}`,
);
}
private getSubscriptionBasedProductAndPrice(
billingProducts: BillingProduct[],
subscription: BillingSubscription,
) {
const basedProductSubscriptionItem =
subscription?.billingSubscriptionItems.find((item) => {
return billingProducts.some((product) => {
const isBasedProduct =
product.stripeProductId === item.stripeProductId &&
product.metadata.productKey === BillingProductKey.BASE_PRODUCT;
return isBasedProduct;
});
});
if (!basedProductSubscriptionItem) {
throw new Error('Based product subscription item not found');
}
const { stripeProductId, stripePriceId } = basedProductSubscriptionItem;
const basedProduct = billingProducts.find(
(product) => product.stripeProductId === stripeProductId,
);
const basedPrice = basedProduct?.billingPrices.find(
(price) => price.stripePriceId === stripePriceId,
);
if (!basedProduct || !basedPrice) {
throw new Error(
`Based product ${stripeProductId} or price ${stripePriceId} not found`,
);
}
return { basedProduct, basedPrice };
}
private getAssociatedWorkflowMeteredBillingPrice(
billingProducts: BillingProduct[],
basedProduct: BillingProduct,
basedPrice: BillingPrice,
) {
const associatedMeteredProduct = billingProducts.find(
(product) =>
product.metadata.planKey === basedProduct.metadata.planKey &&
product.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
const associatedMeteredPrice = associatedMeteredProduct?.billingPrices.find(
(price) => price.interval === basedPrice.interval,
);
if (!associatedMeteredPrice) {
throw new Error(
`Associated metered price for ${basedProduct.name} ${basedPrice.interval} not found`,
);
}
return associatedMeteredPrice;
}
}
@@ -0,0 +1,35 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
@Injectable()
export class BillingPriceService {
protected readonly logger = new Logger(BillingPriceService.name);
constructor(
private readonly stripeSubscriptionService: StripeSubscriptionService,
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
) {}
async getBillingThresholdsByMeterPriceId(meterPriceId: string) {
const price = await this.billingPriceRepository.findOneOrFail({
where: {
stripePriceId: meterPriceId,
},
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(price);
return this.stripeSubscriptionService.getBillingThresholds(
price.tiers[0].flat_amount,
);
}
}
@@ -17,6 +17,7 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
@Injectable()
export class BillingSubscriptionPhaseService {
@@ -24,14 +25,17 @@ export class BillingSubscriptionPhaseService {
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
private readonly billingPlanService: BillingPlanService,
private readonly billingPriceService: BillingPriceService,
) {}
async getDetailsFromPhase(phase: BillingSubscriptionSchedulePhase) {
const meteredPrice = await this.billingPriceRepository.findOneByOrFail({
stripePriceId: findOrThrow(
phase.items,
({ quantity }) => !isDefined(quantity),
).price,
const meteredPrice = await this.billingPriceRepository.findOneOrFail({
where: {
stripePriceId: findOrThrow(
phase.items,
({ quantity }) => !isDefined(quantity),
).price,
},
});
const { quantity, price: licensedItemPriceId } = findOrThrow(
@@ -39,8 +43,10 @@ export class BillingSubscriptionPhaseService {
({ quantity }) => isDefined(quantity),
);
const licensedPrice = await this.billingPriceRepository.findOneByOrFail({
stripePriceId: licensedItemPriceId,
const licensedPrice = await this.billingPriceRepository.findOneOrFail({
where: {
stripePriceId: licensedItemPriceId,
},
});
const plan = await this.billingPlanService.getPlanByPriceId(
@@ -77,13 +83,12 @@ export class BillingSubscriptionPhaseService {
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
}
buildSnapshot(
async buildSnapshot(
base: Stripe.SubscriptionScheduleUpdateParams.Phase,
licensedPriceId: string,
seats: number,
meteredPriceId: string,
billing_thresholds?: Stripe.SubscriptionScheduleUpdateParams.Phase.BillingThresholds,
): Stripe.SubscriptionScheduleUpdateParams.Phase {
): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
return {
start_date: base.start_date,
end_date: base.end_date,
@@ -92,7 +97,10 @@ export class BillingSubscriptionPhaseService {
{ price: licensedPriceId, quantity: seats },
{ price: meteredPriceId },
],
...(billing_thresholds ? { billing_thresholds } : {}),
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredPriceId,
),
};
}
@@ -52,12 +52,14 @@ import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/c
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
import { ensureFutureStartDate } from 'src/engine/core-modules/billing/utils/ensure-future-start-date.util';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
@Injectable()
export class BillingSubscriptionService {
protected readonly logger = new Logger(BillingSubscriptionService.name);
constructor(
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly billingPriceService: BillingPriceService,
private readonly billingPlanService: BillingPlanService,
private readonly billingProductService: BillingProductService,
@InjectRepository(BillingEntitlement)
@@ -259,7 +261,7 @@ export class BillingSubscriptionService {
await this.stripeSubscriptionScheduleService.replaceEditablePhases(
updatedSchedule.id,
{
currentSnapshot: currentMutated ?? undefined,
currentPhaseSnapshot: currentMutated ?? undefined,
nextPhase: nextForUpdate,
},
);
@@ -372,15 +374,20 @@ export class BillingSubscriptionService {
],
});
const { stripePriceId: meterStripePriceId } = findOrThrow(
billingSubscription.billingSubscriptionItems,
(billingSubscriptionItem) =>
billingSubscriptionItem.billingProduct.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
{
billing_thresholds: {
amount_gte: this.twentyConfigService.get(
'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT',
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meterStripePriceId,
),
reset_billing_cycle_anchor: false,
},
},
);
@@ -527,16 +534,6 @@ export class BillingSubscriptionService {
return currentBillingSubscription;
}
private async getBillingThresholdsByPriceId(priceId: string) {
const price = await this.billingPriceRepository.findOneByOrFail({
stripePriceId: priceId,
});
return this.stripeSubscriptionService.getBillingThresholdsByInterval(
price.interval,
);
}
private async loadScheduleEditable(stripeSubscriptionId: string) {
const subscription =
await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule(
@@ -755,12 +752,11 @@ export class BillingSubscriptionService {
: currentLicensedId;
const currentMutated = currentSnap
? mutateCurrentNow
? this.billingSubscriptionPhaseService.buildSnapshot(
? await this.billingSubscriptionPhaseService.buildSnapshot(
currentSnap,
currentLicensedId,
currentPhaseDetails.quantity,
mappedCurrentMeteredId,
await this.getBillingThresholdsByPriceId(currentLicensedId),
)
: currentSnap
: undefined;
@@ -784,13 +780,13 @@ export class BillingSubscriptionService {
items: baseItems,
proration_behavior: 'none',
};
const nextMutated = this.billingSubscriptionPhaseService.buildSnapshot(
nextPhaseBase,
nextLicensedId,
nextPhaseDetails?.quantity ?? currentPhaseDetails.quantity,
mappedNextMeteredId,
await this.getBillingThresholdsByPriceId(nextLicensedId),
);
const nextMutated =
await this.billingSubscriptionPhaseService.buildSnapshot(
nextPhaseBase,
nextLicensedId,
nextPhaseDetails?.quantity ?? currentPhaseDetails.quantity,
mappedNextMeteredId,
);
return {
currentSnap,
@@ -1018,7 +1014,7 @@ export class BillingSubscriptionService {
this.billingSubscriptionPhaseService.toSnapshot(currentEditable);
const nextPhaseForYear =
this.billingSubscriptionPhaseService.buildSnapshot(
await this.billingSubscriptionPhaseService.buildSnapshot(
{
start_date: ensureFutureStartDate(
(currentSnap?.end_date as number | undefined) ??
@@ -1030,15 +1026,12 @@ export class BillingSubscriptionService {
mappedNext.targetLicensedPrice.stripePriceId,
reloadedNextDetails.quantity,
mappedNext.targetMeteredPrice.stripePriceId,
await this.getBillingThresholdsByPriceId(
mappedNext.targetLicensedPrice.stripePriceId,
),
);
return await this.scheduleReplaceNext({
subscription,
scheduleId: schedule.id,
currentSnapshot: currentSnap,
currentPhaseSnapshot: currentSnap,
nextPhase: nextPhaseForYear,
});
}
@@ -1162,7 +1155,7 @@ export class BillingSubscriptionService {
return;
}
// Case B: PRO -> ENTERPRISE (immediate)
// Case B: PRO -> ENTERPRISE
if (
currentPlan === BillingPlanKey.PRO &&
targetPlanKey === BillingPlanKey.ENTERPRISE
@@ -1182,6 +1175,52 @@ export class BillingSubscriptionService {
planMeta: targetPlanKey,
});
const { currentEditable, nextEditable, subscription, schedule } =
await this.loadScheduleEditable(stripeSubscriptionId);
if (nextEditable && currentEditable) {
const nextDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
);
const preservedNextInterval = nextDetails?.interval ?? interval;
const preservedNextMeteredId =
nextDetails?.meteredPrice.stripePriceId ?? currentMeteredPriceId;
const mappedNext = await this.resolvePrices({
interval: preservedNextInterval,
planKey: targetPlanKey,
meteredPriceId: preservedNextMeteredId,
updateType: 'plan',
});
const currentPhaseSnapshot =
this.billingSubscriptionPhaseService.toSnapshot(currentEditable);
const nextPhase =
await this.billingSubscriptionPhaseService.buildSnapshot(
{
start_date: ensureFutureStartDate(
(currentPhaseSnapshot?.end_date as number | undefined) ??
subscription.current_period_end,
),
items: currentPhaseSnapshot.items,
proration_behavior: 'none',
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
mappedNext.targetLicensedPrice.stripePriceId,
nextDetails.quantity,
mappedNext.targetMeteredPrice.stripePriceId,
);
return await this.scheduleReplaceNext({
subscription,
scheduleId: schedule.id,
currentPhaseSnapshot,
nextPhase,
});
}
return;
}
@@ -1258,7 +1297,6 @@ export class BillingSubscriptionService {
seats,
anchor,
proration,
thresholdsPriceId,
metadata,
} = params;
@@ -1271,13 +1309,11 @@ export class BillingSubscriptionService {
],
...(anchor ? { billing_cycle_anchor: anchor } : {}),
...(proration ? { proration_behavior: proration } : {}),
...(thresholdsPriceId
? {
billing_thresholds:
await this.getBillingThresholdsByPriceId(thresholdsPriceId),
}
: {}),
...(metadata ? { metadata } : {}),
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredPriceId,
),
},
);
}
@@ -1285,16 +1321,16 @@ export class BillingSubscriptionService {
private async scheduleReplaceNext(params: {
scheduleId: string;
subscription: SubscriptionWithSchedule | Stripe.Subscription;
currentSnapshot: Stripe.SubscriptionScheduleUpdateParams.Phase;
currentPhaseSnapshot: Stripe.SubscriptionScheduleUpdateParams.Phase;
nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase;
}): Promise<void> {
const { scheduleId, currentSnapshot, subscription } = params;
const { scheduleId, currentPhaseSnapshot, subscription } = params;
let { nextPhase } = params;
if (
nextPhase &&
(await this.billingSubscriptionPhaseService.isSamePhaseSignature(
currentSnapshot,
currentPhaseSnapshot,
nextPhase,
))
) {
@@ -1304,7 +1340,7 @@ export class BillingSubscriptionService {
await this.stripeSubscriptionScheduleService.replaceEditablePhases(
scheduleId,
{
currentSnapshot,
currentPhaseSnapshot,
nextPhase,
},
);
@@ -1313,8 +1349,10 @@ export class BillingSubscriptionService {
subscription.id,
);
const workspaceId = (
await this.billingSubscriptionRepository.findOneByOrFail({
stripeSubscriptionId: refreshed.id,
await this.billingSubscriptionRepository.findOneOrFail({
where: {
stripeSubscriptionId: refreshed.id,
},
})
).workspaceId;
@@ -1426,25 +1464,26 @@ export class BillingSubscriptionService {
const { currentEditable } =
this.stripeSubscriptionScheduleService.getEditablePhases(schedule);
const current = this.billingSubscriptionPhaseService.toSnapshot(
currentEditable as Stripe.SubscriptionSchedule.Phase,
);
const next = this.billingSubscriptionPhaseService.buildSnapshot(
const currentPhaseSnapshot =
this.billingSubscriptionPhaseService.toSnapshot(
currentEditable as Stripe.SubscriptionSchedule.Phase,
);
const next = await this.billingSubscriptionPhaseService.buildSnapshot(
{
start_date: current.end_date ?? subscription.current_period_end,
items: current.items,
start_date:
currentPhaseSnapshot.end_date ?? subscription.current_period_end,
items: currentPhaseSnapshot.items,
proration_behavior: 'none',
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
prices.next.licensedPriceId,
prices.next.seats,
prices.next.meteredPriceId,
await this.getBillingThresholdsByPriceId(prices.next.licensedPriceId),
);
await this.scheduleReplaceNext({
scheduleId: schedule.id,
subscription,
currentSnapshot: current,
currentPhaseSnapshot,
nextPhase: next,
});
}
@@ -134,7 +134,7 @@ export class StripeSubscriptionScheduleService {
async replaceEditablePhases(
scheduleId: string,
desired: {
currentSnapshot?: Stripe.SubscriptionScheduleUpdateParams.Phase;
currentPhaseSnapshot?: Stripe.SubscriptionScheduleUpdateParams.Phase;
nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase;
},
): Promise<Stripe.SubscriptionSchedule> {
@@ -146,10 +146,11 @@ export class StripeSubscriptionScheduleService {
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [];
const currentSnapshot =
desired.currentSnapshot ?? this.snapshotFromLivePhase(currentEditable);
const currentPhaseSnapshot =
desired.currentPhaseSnapshot ??
this.snapshotFromLivePhase(currentEditable);
phases.push(currentSnapshot);
phases.push(currentPhaseSnapshot);
const hasNextKey = 'nextPhase' in desired;
const wantsNext = hasNextKey && !!desired.nextPhase;
@@ -6,7 +6,6 @@ import type Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
@Injectable()
export class StripeSubscriptionService {
@@ -66,11 +65,9 @@ export class StripeSubscriptionService {
return this.stripe.subscriptions.update(stripeSubscriptionId, updateData);
}
getBillingThresholdsByInterval(interval: SubscriptionInterval) {
getBillingThresholds(meterPriceFlatAmount: number) {
return {
amount_gte:
this.twentyConfigService.get('BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT') *
(interval === SubscriptionInterval.Year ? 12 : 1),
amount_gte: Math.max(meterPriceFlatAmount * 2, 10000),
reset_billing_cycle_anchor: false,
};
}
@@ -564,15 +564,6 @@ export class ConfigVariables {
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS = 7;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of money in cents to trigger a billing threshold',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT = 10000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial without credit card',