diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index 83326dbaf4c..60067dd3c55 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command.ts deleted file mode 100644 index f16dcc45ffd..00000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command.ts +++ /dev/null @@ -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, - protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, - @InjectRepository(BillingSubscription) - protected readonly billingSubscriptionRepository: Repository, - @InjectRepository(BillingProduct) - protected readonly billingProductRepository: Repository, - private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, - private readonly stripeSubscriptionService: StripeSubscriptionService, - private readonly twentyConfigService: TwentyConfigService, - ) { - super(workspaceRepository, twentyORMGlobalManager); - } - - override async runOnWorkspace({ - workspaceId, - options, - }: RunOnWorkspaceArgs): Promise { - 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; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-price.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-price.service.ts new file mode 100644 index 00000000000..573e171d39e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-price.service.ts @@ -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, + ) {} + + 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, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-phase.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-phase.service.ts index a9a88645920..71fed2bd72a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-phase.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-phase.service.ts @@ -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, 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 { 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, + ), }; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts index 2186fa51cae..b705536b9d1 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts @@ -30,15 +30,13 @@ import { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/t import { type BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type'; import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; +import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service'; -// Minimal fixtures (duplicate of service spec) function repoMock() { return { find: jest.fn(), findOne: jest.fn(), - findOneBy: jest.fn(), findOneOrFail: jest.fn(), - findOneByOrFail: jest.fn(), upsert: jest.fn(), update: jest.fn(), delete: jest.fn(), @@ -62,7 +60,7 @@ const METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID = const METER_PRICE_PRO_MONTH_TIER_LOW_ID = 'METER_PRICE_PRO_MONTH_TIER_LOW_ID'; const METER_PRICE_PRO_MONTH_TIER_HIGH_ID = 'METER_PRICE_PRO_MONTH_TIER_HIGH_ID'; -describe('BillingManager scenarios (via BillingSubscriptionService)', () => { +describe('BillingSubscriptionService', () => { let module: TestingModule; let service: BillingSubscriptionService; let billingSubscriptionRepository: Repository; @@ -106,7 +104,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { ], } as BillingSubscription; - const arrangeBillingPriceRepositoryFindOneByOrFail = () => { + const arrangeBillingPriceRepositoryFindOneOrFail = () => { const resolvePrice = (criteria: any) => { const priceId = criteria?.stripePriceId ?? criteria?.where?.stripePriceId ?? criteria; @@ -157,11 +155,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { return base as BillingPrice; }; - jest - .spyOn(billingPriceRepository, 'findOneByOrFail') - .mockImplementation(async (criteria: any) => resolvePrice(criteria)); - - jest + return jest .spyOn(billingPriceRepository, 'findOneOrFail') .mockImplementation(async (criteria: any) => resolvePrice(criteria)); }; @@ -367,7 +361,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { const spy = jest.spyOn(billingSubscriptionPhaseService, 'buildSnapshot'); snapshots.forEach((phase) => { - spy.mockReturnValueOnce(phase); + spy.mockResolvedValueOnce(phase); }); return spy; @@ -474,21 +468,16 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { } as BillingSubscription); const arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync = () => { - jest + const spy = jest .spyOn(stripeSubscriptionService, 'updateSubscription') .mockResolvedValueOnce({} as Stripe.Subscription); + jest .spyOn(service, 'syncSubscriptionToDatabase') .mockResolvedValueOnce({} as BillingSubscription); - }; - const arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval = () => - jest - .spyOn(stripeSubscriptionService, 'getBillingThresholdsByInterval') - .mockReturnValue({ - amount_gte: 10000, - reset_billing_cycle_anchor: false, - }); + return spy; + }; const arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule = () => @@ -507,60 +496,6 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }, } as unknown as SubscriptionWithSchedule); - const arrangeBillingSubscriptionRepositoryFindOneByOrFail = ({ - planKey = BillingPlanKey.PRO, - interval = SubscriptionInterval.Month, - licensedPriceId = LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId = METER_PRICE_PRO_MONTH_ID, - seats = 7, - workspaceId = 'ws_1', - stripeSubscriptionId = 'sub_1', - }: { - planKey?: BillingPlanKey; - interval?: SubscriptionInterval; - licensedPriceId?: string; - meteredPriceId?: string; - seats?: number; - workspaceId?: string; - stripeSubscriptionId?: string; - } = {}) => - jest - .spyOn(billingSubscriptionRepository, 'findOneByOrFail') - .mockResolvedValue({ - id: 'sub_db_param_by', - workspaceId, - stripeSubscriptionId, - status: SubscriptionStatus.Active, - interval, - billingSubscriptionItems: [ - { - stripeSubscriptionItemId: 'si_licensed', - stripeProductId: 'prod_base', - stripePriceId: licensedPriceId, - quantity: seats, - billingProduct: { - metadata: { - planKey, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - }, - { - stripeSubscriptionItemId: 'si_metered', - stripeProductId: 'prod_metered', - stripePriceId: meteredPriceId, - billingProduct: { - metadata: { - planKey, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - }, - ], - } as BillingSubscription); - const arrangeBillingSubscriptionPhaseServiceToSnapshot = ( licensedPriceId: string, meteredPriceId: string, @@ -630,18 +565,10 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { return spy; }; - const arrangeBillingSubscriptionPhaseServiceBuildSnapshot = ( - result: Stripe.SubscriptionScheduleUpdateParams.Phase, - ) => - jest - .spyOn(billingSubscriptionPhaseService, 'buildSnapshot') - .mockReturnValueOnce(result); - const arrangeServiceSyncSubscriptionToDatabase = () => jest .spyOn(service, 'syncSubscriptionToDatabase') .mockResolvedValue({} as BillingSubscription); - /* @license Enterprise */ beforeEach(async () => { module = await Test.createTestingModule({ @@ -684,7 +611,12 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { updateSubscription: jest.fn(), cancelSubscription: jest.fn(), collectLastInvoice: jest.fn(), - getBillingThresholdsByInterval: jest.fn().mockResolvedValue({ + }, + }, + { + provide: BillingPriceService, + useValue: { + getBillingThresholdsByMeterPriceId: jest.fn().mockResolvedValue({ amount_gte: 1000, reset_billing_cycle_anchor: false, }), @@ -772,75 +704,94 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { describe('changePlan', () => { describe('upgrade', () => { it('PRO -> ENTERPRISE without existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind(); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.PRO, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_ID, - quantity: 7, - }); - - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_ID, - quantity: 7, - }, - }, - ]); - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind(); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + const spyGetDetailsFromPhase = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.PRO, interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - } as Partial, - ]); + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + quantity: 7, + }); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + quantity: 7, + }, + }, + ]); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + + const spyBillingPriceFindOneOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + const spySubFindOneOrFail = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + const spyUpdateSubscription = + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); await service.changePlan({ id: 'ws_1' } as Workspace); expect( stripeSubscriptionService.updateSubscription, ).toHaveBeenCalledWith(currentSubscription.stripeSubscriptionId, { + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, items: [ { id: 'si_licensed', @@ -861,106 +812,147 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { stripeSubscriptionScheduleService.replaceEditablePhases, ).not.toHaveBeenCalled(); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(2); + expect(spyBillingPriceFindOneOrFail).toHaveBeenCalledTimes(1); + expect(spyGetDetailsFromPhase).toHaveBeenCalledTimes(1); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(2); + expect(spyGetProductPrices).toHaveBeenCalledTimes(1); + expect(spySubFindOneOrFail).toHaveBeenCalledTimes(1); + expect(spyUpdateSubscription).toHaveBeenCalledTimes(1); }); - // TODO: fix this test it's an issue in the business logic. It should pass as is. - // When the update from pro to enterprise with existing phases append the second phase must be updated too. - it.skip('PRO -> ENTERPRISE with existing phases', async () => { - arrangeBillingSubscriptionRepositoryFind(); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); - - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ - { - planKey: BillingPlanKey.PRO, - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, - meteredPriceId: METER_PRICE_PRO_YEAR_ID, - quantity: 7, - }, - { - planKey: BillingPlanKey.PRO, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_ID, - quantity: 7, - }, - ]); - - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { + it('PRO -> ENTERPRISE with existing phases', async () => { + const spyBillingSubscriptionRepositoryFind2 = + arrangeBillingSubscriptionRepositoryFind(); + const spyFindOrCreateSchedule2 = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + const spyGetDetailsFromPhaseSeq2 = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, meteredPriceId: METER_PRICE_PRO_YEAR_ID, quantity: 7, }, - nextEditable: { + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, meteredPriceId: METER_PRICE_PRO_MONTH_ID, quantity: 7, }, - }, - ]); - - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, + ]); + const spyGetEditablePhasesSeq2 = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, }, }, - } as Partial, - ]); + ]); + const spyGetProductPrices2 = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + const spyToSnapshot2 = arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_YEAR_ID, + METER_PRICE_ENTERPRISE_YEAR_ID, + ); + const spyPriceFindByOrFail2 = + arrangeBillingPriceRepositoryFindOneOrFail(); + const spySubFindByOrFail2 = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); - arrangeBillingPriceRepositoryFindOneByOrFail(); - - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + const spyBuildSnapshot2 = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); + const spyGetSubWithSchedule2 = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spySubFindOneOrFail2 = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + const spyUpdateSubscription2 = + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + const spySyncDB2 = arrangeServiceSyncSubscriptionToDatabase(); await service.changePlan({ id: 'ws_1' } as Workspace); expect( stripeSubscriptionService.updateSubscription, ).toHaveBeenCalledWith(currentSubscription.stripeSubscriptionId, { + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, items: [ { id: 'si_licensed', - price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7, }, - { id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID }, + { id: 'si_metered', price: METER_PRICE_ENTERPRISE_YEAR_ID }, ], metadata: { plan: 'ENTERPRISE' }, proration_behavior: 'create_prorations', @@ -971,159 +963,183 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { ).toHaveBeenCalledWith( 'scheduleId', expect.objectContaining({ - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + }), + nextPhase: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_MONTH_ID }, ], }), - nextPhase: expect.objectContaining({ - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_ID }, - ], - }), }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind2).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule2).toHaveBeenCalledTimes(2); + expect(spyGetDetailsFromPhaseSeq2).toHaveBeenCalledTimes(2); + expect(spyGetEditablePhasesSeq2).toHaveBeenCalledTimes(2); + expect(spyGetProductPrices2).toHaveBeenCalledTimes(2); + expect(spyToSnapshot2).toHaveBeenCalledTimes(1); + expect(spySubFindByOrFail2).toHaveBeenCalledTimes(2); + expect(spyBuildSnapshot2).toHaveBeenCalledTimes(1); + expect(spyGetSubWithSchedule2).toHaveBeenCalledTimes(3); + expect(spySubFindOneOrFail2).toHaveBeenCalledTimes(2); + expect(spyUpdateSubscription2).toHaveBeenCalledTimes(1); + expect(spySyncDB2).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail2).toHaveBeenCalledTimes(2); }); }); describe('downgrade', () => { it('ENTERPRISE -> PRO without existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - }); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + const spyBillingSubscriptionRepositoryFindD1 = + arrangeBillingSubscriptionRepositoryFind({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + }); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, - }); + const spyFindOrCreateScheduleD1 = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + const spyGetDetailsFromPhaseD1 = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, + const spyGetEditablePhasesD1 = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, }, - }, - ]); - - arrangeBillingProductServiceGetProductPricesSequence( - [ + ]); + const spyGetProductPricesSeqD1 = + arrangeBillingProductServiceGetProductPricesSequence( + [ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + [ + { + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + ); + const spyToSnapshotD1 = + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_ID, + ); + const spyBuildSnapshotD1 = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_ID }, ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - } as Partial, - ], - [ - { - stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.PRO, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - } as Partial, - { - stripePriceId: METER_PRICE_PRO_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.PRO, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - } as Partial, - ], - ); + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); - arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_MONTH_ID, - METER_PRICE_ENTERPRISE_MONTH_ID, - 7, - ); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_ID }, - ], - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase); + const spyPriceFindByOrFailD1 = + arrangeBillingPriceRepositoryFindOneOrFail(); + const spyGetSubWithScheduleD1 = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spySubFindByOrFailD1 = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + const spySyncDBD1 = arrangeServiceSyncSubscriptionToDatabase(); - arrangeServiceSyncSubscriptionToDatabase(); await service.changePlan({ id: 'ws_1' } as Workspace); expect( @@ -1131,7 +1147,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { ).toHaveBeenCalledWith( 'scheduleId', expect.objectContaining({ - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_MONTH_ID }, @@ -1146,148 +1162,171 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); - }); - it('ENTERPRISE -> PRO. with existing phases', async () => { - arrangeBillingSubscriptionRepositoryFind({ - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - planKey: BillingPlanKey.ENTERPRISE, - }); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ - { - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Year, + // verify arrange calls were useful (downgrade without existing phase) + expect(spyBillingSubscriptionRepositoryFindD1).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateScheduleD1).toHaveBeenCalledTimes(2); + expect(spyGetDetailsFromPhaseD1).toHaveBeenCalledTimes(1); + expect(spyGetEditablePhasesD1).toHaveBeenCalledTimes(2); + expect(spyGetProductPricesSeqD1).toHaveBeenCalledTimes(2); + expect(spyToSnapshotD1).toHaveBeenCalledTimes(1); + expect(spyBuildSnapshotD1).toHaveBeenCalledTimes(1); + expect(spyPriceFindByOrFailD1).toHaveBeenCalledTimes(2); + expect(spyGetSubWithScheduleD1).toHaveBeenCalledTimes(3); + expect(spySubFindByOrFailD1).toHaveBeenCalledTimes(1); + expect(spySyncDBD1).toHaveBeenCalledTimes(1); + }); + it('ENTERPRISE -> PRO with existing phases', async () => { + const spyBillingSubscriptionRepositoryFindD2 = + arrangeBillingSubscriptionRepositoryFind({ licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - }, - { planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - }, - ]); + }); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { + const spyFindOrCreateScheduleD2 = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + const spyGetDetailsFromPhaseSeqD2 = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, }, - }, - ]); + ]); - arrangeBillingProductServiceGetProductPricesSequence( - [ + const spyGetEditablePhasesSeqD2 = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, }, - } as Partial, + }, { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + ]); + const spyGetProductPricesSeqD2 = + arrangeBillingProductServiceGetProductPricesSequence( + [ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, }, + } as Partial, + ], + [ + { + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + ); + + const spyToSnapshotD2 = + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_YEAR_ID, + METER_PRICE_ENTERPRISE_YEAR_ID, + ); + const spyBuildSnapshotD2 = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_ID }, ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - } as Partial, - ], - [ - { - stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.PRO, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - } as Partial, - { - stripePriceId: METER_PRICE_PRO_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.PRO, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - } as Partial, - ], - ); + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); - arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_YEAR_ID, - METER_PRICE_ENTERPRISE_YEAR_ID, - ); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_ID }, - ], - } as Stripe.SubscriptionScheduleUpdateParams.Phase); + const spyPriceFindByOrFailD2 = + arrangeBillingPriceRepositoryFindOneOrFail(); + const spyGetSubWithScheduleD2 = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spySubFindByOrFailD2 = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); - - arrangeServiceSyncSubscriptionToDatabase(); + const spySyncDBD2 = arrangeServiceSyncSubscriptionToDatabase(); await service.changePlan({ id: 'ws_1' } as Workspace); @@ -1297,7 +1336,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { expect( stripeSubscriptionScheduleService.replaceEditablePhases, ).toHaveBeenCalledWith('scheduleId', { - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_YEAR_ID }, @@ -1311,6 +1350,19 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), }); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful (downgrade with existing phases) + expect(spyBillingSubscriptionRepositoryFindD2).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateScheduleD2).toHaveBeenCalledTimes(2); + expect(spyGetDetailsFromPhaseSeqD2).toHaveBeenCalledTimes(1); + expect(spyGetEditablePhasesSeqD2).toHaveBeenCalledTimes(2); + expect(spyGetProductPricesSeqD2).toHaveBeenCalledTimes(2); + expect(spyToSnapshotD2).toHaveBeenCalledTimes(1); + expect(spyBuildSnapshotD2).toHaveBeenCalledTimes(1); + expect(spyPriceFindByOrFailD2).toHaveBeenCalledTimes(2); + expect(spyGetSubWithScheduleD2).toHaveBeenCalledTimes(3); + expect(spySubFindByOrFailD2).toHaveBeenCalledTimes(1); + expect(spySyncDBD2).toHaveBeenCalledTimes(1); }); }); }); @@ -1318,58 +1370,63 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { describe('changeInterval', () => { describe('upgrade', () => { it('MONTHLY -> YEARLY without existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind(); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - }); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind(); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + const spyGetDetailsFromPhase1 = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + }); + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, }, - }, - ]); - arrangeBillingProductServiceGetProductPrices(); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + ]); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices(); + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + const spySubFindOneOrFail = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + const spyUpdateSubscription = + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spySubFindByOrFail = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, - }); - arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_YEAR_ID, - METER_PRICE_ENTERPRISE_YEAR_ID, - 7, - ); - arrangeServiceSyncSubscriptionToDatabase(); + const spyGetDetailsFromPhase2 = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); await service.changeInterval({ id: 'ws_1' } as Workspace); @@ -1389,134 +1446,156 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(3); + expect(spyGetDetailsFromPhase1).toHaveBeenCalledTimes(1); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(3); + expect(spyGetProductPrices).toHaveBeenCalledTimes(1); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(1); + expect(spySubFindOneOrFail).toHaveBeenCalledTimes(1); + expect(spyUpdateSubscription).toHaveBeenCalledTimes(1); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(3); + expect(spySubFindByOrFail).toHaveBeenCalledTimes(1); + expect(spyGetDetailsFromPhase2).toHaveBeenCalledTimes(1); + expect(spySyncDB).toHaveBeenCalledTimes(1); }); it('MONTHLY -> YEARLY with existing phases', async () => { - arrangeBillingSubscriptionRepositoryFind(); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind(); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, - }); + const spyGetDetailsFromPhase1 = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - }, - ]); - - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - interval: SubscriptionInterval.Year, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - interval: SubscriptionInterval.Year, - tiers: [ - { - up_to: 12000, - flat_amount: 12000, - unit_amount: null, - flat_amount_decimal: '1200000', - unit_amount_decimal: null, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '1200', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + nextEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, }, }, - } as Partial, - ]); + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + ]); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + tiers: [ + { + up_to: 12000, + flat_amount: 12000, + unit_amount: null, + flat_amount_decimal: '1200000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '1200', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); - arrangeBillingSubscriptionPhaseServiceToSnapshot( + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spySubFindByOrFail = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + const spyBuildSnapshot = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); + const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( LICENSE_PRICE_ENTERPRISE_MONTH_ID, METER_PRICE_ENTERPRISE_MONTH_ID, - 7, ); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); + const spySubFindOneOrFail = + arrangeBillingSubscriptionRepositoryFindOneOrFail({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + }); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_YEAR_ID }, - ], - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase); - - arrangeServiceSyncSubscriptionToDatabase(); - arrangeBillingSubscriptionRepositoryFindOneOrFail({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - }); - - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }); + const spyGetDetailsFromPhase2 = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }); await service.changeInterval({ id: 'ws_1' } as Workspace); @@ -1526,7 +1605,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { ).toHaveBeenCalledWith( 'scheduleId', expect.objectContaining({ - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_MONTH_ID }, @@ -1541,110 +1620,136 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(3); + expect(spyGetDetailsFromPhase1).toHaveBeenCalledTimes(2); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(3); + expect(spyGetProductPrices).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(2); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(4); + expect(spySubFindByOrFail).toHaveBeenCalledTimes(2); + expect(spyBuildSnapshot).toHaveBeenCalledTimes(1); + expect(spyToSnapshot).toHaveBeenCalledTimes(1); + expect(spySyncDB).toHaveBeenCalledTimes(2); + expect(spySubFindOneOrFail).toHaveBeenCalledTimes(2); + expect(spyGetDetailsFromPhase2).toHaveBeenCalledTimes(2); }); }); describe('downgrade', () => { it('YEARLY -> MONTHLY without existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind({ - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - planKey: BillingPlanKey.ENTERPRISE, - }); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind({ + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + }); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }); + const spyGetDetailsFromPhase = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - }, - ]); - - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, }, }, - } as Partial, - ]); + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + ]); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); - arrangeBillingSubscriptionPhaseServiceToSnapshot( + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spySubFindByOrFail = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + + const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( LICENSE_PRICE_ENTERPRISE_YEAR_ID, METER_PRICE_ENTERPRISE_YEAR_ID, 7, ); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_ID }, - ], - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase); + const spyBuildSnapshot = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); - arrangeServiceSyncSubscriptionToDatabase(); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); await service.changeInterval({ id: 'ws_1' } as Workspace); @@ -1654,7 +1759,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { expect( stripeSubscriptionScheduleService.replaceEditablePhases, ).toHaveBeenCalledWith('scheduleId', { - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_YEAR_ID }, @@ -1668,125 +1773,149 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), }); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(3); + expect(spyGetDetailsFromPhase).toHaveBeenCalledTimes(1); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(3); + expect(spyGetProductPrices).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(2); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(4); + expect(spySubFindByOrFail).toHaveBeenCalledTimes(1); + expect(spyToSnapshot).toHaveBeenCalledTimes(1); + expect(spyBuildSnapshot).toHaveBeenCalledTimes(1); + expect(spySyncDB).toHaveBeenCalledTimes(1); }); it('YEARLY -> MONTHLY with existing phases', async () => { - arrangeBillingSubscriptionRepositoryFind({ - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - planKey: BillingPlanKey.ENTERPRISE, - }); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind({ + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + }); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }); + const spyGetDetailsFromPhase = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, - meteredPriceId: METER_PRICE_PRO_YEAR_ID, - quantity: 7, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, - meteredPriceId: METER_PRICE_PRO_YEAR_ID, - quantity: 7, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, - meteredPriceId: METER_PRICE_PRO_YEAR_ID, - quantity: 7, - }, - }, - ]); - - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, }, }, - } as Partial, - ]); + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, + }, + }, + ]); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); - arrangeBillingSubscriptionPhaseServiceToSnapshot( + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spySubFindByOrFail = + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + + const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( LICENSE_PRICE_ENTERPRISE_YEAR_ID, METER_PRICE_ENTERPRISE_YEAR_ID, 7, ); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_ID }, - ], - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase); + const spyBuildSnapshot = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); - arrangeServiceSyncSubscriptionToDatabase(); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); await service.changeInterval({ id: 'ws_1' } as Workspace); @@ -1796,7 +1925,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { expect( stripeSubscriptionScheduleService.replaceEditablePhases, ).toHaveBeenCalledWith('scheduleId', { - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_YEAR_ID }, @@ -1810,6 +1939,19 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), }); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(3); + expect(spyGetDetailsFromPhase).toHaveBeenCalledTimes(2); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(3); + expect(spyGetProductPrices).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(2); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(4); + expect(spySubFindByOrFail).toHaveBeenCalledTimes(1); + expect(spyToSnapshot).toHaveBeenCalledTimes(1); + expect(spyBuildSnapshot).toHaveBeenCalledTimes(1); + expect(spySyncDB).toHaveBeenCalledTimes(1); }); }); }); @@ -1817,67 +1959,22 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { describe('changeMeteredPrice', () => { describe('upgrade', () => { it('without existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - }); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - quantity: 7, - meteredTiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - }); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - quantity: 7, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - quantity: 7, - }, - }, - ]); - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + }); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + const spyGetDetailsFromPhase = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - interval: SubscriptionInterval.Month, - tiers: [ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + meteredTiers: [ { up_to: 1000, flat_amount: 1000, @@ -1893,53 +1990,105 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { unit_amount_decimal: '100', }, ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + }); + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 5000, - flat_amount: 5000, - unit_amount: null, - flat_amount_decimal: '500000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '500', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, }, }, - } as Partial, - ]); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); - arrangeBillingSubscriptionPhaseServiceToSnapshot( + ]); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spyUpdateSubscription = + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( LICENSE_PRICE_ENTERPRISE_MONTH_ID, METER_PRICE_ENTERPRISE_MONTH_ID, 7, ); - arrangeServiceSyncSubscriptionToDatabase(); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); await service.changeMeteredPrice( { id: 'ws_1' } as Workspace, @@ -1952,7 +2101,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { currentSubscription.stripeSubscriptionId, expect.objectContaining({ billing_thresholds: { - amount_gte: 10000, + amount_gte: 1000, reset_billing_cycle_anchor: false, }, items: [ @@ -1970,193 +2119,216 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(2); + expect(spyGetDetailsFromPhase).toHaveBeenCalledTimes(1); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(2); + expect(spyGetProductPrices).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(3); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(3); + expect(spyUpdateSubscription).toHaveBeenCalledTimes(1); + expect(spyToSnapshot).toHaveBeenCalledTimes(1); + expect(spySyncDB).toHaveBeenCalledTimes(2); }); it('with existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - }); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ - { + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind({ planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - quantity: 7, - meteredTiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - }, - { - planKey: BillingPlanKey.PRO, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, - quantity: 7, - meteredTiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - }, - ]); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { + }); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + const spyGetDetailsFromPhaseSeq = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], }, - nextEditable: { + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, - quantity: 7, - }, - }, - ]); - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, + ]); + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 5000, - flat_amount: 5000, - unit_amount: null, - flat_amount_decimal: '500000', - unit_amount_decimal: null, + ]); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '500', + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, }, - }, - } as Partial, - ]); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + } as Partial, + ]); + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spyUpdateSubscription = + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); // Snapshot courant (phase actuelle) - arrangeBillingSubscriptionPhaseServiceToSnapshot( + const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( LICENSE_PRICE_ENTERPRISE_MONTH_ID, METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, 7, ); - arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ - { - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, - ], - } as Stripe.SubscriptionScheduleUpdateParams.Phase, - { - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, - ], - } as Stripe.SubscriptionScheduleUpdateParams.Phase, - ]); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, - ], - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase); + const spyBuildSnapshotSeq = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); + const spyBuildSnapshot = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); - arrangeServiceSyncSubscriptionToDatabase(); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); await service.changeMeteredPrice( { id: 'ws_1' } as Workspace, @@ -2169,7 +2341,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { ).toHaveBeenCalledWith( 'scheduleId', expect.objectContaining({ - currentSnapshot: { + currentPhaseSnapshot: { items: [ { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, @@ -2184,137 +2356,159 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(2); + expect(spyGetDetailsFromPhaseSeq).toHaveBeenCalledTimes(3); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(2); + expect(spyGetProductPrices).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(3); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(3); + expect(spyUpdateSubscription).toHaveBeenCalledTimes(1); + expect(spyToSnapshot).toHaveBeenCalledTimes(2); + expect(spyBuildSnapshotSeq).toHaveBeenCalledTimes(2); + expect(spyBuildSnapshot).toHaveBeenCalledTimes(2); + expect(spySyncDB).toHaveBeenCalledTimes(2); }); }); describe('downgrade', () => { it('without existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - }); - - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ - { + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind({ planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - quantity: 7, - meteredTiers: [ - { - up_to: 120_000_000, - flat_amount: 10_000, - unit_amount: null, - flat_amount_decimal: '1000000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - }, - ]); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID }, - ], - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { + }); + + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + const spyGetDetailsFromPhaseSeq = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, quantity: 7, + meteredTiers: [ + { + up_to: 120_000_000, + flat_amount: 10_000, + unit_amount: null, + flat_amount_decimal: '1000000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], }, - }, - ]); - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, + ]); + const spyBuildSnapshot = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 120_000_000, - flat_amount: 10_000, - unit_amount: null, - flat_amount_decimal: '1000000', - unit_amount_decimal: null, + ]); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 120_000_000, + flat_amount: 10_000, + unit_amount: null, + flat_amount_decimal: '1000000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, }, - }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 5000, - flat_amount: 5000, - unit_amount: null, - flat_amount_decimal: '500000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '500', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - } as Partial, - ]); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); - arrangeBillingSubscriptionPhaseServiceToSnapshot( + } as Partial, + ]); + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( LICENSE_PRICE_ENTERPRISE_MONTH_ID, METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, 7, ); - arrangeServiceSyncSubscriptionToDatabase(); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); await service.changeMeteredPrice( { id: 'ws_1' } as Workspace, @@ -2326,7 +2520,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { ).toHaveBeenCalledWith( 'scheduleId', expect.objectContaining({ - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, @@ -2341,193 +2535,216 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(1); + expect(spyGetDetailsFromPhaseSeq).toHaveBeenCalledTimes(1); + expect(spyBuildSnapshot).toHaveBeenCalledTimes(1); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(1); + expect(spyGetProductPrices).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(3); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(2); + expect(spyToSnapshot).toHaveBeenCalledTimes(1); + expect(spySyncDB).toHaveBeenCalledTimes(1); }); it('with existing phase', async () => { - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - }); - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ - { + const spyBillingSubscriptionRepositoryFind = + arrangeBillingSubscriptionRepositoryFind({ planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - quantity: 7, - meteredTiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - }, - { - planKey: BillingPlanKey.PRO, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, - quantity: 7, - meteredTiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, - }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - }, - ]); - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { + }); + const spyFindOrCreateSchedule = + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + const spyGetDetailsFromPhaseSeq = + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], }, - nextEditable: { + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - quantity: 7, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, - quantity: 7, - }, - }, - ]); - arrangeBillingProductServiceGetProductPrices([ - { - stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - interval: SubscriptionInterval.Month, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, + ]); + const spyGetEditablePhases = + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 1000, - flat_amount: 1000, - unit_amount: null, - flat_amount_decimal: '100000', - unit_amount_decimal: null, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '100', - }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, }, }, - } as Partial, - { - stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - interval: SubscriptionInterval.Month, - tiers: [ - { - up_to: 5000, - flat_amount: 5000, - unit_amount: null, - flat_amount_decimal: '500000', - unit_amount_decimal: null, + ]); + const spyGetProductPrices = + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, }, - { - up_to: null, - flat_amount: null, - unit_amount: null, - flat_amount_decimal: null, - unit_amount_decimal: '500', + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, }, - ], - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, }, - }, - } as Partial, - ]); - arrangeBillingPriceRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - arrangeBillingSubscriptionRepositoryFindOneByOrFail(); - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + } as Partial, + ]); + const spyPriceFindByOrFail = + arrangeBillingPriceRepositoryFindOneOrFail(); + + const spyGetSubWithSchedule = + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + const spyUpdateSubscription = + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); // Snapshot courant (phase actuelle) - arrangeBillingSubscriptionPhaseServiceToSnapshot( + const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( LICENSE_PRICE_ENTERPRISE_MONTH_ID, METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, 7, ); - arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ - { - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, - ], - } as Stripe.SubscriptionScheduleUpdateParams.Phase, - { - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_TIER_LOW_ID }, - ], - } as Stripe.SubscriptionScheduleUpdateParams.Phase, - ]); - arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, - ], - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase); + const spyBuildSnapshotSeq = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_LOW_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); + const spyBuildSnapshot = + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); - arrangeServiceSyncSubscriptionToDatabase(); + const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); await service.changeMeteredPrice( { id: 'ws_1' } as Workspace, @@ -2539,7 +2756,7 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { ).toHaveBeenCalledWith( 'scheduleId', expect.objectContaining({ - currentSnapshot: expect.objectContaining({ + currentPhaseSnapshot: expect.objectContaining({ items: [ { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, @@ -2554,6 +2771,20 @@ describe('BillingManager scenarios (via BillingSubscriptionService)', () => { }), ); expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + + // verify arrange calls were useful + expect(spyBillingSubscriptionRepositoryFind).toHaveBeenCalledTimes(1); + expect(spyFindOrCreateSchedule).toHaveBeenCalledTimes(2); + expect(spyGetDetailsFromPhaseSeq).toHaveBeenCalledTimes(3); + expect(spyGetEditablePhases).toHaveBeenCalledTimes(2); + expect(spyGetProductPrices).toHaveBeenCalledTimes(2); + expect(spyPriceFindByOrFail).toHaveBeenCalledTimes(3); + expect(spyGetSubWithSchedule).toHaveBeenCalledTimes(3); + expect(spyUpdateSubscription).toHaveBeenCalledTimes(1); + expect(spyToSnapshot).toHaveBeenCalledTimes(2); + expect(spyBuildSnapshotSeq).toHaveBeenCalledTimes(2); + expect(spyBuildSnapshot).toHaveBeenCalledTimes(2); + expect(spySyncDB).toHaveBeenCalledTimes(2); }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index 2940b4268f2..70e766a0f1d 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -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 { - 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, }); } diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts index 3ac62783cbc..437a2a1bed6 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts @@ -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 { @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts index 704081662a1..d80a205a944 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts @@ -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, }; } diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index fda94ae4ecf..4433a24cd25 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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',