chore(billing) - remove feature flag (#20531)
- remove feature flag - remove old enforce cap usage logic
This commit is contained in:
@@ -5,12 +5,10 @@ import { Command, CommandRunner } from 'nest-commander';
|
||||
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
|
||||
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
|
||||
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
|
||||
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
|
||||
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
|
||||
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
|
||||
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
|
||||
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command';
|
||||
import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command';
|
||||
@@ -59,11 +57,9 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
private readonly trashCleanupCronCommand: TrashCleanupCronCommand,
|
||||
private readonly eventLogCleanupCronCommand: EventLogCleanupCronCommand,
|
||||
private readonly enterpriseKeyValidationCronCommand: EnterpriseKeyValidationCronCommand,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
|
||||
private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand,
|
||||
private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand,
|
||||
private readonly enforceUsageCapCronCommand: EnforceUsageCapCronCommand,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -164,10 +160,6 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
name: 'StaleRegistrationCleanup',
|
||||
command: this.staleRegistrationCleanupCronCommand,
|
||||
},
|
||||
{
|
||||
name: 'EnforceUsageCap',
|
||||
command: this.enforceUsageCapCronCommand,
|
||||
},
|
||||
];
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command';
|
||||
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
|
||||
import { GenerateInstanceCommandCommand } from 'src/database/commands/generate-instance-command.command';
|
||||
@@ -8,6 +9,7 @@ import { InstallPreInstalledAppsCommand } from 'src/database/commands/install-pr
|
||||
import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service';
|
||||
import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command';
|
||||
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
|
||||
import { RebuildApplicationDefaultDepsCommand } from 'src/database/commands/rebuild-application-default-deps.command';
|
||||
import { RunInstanceCommandsCommand } from 'src/database/commands/run-instance-commands.command';
|
||||
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
|
||||
import { WorkspaceExportModule } from 'src/database/commands/workspace-export/workspace-export.module';
|
||||
@@ -16,13 +18,9 @@ import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
|
||||
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
|
||||
import { StaleRegistrationCleanupModule } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/stale-registration-cleanup.module';
|
||||
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
|
||||
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
|
||||
import { RebuildApplicationDefaultDepsCommand } from 'src/database/commands/rebuild-application-default-deps.command';
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
|
||||
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
|
||||
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
|
||||
@@ -38,6 +36,7 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module';
|
||||
import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
@@ -96,7 +95,6 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
ListOrphanedWorkspaceEntitiesCommand,
|
||||
EnterpriseKeyValidationCronCommand,
|
||||
GenerateApiKeyCommand,
|
||||
EnforceUsageCapCronCommand,
|
||||
UpgradeStatusCommand,
|
||||
RebuildApplicationDefaultDepsCommand,
|
||||
InstallPreInstalledAppsCommand,
|
||||
|
||||
+3
-382
@@ -1,409 +1,30 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Command } from 'nest-commander';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
|
||||
import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
|
||||
type WorkflowPriceInfo = {
|
||||
planKey: BillingPlanKey;
|
||||
interval: SubscriptionInterval;
|
||||
creditAmount: number | null;
|
||||
};
|
||||
|
||||
@RegisteredWorkspaceCommand('2.4.0', 1797000001000)
|
||||
@Command({
|
||||
name: 'upgrade:2-4:migrate-to-billing-v2',
|
||||
description:
|
||||
'Swap WORKFLOW_NODE_EXECUTION subscription items to RESOURCE_CREDIT for all V1 workspaces',
|
||||
'No-op: all workspaces are now on the resource-credit billing model',
|
||||
})
|
||||
export class MigrateToBillingV2Command extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
// Populated on first workspace, reused for all subsequent workspaces
|
||||
private catalogLoaded = false;
|
||||
private workflowPriceMap = new Map<string, WorkflowPriceInfo>();
|
||||
private resourceCreditBuckets = new Map<string, BillingPriceEntity[]>();
|
||||
private basePriceMap = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService,
|
||||
private readonly billingSubscriptionUpdateService: BillingSubscriptionUpdateService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
@InjectRepository(BillingPriceEntity)
|
||||
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
if (!this.billingService.isBillingEnabled()) {
|
||||
this.logger.log('Billing is not enabled, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.catalogLoaded) {
|
||||
await this.loadCatalog();
|
||||
this.catalogLoaded = true;
|
||||
}
|
||||
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const isAlreadyV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isAlreadyV2) {
|
||||
this.logger.log(
|
||||
`Workspace ${workspaceId} already on billing V2, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscription({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!isDefined(subscription)) {
|
||||
this.logger.log(
|
||||
`Workspace ${workspaceId} has no active subscription, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowItem = subscription.billingSubscriptionItems.find(
|
||||
(item) =>
|
||||
item.billingProduct?.metadata.productKey ===
|
||||
BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
);
|
||||
|
||||
if (!isDefined(workflowItem)) {
|
||||
this.logger.log(
|
||||
`Workspace ${workspaceId} has no WORKFLOW_NODE_EXECUTION item, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const baseItem = subscription.billingSubscriptionItems.find(
|
||||
(item) =>
|
||||
item.billingProduct?.metadata.productKey ===
|
||||
BillingProductKey.BASE_PRODUCT,
|
||||
);
|
||||
|
||||
if (!isDefined(baseItem)) {
|
||||
throw new BillingException(
|
||||
`Workspace ${workspaceId} has no BASE_PRODUCT item`,
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const workflowPriceInfo = this.workflowPriceMap.get(
|
||||
workflowItem.stripePriceId,
|
||||
);
|
||||
|
||||
if (!isDefined(workflowPriceInfo)) {
|
||||
throw new BillingException(
|
||||
`Workspace ${workspaceId} workflow price ${workflowItem.stripePriceId} not in catalog`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const planIntervalKey = `${workflowPriceInfo.planKey}:${workflowPriceInfo.interval}`;
|
||||
const resourceCreditBucket =
|
||||
this.resourceCreditBuckets.get(planIntervalKey);
|
||||
|
||||
if (!isDefined(resourceCreditBucket) || resourceCreditBucket.length === 0) {
|
||||
throw new BillingException(
|
||||
`Workspace ${workspaceId} no RESOURCE_CREDIT prices for ${planIntervalKey}`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const targetResourceCreditPrice = this.resolveClosestResourceCreditPrice(
|
||||
workflowPriceInfo.creditAmount,
|
||||
resourceCreditBucket,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] workspace=${workspaceId} creditAmount=${workflowPriceInfo.creditAmount ?? 'n/a'} ` +
|
||||
`swap ${workflowItem.stripeSubscriptionItemId}/${workflowItem.stripePriceId} → ${targetResourceCreditPrice.stripePriceId}`,
|
||||
);
|
||||
|
||||
if (subscription.phases?.length > 0) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] workspace=${workspaceId} has ${subscription.phases.length} phase(s) that would be updated`,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stripeSubscriptionService.updateSubscription(
|
||||
subscription.stripeSubscriptionId,
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: baseItem.stripeSubscriptionItemId,
|
||||
price: baseItem.stripePriceId,
|
||||
quantity: baseItem.quantity ?? 1,
|
||||
},
|
||||
// Stripe forbids changing usage_type in-place (metered → licensed),
|
||||
// so we delete the old metered item and add the new licensed one.
|
||||
{
|
||||
id: workflowItem.stripeSubscriptionItemId,
|
||||
deleted: true,
|
||||
},
|
||||
{
|
||||
price: targetResourceCreditPrice.stripePriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'none',
|
||||
},
|
||||
);
|
||||
|
||||
if (subscription.phases?.length > 0) {
|
||||
await this.migrateSubscriptionSchedulePhases({
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
[FeatureFlagKey.IS_BILLING_V2_ENABLED],
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.logger.log(`Workspace ${workspaceId} migrated to billing V2`);
|
||||
}
|
||||
|
||||
private async loadCatalog(): Promise<void> {
|
||||
const prices = await this.billingPriceRepository.find({
|
||||
where: { active: true },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
for (const price of prices) {
|
||||
if (!isDefined(price.billingProduct)) continue;
|
||||
|
||||
const { productKey, planKey } = price.billingProduct.metadata;
|
||||
|
||||
if (!isDefined(planKey) || !isDefined(price.interval)) continue;
|
||||
|
||||
const planIntervalKey = `${planKey}:${price.interval}`;
|
||||
|
||||
if (productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION) {
|
||||
const creditAmount = isDefined(price.metadata?.credit_amount)
|
||||
? Number(price.metadata.credit_amount)
|
||||
: isDefined(price.tiers?.[0]?.up_to)
|
||||
? Number(price.tiers![0].up_to)
|
||||
: null;
|
||||
|
||||
this.workflowPriceMap.set(price.stripePriceId, {
|
||||
planKey,
|
||||
interval: price.interval,
|
||||
creditAmount,
|
||||
});
|
||||
} else if (productKey === BillingProductKey.BASE_PRODUCT) {
|
||||
this.basePriceMap.set(planIntervalKey, price.stripePriceId);
|
||||
} else if (productKey === BillingProductKey.RESOURCE_CREDIT) {
|
||||
if (!this.resourceCreditBuckets.has(planIntervalKey)) {
|
||||
this.resourceCreditBuckets.set(planIntervalKey, []);
|
||||
}
|
||||
this.resourceCreditBuckets.get(planIntervalKey)!.push(price);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, bucket] of this.resourceCreditBuckets.entries()) {
|
||||
this.resourceCreditBuckets.set(
|
||||
key,
|
||||
bucket.sort(
|
||||
(a, b) =>
|
||||
Number(a.metadata?.credit_amount ?? 0) -
|
||||
Number(b.metadata?.credit_amount ?? 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Catalog loaded: ${this.workflowPriceMap.size} workflow prices, ` +
|
||||
`${this.resourceCreditBuckets.size} RESOURCE_CREDIT buckets, ` +
|
||||
`${this.basePriceMap.size} base prices`,
|
||||
);
|
||||
}
|
||||
|
||||
// Finds the RESOURCE_CREDIT price whose credit_amount is closest to the
|
||||
// reference credit amount. Falls back to the lowest bucket entry when
|
||||
// the reference amount is unknown.
|
||||
private resolveClosestResourceCreditPrice(
|
||||
referenceCreditAmount: number | null,
|
||||
bucket: BillingPriceEntity[],
|
||||
): BillingPriceEntity {
|
||||
if (!isDefined(referenceCreditAmount)) {
|
||||
return bucket[0];
|
||||
}
|
||||
|
||||
return bucket.reduce((best, candidate) => {
|
||||
const bestDelta = Math.abs(
|
||||
referenceCreditAmount - Number(best.metadata?.credit_amount ?? 0),
|
||||
);
|
||||
const candidateDelta = Math.abs(
|
||||
referenceCreditAmount - Number(candidate.metadata?.credit_amount ?? 0),
|
||||
);
|
||||
|
||||
return candidateDelta < bestDelta ? candidate : best;
|
||||
}, bucket[0]);
|
||||
}
|
||||
|
||||
// Releases the existing schedule then recreates it with the future phases
|
||||
// translated to RESOURCE_CREDIT prices. Each future phase is resolved
|
||||
// independently to handle plan/interval changes across phases.
|
||||
private async migrateSubscriptionSchedulePhases({
|
||||
stripeSubscriptionId,
|
||||
workspaceId,
|
||||
}: {
|
||||
stripeSubscriptionId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const subscriptionWithSchedule =
|
||||
await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule(
|
||||
stripeSubscriptionId,
|
||||
);
|
||||
|
||||
const schedule = subscriptionWithSchedule.schedule;
|
||||
|
||||
if (!isDefined(schedule) || typeof schedule === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const futurePhases = schedule.phases.filter(
|
||||
(phase) => (phase.start_date ?? 0) > nowSeconds,
|
||||
);
|
||||
|
||||
// Release the old schedule — the subscription items are already correct
|
||||
// from the stripe.subscriptions.update call above.
|
||||
await this.stripeSubscriptionScheduleService.releaseSubscriptionSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`workspace=${workspaceId} old schedule=${schedule.id} released`,
|
||||
);
|
||||
|
||||
if (futurePhases.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recreate a fresh schedule from the updated subscription, then attach
|
||||
// the migrated future phases to it.
|
||||
const { schedule: newSchedule, currentPhase } =
|
||||
await this.stripeSubscriptionScheduleService.createSubscriptionSchedule(
|
||||
stripeSubscriptionId,
|
||||
);
|
||||
|
||||
if (futurePhases.length > 1) {
|
||||
this.logger.warn(
|
||||
`workspace=${workspaceId} new schedule=${newSchedule.id} — ` +
|
||||
`${futurePhases.length} future phases found; only the first will be migrated`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextPhase = futurePhases[0];
|
||||
const nextPhaseParams =
|
||||
this.billingSubscriptionPhaseService.toPhaseUpdateParams(nextPhase);
|
||||
|
||||
const { price: phaseLicensedPriceId, quantity: phaseSeats } =
|
||||
this.billingSubscriptionPhaseService.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
|
||||
nextPhaseParams,
|
||||
);
|
||||
|
||||
const phaseWorkflowPriceId = nextPhase.items
|
||||
.map((item: Stripe.SubscriptionSchedule.Phase.Item) =>
|
||||
typeof item.price === 'string' ? item.price : item.price.id,
|
||||
)
|
||||
.find((priceId) => this.workflowPriceMap.has(priceId));
|
||||
|
||||
if (!isDefined(phaseWorkflowPriceId)) {
|
||||
this.logger.warn(
|
||||
`workspace=${workspaceId} new schedule=${newSchedule.id} — ` +
|
||||
`no WORKFLOW_NODE_EXECUTION price in future phase, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseWorkflowInfo = this.workflowPriceMap.get(phaseWorkflowPriceId)!;
|
||||
const phasePlanIntervalKey = `${phaseWorkflowInfo.planKey}:${phaseWorkflowInfo.interval}`;
|
||||
const phaseBucket = this.resourceCreditBuckets.get(phasePlanIntervalKey);
|
||||
|
||||
if (!isDefined(phaseBucket) || phaseBucket.length === 0) {
|
||||
this.logger.warn(
|
||||
`workspace=${workspaceId} new schedule=${newSchedule.id} — ` +
|
||||
`no RESOURCE_CREDIT prices for ${phasePlanIntervalKey}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseResourceCreditPriceId = this.resolveClosestResourceCreditPrice(
|
||||
phaseWorkflowInfo.creditAmount,
|
||||
phaseBucket,
|
||||
).stripePriceId;
|
||||
|
||||
await this.billingSubscriptionUpdateService.runSubscriptionScheduleUpdate({
|
||||
stripeScheduleId: newSchedule.id,
|
||||
toUpdateCurrentPrices: undefined,
|
||||
toUpdateNextPrices: {
|
||||
licensedPriceId: phaseLicensedPriceId,
|
||||
seats: phaseSeats,
|
||||
meteredPriceId: undefined,
|
||||
resourceCreditPriceId: phaseResourceCreditPriceId,
|
||||
},
|
||||
currentPhase:
|
||||
this.billingSubscriptionPhaseService.toPhaseUpdateParams(currentPhase),
|
||||
subscriptionCurrentPeriodEnd: nextPhase.start_date,
|
||||
isV2: true,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`workspace=${workspaceId} new schedule=${newSchedule.id} — future phase migrated`,
|
||||
this.logger.debug(
|
||||
`Workspace ${workspaceId}: already on resource-credit billing, nothing to do`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user