fix(billing): refactor and update subscription phase handling logic (#14783)
This commit is contained in:
@@ -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,
|
||||
|
||||
-180
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1590
-1359
File diff suppressed because it is too large
Load Diff
+91
-52
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
+2
-5
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user