diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 9695197ada..d441fe8e36 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -6,7 +6,7 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; +import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input'; import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input'; import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input'; @@ -24,8 +24,8 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; -import { UserEntity } from 'src/engine/core-modules/user/user.entity'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; @@ -144,7 +144,7 @@ export class BillingResolver { async switchSubscriptionInterval( @AuthWorkspace() workspace: WorkspaceEntity, ) { - await this.billingSubscriptionService.changeInterval(workspace); + await this.billingSubscriptionService.changeInterval(workspace.id); return { billingSubscriptions: @@ -164,7 +164,7 @@ export class BillingResolver { SettingsPermissionGuard(PermissionFlagType.BILLING), ) async switchBillingPlan(@AuthWorkspace() workspace: WorkspaceEntity) { - await this.billingSubscriptionService.changePlan(workspace); + await this.billingSubscriptionService.changePlan(workspace.id); return { billingSubscriptions: @@ -184,7 +184,7 @@ export class BillingResolver { SettingsPermissionGuard(PermissionFlagType.BILLING), ) async cancelSwitchBillingPlan(@AuthWorkspace() workspace: WorkspaceEntity) { - await this.billingSubscriptionService.cancelSwitchPlan(workspace); + await this.billingSubscriptionService.cancelSwitchPlan(workspace.id); return { billingSubscriptions: @@ -206,7 +206,7 @@ export class BillingResolver { async cancelSwitchBillingInterval( @AuthWorkspace() workspace: WorkspaceEntity, ) { - await this.billingSubscriptionService.cancelSwitchInterval(workspace); + await this.billingSubscriptionService.cancelSwitchInterval(workspace.id); return { billingSubscriptions: @@ -230,7 +230,7 @@ export class BillingResolver { @Args() { priceId }: BillingUpdateSubscriptionItemPriceInput, ) { await this.billingSubscriptionService.changeMeteredPrice( - workspace, + workspace.id, priceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts index 9ecf62c580..c56ef9d641 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts @@ -9,7 +9,7 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; export type UpdateSubscriptionQuantityJobData = { workspaceId: string }; @Processor({ @@ -45,14 +45,9 @@ export class UpdateSubscriptionQuantityJob { } try { - const billingBaseProductSubscriptionItem = - await this.billingSubscriptionService.getBaseProductCurrentBillingSubscriptionItemOrThrow( - data.workspaceId, - ); - - await this.stripeSubscriptionItemService.updateSubscriptionItem( - billingBaseProductSubscriptionItem.stripeSubscriptionItemId, - { quantity: workspaceMembersCount }, + await this.billingSubscriptionService.changeSeats( + data.workspaceId, + workspaceMembersCount, ); this.logger.log( diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription.service.spec.ts new file mode 100644 index 0000000000..e41328ca34 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription.service.spec.ts @@ -0,0 +1,1789 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { type Repository } from 'typeorm'; + +import type Stripe from 'stripe'; + +import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; +import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; +import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; +import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; +import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service'; +import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; +import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service'; +import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; +import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; +import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; +import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; +import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; +import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +import { + arrangeBillingPriceRepositoryFindOneOrFail, + arrangeBillingProductServiceGetProductPrices, + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams, + arrangeBillingSubscriptionRepositoryFindOneOrFail, + arrangeStripeSubscriptionScheduleServiceCreateSubscriptionSchedule, + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule, + buildBillingPriceEntity, + buildDefaultMeteredTiers, + buildSchedulePhase, + repoMock, +} from './utils/mock-builders.util'; +import { + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + LICENSE_PRICE_ENTERPRISE_YEAR_ID, + LICENSE_PRICE_PRO_MONTH_ID, + LICENSE_PRICE_PRO_YEAR_ID, + METER_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + METER_PRICE_ENTERPRISE_YEAR_ID, + METER_PRICE_PRO_MONTH_ID, + METER_PRICE_PRO_MONTH_TIER_HIGH_ID, + METER_PRICE_PRO_MONTH_TIER_LOW_ID, + METER_PRICE_PRO_YEAR_ID, +} from './utils/price.constants'; + +describe('BillingSubscriptionService', () => { + let module: TestingModule; + let service: BillingSubscriptionService; + let billingSubscriptionRepository: jest.Mocked< + Repository + >; + let billingPriceRepository: jest.Mocked>; + let billingProductService: jest.Mocked; + let billingPriceService: BillingPriceService; + let stripeSubscriptionScheduleService: jest.Mocked; + let stripeSubscriptionService: jest.Mocked; + let billingSubscriptionPhaseService: jest.Mocked; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + BillingSubscriptionService, + { + provide: BillingPlanService, + useValue: { + getPlanBaseProduct: jest.fn(), + listPlans: jest.fn(), + getPlanByPriceId: jest.fn(), + getPricesPerPlanByInterval: jest.fn(), + }, + }, + { + provide: BillingProductService, + useValue: { + getProductPrices: jest.fn(), + }, + }, + { + provide: StripeCustomerService, + useValue: { + hasPaymentMethod: jest.fn(), + }, + }, + { provide: StripeSubscriptionItemService, useValue: {} }, + { + provide: StripeSubscriptionScheduleService, + useValue: { + loadSubscriptionSchedule: jest.fn(), + createSubscriptionSchedule: jest.fn(), + updateSchedule: jest.fn().mockResolvedValue({}), + releaseSubscriptionSchedule: jest.fn(), + getSubscriptionWithSchedule: jest.fn(), + }, + }, + { + provide: StripeSubscriptionService, + useValue: { + updateSubscription: jest.fn().mockResolvedValue({}), + cancelSubscription: jest.fn(), + collectLastInvoice: jest.fn(), + }, + }, + { + provide: BillingSubscriptionPhaseService, + useValue: { + toPhaseUpdateParams: jest.fn(), + buildPhaseUpdateParams: jest + .fn() + .mockImplementation( + async ({ + licensedStripePriceId, + seats, + meteredStripePriceId, + startDate, + endDate, + }) => ({ + start_date: startDate, + ...(endDate ? { end_date: endDate } : {}), + proration_behavior: 'none', + items: [ + { price: licensedStripePriceId, quantity: seats }, + { price: meteredStripePriceId }, + ], + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }), + ), + isSamePhaseSignature: jest.fn().mockResolvedValue(false), + }, + }, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn().mockReturnValue(0), + }, + }, + { + provide: getRepositoryToken(BillingEntitlementEntity), + useValue: repoMock(), + }, + { + provide: getRepositoryToken(BillingSubscriptionEntity), + useValue: repoMock(), + }, + { + provide: getRepositoryToken(BillingPriceEntity), + useValue: repoMock(), + }, + { + provide: getRepositoryToken(BillingSubscriptionItemEntity), + useValue: repoMock(), + }, + { + provide: getRepositoryToken(BillingCustomerEntity), + useValue: repoMock(), + }, + BillingPriceService, + ], + }).compile(); + + service = module.get(BillingSubscriptionService); + billingSubscriptionRepository = module.get( + getRepositoryToken(BillingSubscriptionEntity), + ); + billingPriceRepository = module.get(getRepositoryToken(BillingPriceEntity)); + billingProductService = module.get(BillingProductService); + billingPriceService = module.get(BillingPriceService); + stripeSubscriptionScheduleService = module.get( + StripeSubscriptionScheduleService, + ); + stripeSubscriptionService = module.get(StripeSubscriptionService); + billingSubscriptionPhaseService = module.get( + BillingSubscriptionPhaseService, + ); + + jest + .spyOn(service, 'syncSubscriptionToDatabase') + .mockResolvedValue({} as BillingSubscriptionEntity); + + jest + .spyOn(billingPriceService, 'getBillingThresholdsByMeterPriceId') + .mockResolvedValue({ + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('updateSubscription - Plan update', () => { + it('should update from PRO to ENTERPRISE - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.PLAN, + newPlan: BillingPlanKey.ENTERPRISE, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + quantity: 1, + }, + { id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + proration_behavior: 'create_prorations', + metadata: { plan: BillingPlanKey.ENTERPRISE }, + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should update from PRO to ENTERPRISE - with schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + [LICENSE_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 1, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 1, + }); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + const refreshedCurrentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + seats: 1, + }); + + jest + .spyOn(stripeSubscriptionScheduleService, 'loadSubscriptionSchedule') + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }) + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase: refreshedCurrentPhase, + nextPhase, + }); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.PLAN, + newPlan: BillingPlanKey.ENTERPRISE, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + quantity: 1, + }, + { id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + proration_behavior: 'create_prorations', + metadata: { plan: BillingPlanKey.ENTERPRISE }, + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 1 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + }), + ], + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should update from ENTERPRISE to PRO - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }); + + arrangeStripeSubscriptionScheduleServiceCreateSubscriptionSchedule( + stripeSubscriptionScheduleService, + currentPhase, + ); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.PLAN, + newPlan: BillingPlanKey.PRO, + }); + + expect( + stripeSubscriptionScheduleService.createSubscriptionSchedule, + ).toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 1 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + }), + ], + }); + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should update from ENTERPRISE to PRO - with schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + [LICENSE_PRICE_ENTERPRISE_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + seats: 1, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + { + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }, + ); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.PLAN, + newPlan: BillingPlanKey.PRO, + }); + + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 1 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + }), + ], + }); + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); + + describe('updateSubscription - Metered price update', () => { + it('should change metered price from low cap to high cap - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_TIER_LOW_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(1000), + }), + [METER_PRICE_PRO_MONTH_TIER_HIGH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_TIER_HIGH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(10000), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.METERED_PRICE, + newMeteredPriceId: METER_PRICE_PRO_MONTH_TIER_HIGH_ID, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_PRO_MONTH_ID, + quantity: 1, + }, + { id: 'si_metered', price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + proration_behavior: 'create_prorations', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change metered price from low cap to high cap - with schedule (ENTERPRISE to PRO downgrade)', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(1000), + }), + [METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(10000), + }), + [LICENSE_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(1000), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + seats: 1, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 1, + }); + + jest + .spyOn(stripeSubscriptionScheduleService, 'loadSubscriptionSchedule') + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }) + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase: buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + seats: 1, + }), + nextPhase, + }); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(10000), + }) as BillingPriceEntity, + ]); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.METERED_PRICE, + newMeteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + quantity: 1, + }, + { + id: 'si_metered', + price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + }, + ], + proration_behavior: 'create_prorations', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_YEAR_ID, quantity: 1 }, + { price: METER_PRICE_PRO_YEAR_ID }, + ], + }), + ], + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change metered price from high cap to low cap - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_HIGH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_TIER_HIGH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_TIER_HIGH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(10000), + }), + [METER_PRICE_PRO_MONTH_TIER_LOW_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(1000), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_HIGH_ID, + seats: 1, + }); + + arrangeStripeSubscriptionScheduleServiceCreateSubscriptionSchedule( + stripeSubscriptionScheduleService, + currentPhase, + ); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.METERED_PRICE, + newMeteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + }); + + expect( + stripeSubscriptionScheduleService.createSubscriptionSchedule, + ).toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 1 }, + { price: METER_PRICE_PRO_MONTH_TIER_LOW_ID }, + ], + }), + ], + }); + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change metered price from high cap to low cap - with schedule (ENTERPRISE to PRO downgrade)', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(10000), + }), + [METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(1000), + }), + [LICENSE_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(10000), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + seats: 1, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 1, + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + { + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }, + ); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(1000), + }) as BillingPriceEntity, + ]); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.METERED_PRICE, + newMeteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + }); + + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_YEAR_ID, quantity: 1 }, + { price: METER_PRICE_PRO_YEAR_ID }, + ], + }), + ], + }); + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); + + describe('updateSubscription - Interval update', () => { + it('should change interval from monthly to yearly - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.INTERVAL, + newInterval: SubscriptionInterval.Year, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_PRO_YEAR_ID, + quantity: 1, + }, + { id: 'si_metered', price: METER_PRICE_PRO_YEAR_ID }, + ], + proration_behavior: 'create_prorations', + billing_cycle_anchor: 'now', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change interval from monthly to yearly - with schedule (ENTERPRISE monthly to PRO yearly downgrade)', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + [LICENSE_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 1, + }); + + jest + .spyOn(stripeSubscriptionScheduleService, 'loadSubscriptionSchedule') + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }) + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase: buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + seats: 1, + }), + nextPhase, + }); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.INTERVAL, + newInterval: SubscriptionInterval.Year, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + quantity: 1, + }, + { id: 'si_metered', price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + proration_behavior: 'create_prorations', + billing_cycle_anchor: 'now', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_YEAR_ID, quantity: 1 }, + { price: METER_PRICE_PRO_YEAR_ID }, + ], + }), + ], + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change interval from yearly to monthly - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 1, + }); + + arrangeStripeSubscriptionScheduleServiceCreateSubscriptionSchedule( + stripeSubscriptionScheduleService, + currentPhase, + ); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.INTERVAL, + newInterval: SubscriptionInterval.Month, + }); + + expect( + stripeSubscriptionScheduleService.createSubscriptionSchedule, + ).toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 1 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + }), + ], + }); + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change interval from yearly to monthly - with schedule (ENTERPRISE yearly to PRO monthly downgrade)', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_ENTERPRISE_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + seats: 1, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 1, + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + { + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }, + ); + + arrangeBillingProductServiceGetProductPrices(billingProductService, [ + buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }) as BillingPriceEntity, + buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }) as BillingPriceEntity, + ]); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.INTERVAL, + newInterval: SubscriptionInterval.Month, + }); + + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 1 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + }), + ], + }); + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); + + describe('updateSubscription - Seats update', () => { + it('should change seats from 1 to 2 - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.SEATS, + newSeats: 2, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_PRO_MONTH_ID, + quantity: 2, + }, + { id: 'si_metered', price: METER_PRICE_PRO_MONTH_ID }, + ], + proration_behavior: 'create_prorations', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change seats from 1 to 2 - with schedule (ENTERPRISE monthly to PRO yearly downgrade)', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_ENTERPRISE_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + [LICENSE_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 1, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 1, + }); + + jest + .spyOn(stripeSubscriptionScheduleService, 'loadSubscriptionSchedule') + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }) + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase: buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + seats: 2, + }), + nextPhase, + }); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.SEATS, + newSeats: 2, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + quantity: 2, + }, + { id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + proration_behavior: 'create_prorations', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_YEAR_ID, quantity: 2 }, + { price: METER_PRICE_PRO_YEAR_ID }, + ], + }), + ], + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change seats from 2 to 1 - without schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 2, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule( + stripeSubscriptionScheduleService, + {}, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.SEATS, + newSeats: 1, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_PRO_MONTH_ID, + quantity: 1, + }, + { id: 'si_metered', price: METER_PRICE_PRO_MONTH_ID }, + ], + proration_behavior: 'create_prorations', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + + it('should change seats from 2 to 1 - with schedule', async () => { + arrangeBillingSubscriptionRepositoryFindOneOrFail( + billingSubscriptionRepository, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 2, + }, + ); + + arrangeBillingPriceRepositoryFindOneOrFail(billingPriceRepository, { + [LICENSE_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: false, + }), + [METER_PRICE_PRO_MONTH_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_MONTH_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + [LICENSE_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: LICENSE_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: false, + }), + [METER_PRICE_PRO_YEAR_ID]: buildBillingPriceEntity({ + stripePriceId: METER_PRICE_PRO_YEAR_ID, + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + isMetered: true, + tiers: buildDefaultMeteredTiers(), + }), + }); + + const currentPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 2, + }); + const nextPhase = buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + seats: 2, + }); + + jest + .spyOn(stripeSubscriptionScheduleService, 'loadSubscriptionSchedule') + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase, + nextPhase, + }) + .mockResolvedValueOnce({ + schedule: { id: 'schedule_1' } as Stripe.SubscriptionSchedule, + currentPhase: buildSchedulePhase({ + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + seats: 1, + }), + nextPhase, + }); + + arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams( + billingSubscriptionPhaseService, + { + items: currentPhase.items, + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ); + + await service.updateSubscription('sub_db_1', { + type: SubscriptionUpdateType.SEATS, + newSeats: 1, + }); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith( + 'sub_1', + { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_PRO_MONTH_ID, + quantity: 1, + }, + { id: 'si_metered', price: METER_PRICE_PRO_MONTH_ID }, + ], + proration_behavior: 'create_prorations', + billing_thresholds: { + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }, + }, + ); + expect( + stripeSubscriptionScheduleService.updateSchedule, + ).toHaveBeenCalledWith('schedule_1', { + phases: [ + expect.objectContaining({ items: currentPhase.items }), + expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_YEAR_ID, quantity: 1 }, + { price: METER_PRICE_PRO_YEAR_ID }, + ], + }), + ], + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/build-subscription.util.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/build-subscription.util.ts new file mode 100644 index 0000000000..76afd24c02 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/build-subscription.util.ts @@ -0,0 +1,66 @@ +import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; + +import { + LICENSE_PRICE_PRO_MONTH_ID, + METER_PRICE_PRO_MONTH_ID, +} from './price.constants'; + +export const buildSubscription = ({ + planKey = BillingPlanKey.PRO, + interval = SubscriptionInterval.Month, + licensedPriceId = LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId = METER_PRICE_PRO_MONTH_ID, + seats = 1, + workspaceId = 'ws_1', + stripeSubscriptionId = 'sub_1', + currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), +}: { + planKey?: BillingPlanKey; + interval?: SubscriptionInterval; + licensedPriceId?: string; + meteredPriceId?: string; + seats?: number; + workspaceId?: string; + stripeSubscriptionId?: string; + currentPeriodEnd?: Date; +} = {}): BillingSubscriptionEntity => + ({ + id: 'sub_db_1', + workspaceId, + stripeSubscriptionId, + status: SubscriptionStatus.Active, + interval, + currentPeriodEnd, + 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 BillingSubscriptionEntity; diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/mock-builders.util.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/mock-builders.util.ts new file mode 100644 index 0000000000..a0f1e7fb74 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/mock-builders.util.ts @@ -0,0 +1,180 @@ +import { type ObjectLiteral, type Repository } from 'typeorm'; + +import type Stripe from 'stripe'; + +import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; +import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; +import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { type BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; +import { type BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service'; +import { type StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; +import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; + +import { buildSubscription } from './build-subscription.util'; + +export const repoMock = () => + ({ + find: jest.fn(), + findOne: jest.fn(), + findOneOrFail: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }) as unknown as jest.Mocked>; + +export const buildBillingPriceEntity = ({ + stripePriceId, + planKey, + interval, + isMetered, + tiers, +}: { + stripePriceId: string; + planKey: BillingPlanKey; + interval: SubscriptionInterval; + isMetered: boolean; + tiers?: Stripe.Price.Tier[]; +}): BillingPriceEntity | BillingMeterPrice => + ({ + stripePriceId, + interval, + billingProduct: { + metadata: { + planKey, + productKey: isMetered + ? BillingProductKey.WORKFLOW_NODE_EXECUTION + : BillingProductKey.BASE_PRODUCT, + priceUsageBased: isMetered + ? BillingUsageType.METERED + : BillingUsageType.LICENSED, + }, + }, + ...(isMetered && tiers + ? { + tiers, + } + : {}), + }) as BillingPriceEntity | BillingMeterPrice; + +export const buildDefaultMeteredTiers = ( + upTo: number = 1000, +): Stripe.Price.Tier[] => [ + { + up_to: upTo, + flat_amount: upTo, + unit_amount: null, + flat_amount_decimal: String(upTo * 100), + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, +]; + +export const arrangeBillingSubscriptionRepositoryFindOneOrFail = ( + billingSubscriptionRepository: jest.Mocked< + Repository + >, + params: { + planKey?: BillingPlanKey; + interval?: SubscriptionInterval; + licensedPriceId?: string; + meteredPriceId?: string; + seats?: number; + workspaceId?: string; + stripeSubscriptionId?: string; + currentPeriodEnd?: Date; + } = {}, +) => + jest + .spyOn(billingSubscriptionRepository, 'findOneOrFail') + .mockResolvedValue(buildSubscription(params)); + +export const arrangeBillingPriceRepositoryFindOneOrFail = ( + billingPriceRepository: jest.Mocked>, + priceIdToPriceMap: Record, +) => + jest + .spyOn(billingPriceRepository, 'findOneOrFail') + .mockImplementation(async (criteria: unknown) => { + const where = (criteria as { where?: { stripePriceId?: string } })?.where; + const priceId = where?.stripePriceId; + + if (priceId && priceIdToPriceMap[priceId]) { + return priceIdToPriceMap[priceId] as BillingPriceEntity; + } + + return {} as BillingPriceEntity; + }); + +export const arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule = + ( + stripeSubscriptionScheduleService: jest.Mocked, + result: { + schedule?: Stripe.SubscriptionSchedule; + currentPhase?: Stripe.SubscriptionSchedule.Phase; + nextPhase?: Stripe.SubscriptionSchedule.Phase; + } = {}, + ) => + jest + .spyOn(stripeSubscriptionScheduleService, 'loadSubscriptionSchedule') + .mockResolvedValue(result as never); + +export const arrangeStripeSubscriptionScheduleServiceCreateSubscriptionSchedule = + ( + stripeSubscriptionScheduleService: jest.Mocked, + currentPhase: Stripe.SubscriptionSchedule.Phase = {} as Stripe.SubscriptionSchedule.Phase, + ) => + jest + .spyOn(stripeSubscriptionScheduleService, 'createSubscriptionSchedule') + .mockResolvedValue({ + schedule: { + id: 'schedule_1', + } as unknown as Stripe.Response, + currentPhase, + }); + +export const arrangeBillingProductServiceGetProductPrices = ( + billingProductService: jest.Mocked, + prices: BillingPriceEntity[], +) => + jest + .spyOn(billingProductService, 'getProductPrices') + .mockResolvedValue(prices); + +export const arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams = ( + billingSubscriptionPhaseService: jest.Mocked, + result: Stripe.SubscriptionScheduleUpdateParams.Phase = {} as Stripe.SubscriptionScheduleUpdateParams.Phase, +) => + jest + .spyOn(billingSubscriptionPhaseService, 'toPhaseUpdateParams') + .mockReturnValue(result); + +export const buildSchedulePhase = ({ + licensedPriceId, + meteredPriceId, + seats = 1, + startDate = Math.floor(Date.now() / 1000), + endDate = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, +}: { + licensedPriceId: string; + meteredPriceId: string; + seats?: number; + startDate?: number; + endDate?: number; +}): Stripe.SubscriptionSchedule.Phase => + ({ + start_date: startDate, + end_date: endDate, + items: [ + { price: licensedPriceId, quantity: seats }, + { price: meteredPriceId }, + ], + }) as Stripe.SubscriptionSchedule.Phase; diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/price.constants.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/price.constants.ts new file mode 100644 index 0000000000..e30cad74d9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/price.constants.ts @@ -0,0 +1,21 @@ +export const LICENSE_PRICE_ENTERPRISE_MONTH_ID = + 'LICENSE_PRICE_ENTERPRISE_MONTH_ID'; +export const LICENSE_PRICE_PRO_MONTH_ID = 'LICENSE_PRICE_PRO_MONTH_ID'; +export const LICENSE_PRICE_ENTERPRISE_YEAR_ID = + 'LICENSE_PRICE_ENTERPRISE_YEAR_ID'; +export const LICENSE_PRICE_PRO_YEAR_ID = 'LICENSE_PRICE_PRO_YEAR_ID'; + +export const METER_PRICE_ENTERPRISE_MONTH_ID = + 'METER_PRICE_ENTERPRISE_MONTH_ID'; +export const METER_PRICE_PRO_MONTH_ID = 'METER_PRICE_PRO_MONTH_ID'; +export const METER_PRICE_ENTERPRISE_YEAR_ID = 'METER_PRICE_ENTERPRISE_YEAR_ID'; +export const METER_PRICE_PRO_YEAR_ID = 'METER_PRICE_PRO_YEAR_ID'; + +export const METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID = + 'METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID'; +export const METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID = + 'METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID'; +export const METER_PRICE_PRO_MONTH_TIER_LOW_ID = + 'METER_PRICE_PRO_MONTH_TIER_LOW_ID'; +export const METER_PRICE_PRO_MONTH_TIER_HIGH_ID = + 'METER_PRICE_PRO_MONTH_TIER_HIGH_ID'; diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index db8f0b008d..5f55f2d99d 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -21,11 +21,11 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service'; import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type'; -import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; +import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { assert } from 'src/utils/assert'; @Injectable() @@ -105,7 +105,7 @@ export class BillingPortalWorkspaceService { ); } - const subscription = + const stripeSubscription = await this.stripeCheckoutService.createDirectSubscription({ user, workspace, @@ -120,7 +120,7 @@ export class BillingPortalWorkspaceService { const createdBillingSubscription = await this.billingSubscriptionService.syncSubscriptionToDatabase( workspace.id, - subscription, + stripeSubscription.id, ); await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits( 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 index ac57522532..cfcbe4b18f 100644 --- 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 @@ -3,11 +3,19 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { type Repository } from 'typeorm'; -import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; import { billingValidator } from 'src/engine/core-modules/billing/billing.validate'; +import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; +import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; +import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; @Injectable() export class BillingPriceService { @@ -16,6 +24,7 @@ export class BillingPriceService { private readonly stripeSubscriptionService: StripeSubscriptionService, @InjectRepository(BillingPriceEntity) private readonly billingPriceRepository: Repository, + private readonly billingProductService: BillingProductService, ) {} async getBillingThresholdsByMeterPriceId(meterPriceId: string) { @@ -32,4 +41,92 @@ export class BillingPriceService { price.tiers[0].flat_amount, ); } + + async findEquivalentMeteredPrice({ + meteredPrice, + targetInterval, + targetPlanKey, + hasSameInterval, + hasSamePlanKey, + }: { + meteredPrice: BillingMeterPrice; + targetInterval: SubscriptionInterval; + targetPlanKey: BillingPlanKey; + hasSameInterval: boolean; + hasSamePlanKey: boolean; + }) { + if (hasSameInterval && hasSamePlanKey) { + return meteredPrice; + } + + const billingPricesPerPlanAndIntervalArray = + await this.billingProductService.getProductPrices({ + interval: targetInterval, + planKey: targetPlanKey, + }); + + const targetMeteredPrice = await this.findMeteredMatchFloor( + billingPricesPerPlanAndIntervalArray, + meteredPrice, + targetInterval !== meteredPrice.interval ? targetInterval : undefined, + ); + + return targetMeteredPrice; + } + + private async findMeteredMatchFloor( + catalog: BillingPriceEntity[], + reference: BillingMeterPrice, + targetInterval: SubscriptionInterval | undefined, + ): Promise { + const refCap = targetInterval + ? this.scaleCap( + reference.tiers[0].up_to, + reference.interval, + targetInterval, + ) + : reference.tiers[0].up_to; + + const candidates = this.filterMeteredCandidates(catalog, targetInterval); + + if (!candidates.length) { + throw new BillingException( + 'No metered candidates found for mapping', + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } + + return ( + candidates.filter((p) => p.tiers[0].up_to <= refCap).pop() ?? + candidates[0] + ); + } + + private scaleCap( + cap: number, + from: SubscriptionInterval, + to: SubscriptionInterval, + ) { + if (from === to) return cap; + + return from === SubscriptionInterval.Month && + to === SubscriptionInterval.Year + ? cap * 12 + : cap / 12; + } + + private filterMeteredCandidates( + catalog: BillingPriceEntity[], + interval?: SubscriptionInterval, + ) { + const pool = interval + ? catalog.filter((p) => p.interval === interval) + : catalog; + + return ( + pool.filter((p) => + billingValidator.isMeteredPrice(p), + ) as BillingMeterPrice[] + ).sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to); + } } 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 74f6334efe..954d32e194 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 @@ -3,18 +3,17 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import Stripe from 'stripe'; import { assertIsDefinedOrThrow, findOrThrow, isDefined, } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { type Repository } from 'typeorm'; -import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto'; +import type Stripe from 'stripe'; + +import { type BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto'; import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; -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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service'; import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils'; @@ -66,7 +65,7 @@ export class BillingSubscriptionPhaseService { }; } - toSnapshot( + toPhaseUpdateParams( phase: Stripe.SubscriptionSchedule.Phase, ): Stripe.SubscriptionScheduleUpdateParams.Phase { return { @@ -83,35 +82,56 @@ export class BillingSubscriptionPhaseService { } as Stripe.SubscriptionScheduleUpdateParams.Phase; } - async buildSnapshot( - base: Stripe.SubscriptionScheduleUpdateParams.Phase, - licensedPriceId: string, - seats: number, - meteredPriceId: string, - ): Promise { + async buildPhaseUpdateParams({ + licensedStripePriceId, + seats, + meteredStripePriceId, + startDate, + endDate, + }: { + licensedStripePriceId: string; + seats: number; + meteredStripePriceId: string; + startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date']; + endDate: number | undefined; + }): Promise { return { - start_date: base.start_date, - end_date: base.end_date, - proration_behavior: base.proration_behavior ?? 'none', + start_date: startDate, + ...(endDate ? { end_date: endDate } : {}), + proration_behavior: 'none', items: [ - { price: licensedPriceId, quantity: seats }, - { price: meteredPriceId }, + { price: licensedStripePriceId, quantity: seats }, + { price: meteredStripePriceId }, ], billing_thresholds: await this.billingPriceService.getBillingThresholdsByMeterPriceId( - meteredPriceId, + meteredStripePriceId, ), }; } - getLicensedPriceIdFromSnapshot( + getLicensedPriceIdAndQuantityFromPhaseUpdateParams( phase: Stripe.SubscriptionScheduleUpdateParams.Phase, - ): string { + ): { price: string; quantity: number } { const licensedItem = findOrThrow(phase.items!, (i) => i.quantity != null); assertIsDefinedOrThrow(licensedItem.price); + assertIsDefinedOrThrow(licensedItem.quantity); - return licensedItem.price; + return { + price: licensedItem.price, + quantity: licensedItem.quantity, + }; + } + + getMeteredPriceIdFromPhaseUpdateParams( + phase: Stripe.SubscriptionScheduleUpdateParams.Phase, + ): string { + const meteredItem = findOrThrow(phase.items!, (i) => i.quantity == null); + + assertIsDefinedOrThrow(meteredItem.price); + + return meteredItem.price; } async isSamePhaseSignature( @@ -119,44 +139,24 @@ export class BillingSubscriptionPhaseService { b: Stripe.SubscriptionScheduleUpdateParams.Phase, ): Promise { try { - const sigA = await this.getPhaseSignatureFromSnapshot(a); - const sigB = await this.getPhaseSignatureFromSnapshot(b); + const phaseALicensedPriceIdAndQuantity = + this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(a); + const phaseBLicensedPriceIdAndQuantity = + this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(b); + const phaseAMeteredPriceId = + this.getMeteredPriceIdFromPhaseUpdateParams(a); + const phaseBMeteredPriceId = + this.getMeteredPriceIdFromPhaseUpdateParams(b); return ( - sigA.planKey === sigB.planKey && - sigA.interval === sigB.interval && - sigA.meteredPriceId === sigB.meteredPriceId + phaseALicensedPriceIdAndQuantity.price === + phaseBLicensedPriceIdAndQuantity.price && + phaseALicensedPriceIdAndQuantity.quantity === + phaseBLicensedPriceIdAndQuantity.quantity && + phaseAMeteredPriceId === phaseBMeteredPriceId ); } catch { return false; } } - - private async getPhaseSignatureFromSnapshot( - phase: Stripe.SubscriptionScheduleUpdateParams.Phase, - ): Promise<{ - planKey: BillingPlanKey; - interval: SubscriptionInterval; - meteredPriceId: string; - }> { - const metered = findOrThrow(phase.items!, (i) => i.quantity == null); - const meteredPriceId = metered.price; - - assertIsDefinedOrThrow(meteredPriceId); - - const meteredPrice = await this.billingPriceRepository.findOneOrFail({ - where: { - stripePriceId: meteredPriceId, - }, - relations: ['billingProduct'], - }); - - const plan = await this.billingPlanService.getPlanByPriceId(meteredPriceId); - - return { - planKey: plan.planKey, - interval: meteredPrice.interval, - 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 deleted file mode 100644 index 76ac119cd6..0000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts +++ /dev/null @@ -1,2792 +0,0 @@ -import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { type ObjectLiteral, type Repository } from 'typeorm'; - -import type Stripe from 'stripe'; - -import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; -import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; -import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; -import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; -import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; -import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; -import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; -import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; -import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; -import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; -import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; -import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; -import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; -import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; -import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; -import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type'; -import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; -import { type BillingProductEntity } 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'; - -function repoMock() { - return { - find: jest.fn(), - findOne: jest.fn(), - findOneOrFail: jest.fn(), - upsert: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - } as unknown as jest.Mocked>; -} - -const LICENSE_PRICE_ENTERPRISE_MONTH_ID = 'LICENSE_PRICE_ENTERPRISE_MONTH_ID'; -const LICENSE_PRICE_PRO_MONTH_ID = 'LICENCE_PRICE_PRO_MONTH_ID'; -const LICENSE_PRICE_ENTERPRISE_YEAR_ID = 'LICENSE_PRICE_ENTERPRISE_YEAR_ID'; -const LICENSE_PRICE_PRO_YEAR_ID = 'LICENSE_PRICE_PRO_YEAR_ID'; - -const METER_PRICE_ENTERPRISE_MONTH_ID = 'METER_PRICE_ENTERPRISE_MONTH_ID'; -const METER_PRICE_PRO_MONTH_ID = 'METER_PRICE_PRO_MONTH_ID'; -const METER_PRICE_ENTERPRISE_YEAR_ID = 'METER_PRICE_ENTERPRISE_YEAR_ID'; -const METER_PRICE_PRO_YEAR_ID = 'METER_PRICE_PRO_YEAR_ID'; - -const METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID = - 'METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID'; -const METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID = - '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('BillingSubscriptionService', () => { - let module: TestingModule; - let service: BillingSubscriptionService; - let billingSubscriptionRepository: Repository; - let billingPriceRepository: Repository; - let billingProductService: BillingProductService; - let stripeSubscriptionScheduleService: StripeSubscriptionScheduleService; - let stripeSubscriptionService: StripeSubscriptionService; - let billingSubscriptionPhaseService: BillingSubscriptionPhaseService; - - const currentSubscription = { - id: 'sub_db_1', - workspaceId: 'ws_1', - stripeSubscriptionId: 'sub_1', - status: SubscriptionStatus.Active, - interval: SubscriptionInterval.Month, - billingSubscriptionItems: [ - { - stripeSubscriptionItemId: 'si_licensed', - stripeProductId: 'prod_base', - stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, - billingProduct: { - metadata: { - planKey: BillingPlanKey.PRO, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - }, - { - stripeSubscriptionItemId: 'si_metered', - stripeProductId: 'prod_metered', - stripePriceId: METER_PRICE_PRO_MONTH_ID, - billingProduct: { - metadata: { - planKey: BillingPlanKey.PRO, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - }, - ], - } as BillingSubscriptionEntity; - - const arrangeBillingPriceRepositoryFindOneOrFail = () => { - const resolvePrice = (criteria: any) => { - const priceId = - criteria?.stripePriceId ?? criteria?.where?.stripePriceId ?? criteria; - const id = String(priceId).toUpperCase(); - const isMetered = id.includes('METER'); - const interval = id.includes('YEAR') - ? SubscriptionInterval.Year - : SubscriptionInterval.Month; - - const base: Partial = { - stripePriceId: priceId, - interval, - billingProduct: { - metadata: { - planKey: BillingPlanKey.ENTERPRISE, - productKey: isMetered - ? BillingProductKey.WORKFLOW_NODE_EXECUTION - : BillingProductKey.BASE_PRODUCT, - priceUsageBased: isMetered - ? BillingUsageType.METERED - : BillingUsageType.LICENSED, - }, - } as BillingProductEntity, - }; - - if (isMetered) { - return { - ...base, - 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', - }, - ], - } as BillingMeterPrice; - } - - return base as BillingPriceEntity; - }; - - return jest - .spyOn(billingPriceRepository, 'findOneOrFail') - .mockImplementation(async (criteria: any) => resolvePrice(criteria)); - }; - - const arrangeBillingSubscriptionRepositoryFind = ( - overrides: { - planKey?: BillingPlanKey; - interval?: SubscriptionInterval; - licensedPriceId?: string; - meteredPriceId?: string; - seats?: number; - workspaceId?: string; - stripeSubscriptionId?: string; - } = {}, - ) => { - const sub: BillingSubscriptionEntity = { - ...currentSubscription, - workspaceId: overrides.workspaceId ?? currentSubscription.workspaceId, - stripeSubscriptionId: - overrides.stripeSubscriptionId ?? - currentSubscription.stripeSubscriptionId, - interval: overrides.interval ?? currentSubscription.interval, - billingSubscriptionItems: [ - { - ...currentSubscription.billingSubscriptionItems[0], - stripePriceId: - overrides.licensedPriceId ?? - currentSubscription.billingSubscriptionItems[0].stripePriceId, - quantity: - overrides.seats ?? - currentSubscription.billingSubscriptionItems[0].quantity ?? - 7, - billingProduct: { - ...currentSubscription.billingSubscriptionItems[0].billingProduct, - metadata: { - ...currentSubscription.billingSubscriptionItems[0].billingProduct - .metadata, - planKey: - overrides.planKey ?? - currentSubscription.billingSubscriptionItems[0].billingProduct - .metadata.planKey, - productKey: BillingProductKey.BASE_PRODUCT, - priceUsageBased: BillingUsageType.LICENSED, - }, - }, - }, - { - ...currentSubscription.billingSubscriptionItems[1], - stripePriceId: - overrides.meteredPriceId ?? - currentSubscription.billingSubscriptionItems[1].stripePriceId, - billingProduct: { - ...currentSubscription.billingSubscriptionItems[1].billingProduct, - metadata: { - ...currentSubscription.billingSubscriptionItems[1].billingProduct - .metadata, - planKey: - overrides.planKey ?? - currentSubscription.billingSubscriptionItems[1].billingProduct - .metadata.planKey, - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - priceUsageBased: BillingUsageType.METERED, - }, - }, - }, - ], - } as BillingSubscriptionEntity; - - return jest - .spyOn(billingSubscriptionRepository, 'find') - .mockResolvedValueOnce([sub]); - }; - - const arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule = - (phases: Array> = []) => - jest - .spyOn( - stripeSubscriptionScheduleService, - 'findOrCreateSubscriptionSchedule', - ) - .mockResolvedValue({ - id: 'scheduleId', - phases, - } as unknown as Stripe.SubscriptionSchedule); - - const arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase = ({ - planKey = BillingPlanKey.ENTERPRISE, - interval = SubscriptionInterval.Year, - licensedPriceId = LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId = METER_PRICE_ENTERPRISE_YEAR_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', - }, - ] as MeterBillingPriceTiers, - }: { - planKey?: BillingPlanKey; - interval?: SubscriptionInterval; - licensedPriceId?: string; - meteredPriceId?: string; - quantity?: number; - meteredTiers?: MeterBillingPriceTiers; - } = {}) => - jest - .spyOn(billingSubscriptionPhaseService, 'getDetailsFromPhase') - .mockResolvedValueOnce({ - licensedPrice: { - stripePriceId: licensedPriceId, - quantity, - } as unknown as BillingPriceEntity, - meteredPrice: { - stripePriceId: meteredPriceId, - tiers: meteredTiers, - } as unknown as BillingMeterPrice, - plan: { - planKey, - licensedProducts: [], - meteredProducts: [], - } as BillingGetPlanResult, - quantity, - interval, - }); - - const arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences = ( - sequences: Array<{ - planKey?: BillingPlanKey; - interval?: SubscriptionInterval; - licensedPriceId?: string; - meteredPriceId?: string; - quantity?: number; - meteredTiers?: MeterBillingPriceTiers; - }> = [{}], - ) => { - const spy = jest.spyOn( - billingSubscriptionPhaseService, - 'getDetailsFromPhase', - ); - - sequences.forEach((cfg) => { - const { - planKey = BillingPlanKey.ENTERPRISE, - interval = SubscriptionInterval.Year, - licensedPriceId = LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId = METER_PRICE_ENTERPRISE_YEAR_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', - }, - ] as MeterBillingPriceTiers, - } = cfg ?? {}; - - spy.mockResolvedValueOnce({ - licensedPrice: { - stripePriceId: licensedPriceId, - quantity, - } as unknown as BillingPriceEntity, - meteredPrice: { - stripePriceId: meteredPriceId, - tiers: meteredTiers, - } as unknown as BillingMeterPrice, - plan: { - planKey, - licensedProducts: [], - meteredProducts: [], - } as BillingGetPlanResult, - quantity, - interval, - }); - }); - - return spy; - }; - - const arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences = ( - snapshots: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [ - {} as Stripe.SubscriptionScheduleUpdateParams.Phase, - ], - ) => { - const spy = jest.spyOn(billingSubscriptionPhaseService, 'buildSnapshot'); - - snapshots.forEach((phase) => { - spy.mockResolvedValueOnce(phase); - }); - - return spy; - }; - - const arrangeBillingProductServiceGetProductPrices = ( - prices: Array> = [ - { - 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, - ], - ) => - jest - .spyOn(billingProductService, 'getProductPrices') - .mockResolvedValue(prices as BillingPriceEntity[]); - - const arrangeBillingSubscriptionRepositoryFindOneOrFail = ({ - 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, 'findOneOrFail') - .mockResolvedValue({ - id: 'sub_db_param', - 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 BillingSubscriptionEntity); - - const arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync = () => { - const spy = jest - .spyOn(stripeSubscriptionService, 'updateSubscription') - .mockResolvedValueOnce({} as Stripe.Subscription); - - jest - .spyOn(service, 'syncSubscriptionToDatabase') - .mockResolvedValueOnce({} as BillingSubscriptionEntity); - - return spy; - }; - - const arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule = - () => - jest - .spyOn(stripeSubscriptionScheduleService, 'getSubscriptionWithSchedule') - .mockResolvedValue({ - id: 'sub_id', - schedule: { id: 'scheduleId' }, - status: 'active', - current_period_end: Math.floor(Date.now() / 1000), - items: { data: [] }, - latest_invoice: { - payment_intent: { - charges: { data: [] }, - }, - }, - } as unknown as SubscriptionWithSchedule); - - const arrangeBillingSubscriptionPhaseServiceToSnapshot = ( - licensedPriceId: string, - meteredPriceId: string, - quantity = 7, - ) => - jest.spyOn(billingSubscriptionPhaseService, 'toSnapshot').mockReturnValue({ - end_date: Date.now() + 1000, - items: [{ price: licensedPriceId, quantity }, { price: meteredPriceId }], - } as Stripe.SubscriptionScheduleUpdateParams.Phase); - - const arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences = ( - sequences: Array<{ - currentEditable: { - licensedPriceId: string; - meteredPriceId: string; - quantity?: number; - }; - nextEditable?: { - licensedPriceId?: string; - meteredPriceId?: string; - quantity?: number; - }; - }> = [], - ) => { - const spy = jest.spyOn( - stripeSubscriptionScheduleService, - 'getEditablePhases', - ); - - sequences.forEach((sequence) => { - spy.mockReturnValueOnce({ - currentEditable: { - items: [ - { - price: sequence.currentEditable.licensedPriceId, - quantity: sequence.currentEditable.quantity ?? 7, - }, - { price: sequence.currentEditable.meteredPriceId }, - ], - } as Stripe.SubscriptionSchedule.Phase, - nextEditable: sequence.nextEditable - ? ({ - items: [ - { - price: sequence.nextEditable.licensedPriceId, - quantity: sequence.nextEditable.quantity ?? 7, - }, - { price: sequence.nextEditable.meteredPriceId }, - ], - } as Stripe.SubscriptionSchedule.Phase) - : undefined, - }); - }); - - return spy; - }; - - const arrangeBillingProductServiceGetProductPricesSequence = ( - first: Array>, - second: Array>, - ) => { - const spy = jest.spyOn(billingProductService, 'getProductPrices'); - - spy.mockResolvedValueOnce(first as BillingPriceEntity[]); - spy.mockResolvedValueOnce(second as BillingPriceEntity[]); - - return spy; - }; - - const arrangeServiceSyncSubscriptionToDatabase = () => - jest - .spyOn(service, 'syncSubscriptionToDatabase') - .mockResolvedValue({} as BillingSubscriptionEntity); - - beforeEach(async () => { - module = await Test.createTestingModule({ - providers: [ - BillingSubscriptionService, - { - provide: BillingPlanService, - useValue: { - getPlanBaseProduct: jest.fn(), - listPlans: jest.fn(), - getPlanByPriceId: jest.fn(), - getPricesPerPlanByInterval: jest.fn(), - }, - }, - { - provide: BillingProductService, - useValue: { - getProductPrices: jest.fn(), - }, - }, - { - provide: StripeCustomerService, - useValue: { - hasPaymentMethod: jest.fn(), - }, - }, - { provide: StripeSubscriptionItemService, useValue: {} }, - { - provide: StripeSubscriptionScheduleService, - useValue: { - getSubscriptionWithSchedule: jest.fn(), - findOrCreateSubscriptionSchedule: jest.fn(), - getEditablePhases: jest.fn(), - replaceEditablePhases: jest.fn(), - }, - }, - { - provide: StripeSubscriptionService, - useValue: { - updateSubscription: jest.fn(), - cancelSubscription: jest.fn(), - collectLastInvoice: jest.fn(), - }, - }, - { - provide: BillingPriceService, - useValue: { - getBillingThresholdsByMeterPriceId: jest.fn().mockResolvedValue({ - amount_gte: 1000, - reset_billing_cycle_anchor: false, - }), - }, - }, - { - provide: StripeSubscriptionScheduleService, - useValue: { - getSubscriptionWithSchedule: jest.fn(), - findOrCreateSubscriptionSchedule: jest.fn(), - getEditablePhases: jest.fn(), - replaceEditablePhases: jest.fn(), - }, - }, - { - provide: BillingSubscriptionPhaseService, - useValue: { - getDetailsFromPhase: jest.fn(), - toSnapshot: jest.fn(), - buildSnapshot: jest.fn(), - getLicensedPriceIdFromSnapshot: jest.fn(), - isSamePhaseSignature: jest.fn(), - }, - }, - { - provide: TwentyConfigService, - useValue: { - get: jest.fn().mockReturnValue(0), - }, - }, - { - provide: getRepositoryToken(BillingEntitlementEntity), - useValue: repoMock(), - }, - { - provide: getRepositoryToken(BillingSubscriptionEntity), - useValue: repoMock(), - }, - { - provide: getRepositoryToken(BillingPriceEntity), - useValue: repoMock(), - }, - { - provide: getRepositoryToken(BillingSubscriptionItemEntity), - useValue: repoMock(), - }, - { - provide: getRepositoryToken(BillingCustomerEntity), - useValue: repoMock(), - }, - ], - }).compile(); - - service = module.get(BillingSubscriptionService); - billingSubscriptionRepository = module.get< - Repository - >(getRepositoryToken(BillingSubscriptionEntity)); - billingPriceRepository = module.get>( - getRepositoryToken(BillingPriceEntity), - ); - billingProductService = module.get( - BillingProductService, - ); - stripeSubscriptionScheduleService = - module.get( - StripeSubscriptionScheduleService, - ); - stripeSubscriptionService = module.get( - StripeSubscriptionService, - ); - stripeSubscriptionScheduleService = - module.get( - StripeSubscriptionScheduleService, - ); - billingSubscriptionPhaseService = - module.get( - BillingSubscriptionPhaseService, - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('changePlan', () => { - describe('upgrade', () => { - it('PRO -> ENTERPRISE without existing phase', async () => { - const spyBillingSubscriptionRepositoryFind = - arrangeBillingSubscriptionRepositoryFind(); - const spyFindOrCreateSchedule = - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); - const spyGetDetailsFromPhase = - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.PRO, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_ID, - quantity: 7, - }); - - 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 WorkspaceEntity); - - 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, - quantity: 7, - }, - { - id: 'si_metered', - price: METER_PRICE_ENTERPRISE_MONTH_ID, - }, - ], - metadata: { - plan: 'ENTERPRISE', - }, - proration_behavior: 'create_prorations', - }); - expect( - 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); - }); - 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, - }, - { - planKey: BillingPlanKey.PRO, - interval: SubscriptionInterval.Month, - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_ID, - quantity: 7, - }, - ]); - 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, - }, - }, - { - currentEditable: { - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - }, - nextEditable: { - licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, - meteredPriceId: METER_PRICE_PRO_MONTH_ID, - }, - }, - ]); - 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(); - - 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 WorkspaceEntity); - - expect( - stripeSubscriptionService.updateSubscription, - ).toHaveBeenCalledWith(currentSubscription.stripeSubscriptionId, { - billing_thresholds: { - amount_gte: 1000, - reset_billing_cycle_anchor: false, - }, - items: [ - { - id: 'si_licensed', - price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - { id: 'si_metered', price: METER_PRICE_ENTERPRISE_YEAR_ID }, - ], - metadata: { plan: 'ENTERPRISE' }, - proration_behavior: 'create_prorations', - }); - - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith( - 'scheduleId', - 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 }, - ], - }), - }), - ); - 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 () => { - const spyBillingSubscriptionRepositoryFindD1 = - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, - }); - - 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, - }); - - 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, - }, - }, - ]); - 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([ - { - 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(); - - const spySyncDBD1 = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changePlan({ id: 'ws_1' } as WorkspaceEntity); - - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith( - 'scheduleId', - expect.objectContaining({ - currentPhaseSnapshot: 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 (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, - }); - - const spyFindOrCreateScheduleD2 = - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); - const spyGetDetailsFromPhaseSeqD2 = - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ - { - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Year, - 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, - }, - ]); - - const spyGetEditablePhasesSeqD2 = - arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ - { - currentEditable: { - 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, - }, - }, - ]); - const spyGetProductPricesSeqD2 = - 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 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 }, - ], - } as Stripe.SubscriptionScheduleUpdateParams.Phase, - ]); - - const spyPriceFindByOrFailD2 = - arrangeBillingPriceRepositoryFindOneOrFail(); - const spyGetSubWithScheduleD2 = - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - const spySubFindByOrFailD2 = - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - - const spySyncDBD2 = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changePlan({ id: 'ws_1' } as WorkspaceEntity); - - expect( - stripeSubscriptionService.updateSubscription, - ).not.toHaveBeenCalled(); - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith('scheduleId', { - 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_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_ID }, - ], - }), - }); - 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); - }); - }); - }); - - describe('changeInterval', () => { - describe('upgrade', () => { - it('MONTHLY -> YEARLY without existing phase', async () => { - 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, - }, - }, - ]); - const spyGetProductPrices = - arrangeBillingProductServiceGetProductPrices(); - const spyPriceFindByOrFail = - arrangeBillingPriceRepositoryFindOneOrFail(); - const spySubFindOneOrFail = - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - const spyUpdateSubscription = - arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); - const spyGetSubWithSchedule = - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - const spySubFindByOrFail = - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - - 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 WorkspaceEntity); - - expect( - stripeSubscriptionService.updateSubscription, - ).toHaveBeenCalledWith( - currentSubscription.stripeSubscriptionId, - expect.objectContaining({ - items: [ - { - id: 'si_licensed', - price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }, - { id: 'si_metered', price: METER_PRICE_ENTERPRISE_YEAR_ID }, - ], - }), - ); - 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 () => { - 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, - quantity: 7, - }); - - 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, - }, - }, - { - 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, - }, - }, - ]); - - 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, - ]); - - 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, - ); - const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); - const spySubFindOneOrFail = - arrangeBillingSubscriptionRepositoryFindOneOrFail({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - }); - - 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 WorkspaceEntity); - - expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalled(); - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith( - 'scheduleId', - expect.objectContaining({ - currentPhaseSnapshot: expect.objectContaining({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_ID }, - ], - }), - nextPhase: expect.objectContaining({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_YEAR_ID }, - ], - }), - }), - ); - 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 () => { - const spyBillingSubscriptionRepositoryFind = - arrangeBillingSubscriptionRepositoryFind({ - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - planKey: BillingPlanKey.ENTERPRISE, - }); - const spyFindOrCreateSchedule = - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); - - const spyGetDetailsFromPhase = - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }); - - const spyGetEditablePhases = - 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, - }, - }, - ]); - - 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 spyPriceFindByOrFail = - arrangeBillingPriceRepositoryFindOneOrFail(); - - const spyGetSubWithSchedule = - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - const spySubFindByOrFail = - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - - const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_YEAR_ID, - METER_PRICE_ENTERPRISE_YEAR_ID, - 7, - ); - 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, - ]); - - const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changeInterval({ id: 'ws_1' } as WorkspaceEntity); - - expect( - stripeSubscriptionService.updateSubscription, - ).not.toHaveBeenCalled(); - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith('scheduleId', { - 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 }, - ], - }), - }); - 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 () => { - const spyBillingSubscriptionRepositoryFind = - arrangeBillingSubscriptionRepositoryFind({ - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - planKey: BillingPlanKey.ENTERPRISE, - }); - const spyFindOrCreateSchedule = - arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( - [{}, {}], - ); - - const spyGetDetailsFromPhase = - arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ - planKey: BillingPlanKey.ENTERPRISE, - interval: SubscriptionInterval.Year, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, - quantity: 7, - }); - - 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, - }, - }, - { - 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, - }, - }, - ]); - - 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 spyPriceFindByOrFail = - arrangeBillingPriceRepositoryFindOneOrFail(); - - const spyGetSubWithSchedule = - arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); - const spySubFindByOrFail = - arrangeBillingSubscriptionRepositoryFindOneOrFail(); - - const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_YEAR_ID, - METER_PRICE_ENTERPRISE_YEAR_ID, - 7, - ); - 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, - ]); - - const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changeInterval({ id: 'ws_1' } as WorkspaceEntity); - - expect( - stripeSubscriptionService.updateSubscription, - ).not.toHaveBeenCalled(); - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith('scheduleId', { - 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 }, - ], - }), - }); - 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); - }); - }); - }); - - describe('changeMeteredPrice', () => { - describe('upgrade', () => { - it('without existing phase', async () => { - 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, - 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', - }, - ], - }); - const spyGetEditablePhases = - 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, - }, - }, - ]); - 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, - ); - const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changeMeteredPrice( - { id: 'ws_1' } as WorkspaceEntity, - METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - ); - - expect( - stripeSubscriptionService.updateSubscription, - ).toHaveBeenCalledWith( - currentSubscription.stripeSubscriptionId, - expect.objectContaining({ - billing_thresholds: { - amount_gte: 1000, - reset_billing_cycle_anchor: false, - }, - items: [ - { - id: 'si_licensed', - price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - quantity: 7, - }, - { - id: 'si_metered', - price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - }, - ], - proration_behavior: 'none', - }), - ); - 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 () => { - const spyBillingSubscriptionRepositoryFind = - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - }); - 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', - }, - ], - }, - { - 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', - }, - ], - }, - ]); - 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, - }, - }, - { - 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, - }, - }, - ]); - 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(); - - // Snapshot courant (phase actuelle) - const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_MONTH_ID, - METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - 7, - ); - - 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, - ]); - - const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changeMeteredPrice( - { id: 'ws_1' } as WorkspaceEntity, - METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - ); - - expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalled(); - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith( - 'scheduleId', - expect.objectContaining({ - currentPhaseSnapshot: { - end_date: expect.any(Number), - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, - ], - }, - nextPhase: { - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, - ], - }, - }), - ); - 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 () => { - const spyBillingSubscriptionRepositoryFind = - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - }); - - 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', - }, - ], - }, - ]); - 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, - }, - }, - ]); - 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: 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, - }, - }, - } 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 spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_MONTH_ID, - METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, - 7, - ); - - const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changeMeteredPrice( - { id: 'ws_1' } as WorkspaceEntity, - METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - ); - - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith( - 'scheduleId', - expect.objectContaining({ - currentPhaseSnapshot: expect.objectContaining({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, - ], - }), - nextPhase: expect.objectContaining({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID }, - ], - }), - }), - ); - 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 () => { - const spyBillingSubscriptionRepositoryFind = - arrangeBillingSubscriptionRepositoryFind({ - planKey: BillingPlanKey.ENTERPRISE, - licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, - meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - }); - 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', - }, - ], - }, - { - 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', - }, - ], - }, - ]); - 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, - }, - }, - { - 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, - }, - }, - ]); - 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(); - - // Snapshot courant (phase actuelle) - const spyToSnapshot = arrangeBillingSubscriptionPhaseServiceToSnapshot( - LICENSE_PRICE_ENTERPRISE_MONTH_ID, - METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - 7, - ); - - 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, - ]); - - const spySyncDB = arrangeServiceSyncSubscriptionToDatabase(); - - await service.changeMeteredPrice( - { id: 'ws_1' } as WorkspaceEntity, - METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, - ); - - expect( - stripeSubscriptionScheduleService.replaceEditablePhases, - ).toHaveBeenCalledWith( - 'scheduleId', - expect.objectContaining({ - currentPhaseSnapshot: expect.objectContaining({ - items: [ - { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, - ], - }), - nextPhase: expect.objectContaining({ - items: [ - { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, - { price: METER_PRICE_PRO_MONTH_TIER_LOW_ID }, - ], - }), - }), - ); - 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 8c2fd2a488..590b35428e 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 @@ -6,10 +6,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { differenceInDays } from 'date-fns'; import { assertIsDefinedOrThrow, + assertUnreachable, findOrThrow, isDefined, } from 'twenty-shared/utils'; -import { Not, Repository } from 'typeorm'; +import { Not, type Repository } from 'typeorm'; import type Stripe from 'stripe'; @@ -24,18 +25,16 @@ import { BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; import { billingValidator } from 'src/engine/core-modules/billing/billing.validate'; -import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; -import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; -import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service'; import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; @@ -43,16 +42,23 @@ import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; -import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; -import { LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type'; -import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; -import { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type'; -import { getOppositeInterval } from 'src/engine/core-modules/billing/utils/get-opposite-interval'; -import { getOppositePlan } from 'src/engine/core-modules/billing/utils/get-opposite-plan'; +import { + type SubscriptionUpdate, + SubscriptionUpdateType, +} from 'src/engine/core-modules/billing/types/billing-subscription-update.type'; +import { computeSubscriptionUpdateOptions } from 'src/engine/core-modules/billing/utils/compute-subscription-update-options.util'; +import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util'; +import { getCurrentMeteredBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util'; import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util'; +import { getSubscriptionPricesFromSchedulePhase } from 'src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +type SubscriptionStripePrices = { + licensedPriceId: string; + seats: number; + meteredPriceId: string; +}; @Injectable() export class BillingSubscriptionService { protected readonly logger = new Logger(BillingSubscriptionService.name); @@ -208,121 +214,31 @@ export class BillingSubscriptionService { } async changeMeteredPrice( - workspace: WorkspaceEntity, + workspaceId: string, meteredPriceId: string, ): Promise { - const { - billingSubscription, - subscription, - schedule, - currentEditable, - nextEditable, - currentPhaseDetails, - currentCap, - targetCap, - mappedCurrentMeteredId, - mappedNextMeteredId, - } = await this.loadInitialState(workspace, meteredPriceId); - const isUpgrade = targetCap > currentCap; - const { - subscription: updatedSubscription, - schedule: updatedSchedule, - currentEditable: updatedCurrentEditable, - nextEditable: updatedNextEditable, - } = (await this.maybeUpgradeNowIfHigherTier( - billingSubscription, - targetCap, - currentCap, - mappedCurrentMeteredId, - )) ?? { - subscription, - schedule, - currentEditable, - nextEditable, - }; - - const { currentMutated, nextMutated } = await this.buildSnapshots( - updatedCurrentEditable, - updatedNextEditable, - currentPhaseDetails, - mappedCurrentMeteredId, - mappedNextMeteredId, - updatedSubscription.current_period_end, - isUpgrade, + const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( + { workspaceId }, ); - const nextForUpdate = await this.dedupeNextPhase( - currentMutated, - nextMutated, - ); - - const currentPhaseSnapshotForUpdate = currentMutated - ? { ...currentMutated, end_date: updatedSubscription.current_period_end } - : undefined; - - await this.stripeSubscriptionScheduleService.replaceEditablePhases( - updatedSchedule.id, - { - currentPhaseSnapshot: currentPhaseSnapshotForUpdate, - nextPhase: nextForUpdate, - }, - ); - - const refreshed = - await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( - updatedSubscription.id, - ); - - await this.syncSubscriptionToDatabase( - billingSubscription.workspaceId, - refreshed, - ); + await this.updateSubscription(billingSubscription.id, { + type: SubscriptionUpdateType.METERED_PRICE, + newMeteredPriceId: meteredPriceId, + }); } async cancelSwitchMeteredPrice(workspace: WorkspaceEntity): Promise { const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( { workspaceId: workspace.id }, ); - const { currentEditable } = await this.loadScheduleEditable( - billingSubscription.stripeSubscriptionId, - ); - const currentPhaseDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - currentEditable as BillingSubscriptionSchedulePhaseDTO, - ); + const currentMeteredPrice = + getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription); - await this.changeMeteredPrice( - workspace, - currentPhaseDetails.meteredPrice.stripePriceId, - ); - } - - async changeInterval(workspace: WorkspaceEntity) { - const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( - { workspaceId: workspace.id }, - ); - - const nextInterval = getOppositeInterval(billingSubscription.interval); - - return this.setTargetInterval(billingSubscription, nextInterval); - } - - async changePlan(workspace: WorkspaceEntity) { - const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( - { workspaceId: workspace.id }, - ); - - const currentPlanKey = - billingSubscription.billingSubscriptionItems[0].billingProduct.metadata - .planKey; - - const nextPlanKey = getOppositePlan(currentPlanKey); - - return this.setTargetPlan( - billingSubscription.stripeSubscriptionId, - nextPlanKey, - ); + await this.updateSubscription(billingSubscription.id, { + type: SubscriptionUpdateType.METERED_PRICE, + newMeteredPriceId: currentMeteredPrice.stripePriceId, + }); } async endTrialPeriod(workspace: WorkspaceEntity) { @@ -408,48 +324,88 @@ export class BillingSubscriptionService { } } - async getMeteredBillingPriceByPriceId(stripePriceId: string) { - assertIsDefinedOrThrow(stripePriceId); + async cancelSwitchPlan(workspaceId: string) { + const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( + { workspaceId }, + ); - const currentMeteredBillingPrice = - await this.billingPriceRepository.findOneOrFail({ - where: { - stripePriceId: stripePriceId, - }, - relations: ['billingProduct'], - }); + const currentPlan = + getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription) + .billingProduct?.metadata.planKey; - billingValidator.assertIsMeteredPrice(currentMeteredBillingPrice); - - return currentMeteredBillingPrice; + await this.updateSubscription(billingSubscription.id, { + type: SubscriptionUpdateType.PLAN, + newPlan: currentPlan, + }); } - async cancelSwitchPlan(workspace: WorkspaceEntity) { + async cancelSwitchInterval(workspaceId: string) { const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( - { workspaceId: workspace.id }, + { workspaceId }, ); - return this.setTargetPlan( - billingSubscription.stripeSubscriptionId, - BillingPlanKey.ENTERPRISE, - ); + const currentInterval = billingSubscription.interval; + + await this.updateSubscription(billingSubscription.id, { + type: SubscriptionUpdateType.INTERVAL, + newInterval: currentInterval, + }); } - async cancelSwitchInterval(workspace: WorkspaceEntity) { + async changeInterval(workspaceId: string) { const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( - { workspaceId: workspace.id }, + { workspaceId }, ); - return this.setTargetInterval( - billingSubscription, - SubscriptionInterval.Year, + const currentInterval = billingSubscription.interval; + + await this.updateSubscription(billingSubscription.id, { + type: SubscriptionUpdateType.INTERVAL, + newInterval: + currentInterval === SubscriptionInterval.Month + ? SubscriptionInterval.Year + : SubscriptionInterval.Month, + }); + } + + async changePlan(workspaceId: string) { + const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( + { workspaceId }, ); + + const currentPlan = + getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription) + .billingProduct?.metadata.planKey; + + await this.updateSubscription(billingSubscription.id, { + type: SubscriptionUpdateType.PLAN, + newPlan: + currentPlan === BillingPlanKey.ENTERPRISE + ? BillingPlanKey.PRO + : BillingPlanKey.ENTERPRISE, + }); + } + + async changeSeats(workspaceId: string, newSeats: number) { + const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( + { workspaceId }, + ); + + await this.updateSubscription(billingSubscription.id, { + type: SubscriptionUpdateType.SEATS, + newSeats, + }); } async syncSubscriptionToDatabase( workspaceId: string, - subscription: Stripe.Subscription | SubscriptionWithSchedule, + stripeSubscriptionId: string, ) { + const subscription = + await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( + stripeSubscriptionId, + ); + await this.billingCustomerRepository.upsert( transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, { object: subscription, @@ -463,11 +419,7 @@ export class BillingSubscriptionService { await this.billingSubscriptionRepository.upsert( transformStripeSubscriptionEventToDatabaseSubscription( workspaceId, - typeof subscription.schedule === 'string' - ? await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( - subscription.id, - ) - : (subscription as SubscriptionWithSchedule), + subscription, ), { conflictPaths: ['stripeSubscriptionId'], @@ -536,293 +488,6 @@ export class BillingSubscriptionService { return currentBillingSubscription; } - private async loadScheduleEditable(stripeSubscriptionId: string) { - const subscription = - await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( - stripeSubscriptionId, - ); - - const schedule = - await this.stripeSubscriptionScheduleService.findOrCreateSubscriptionSchedule( - subscription, - ); - - const { currentEditable, nextEditable } = - this.stripeSubscriptionScheduleService.getEditablePhases(schedule); - - return { subscription, schedule, currentEditable, nextEditable }; - } - - private async mapTargetMeteredForPhase( - planKey: BillingPlanKey, - interval: SubscriptionInterval, - targetMeteredPriceId: string, - ): Promise { - const prices = await this.billingProductService.getProductPrices({ - interval, - planKey, - }); - const mapped = await this.findMeteredMatchingPriceForMeteredPriceSwitching({ - billingPricesPerPlanAndIntervalArray: prices, - targetMeteredPriceId, - interval, - }); - - return mapped.stripePriceId; - } - - private async replaceCurrentMeteredItem( - billingSubscription: BillingSubscriptionEntity, - newMeteredPriceId: string, - ): Promise { - const licensedItem = - this.getCurrentLicensedBillingSubscriptionItemOrThrow( - billingSubscription, - ); - const meteredItem = - this.getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription); - - const updated = await this.updateSubscription({ - stripeSubscriptionId: billingSubscription.stripeSubscriptionId, - licensedItemId: licensedItem.stripeSubscriptionItemId, - meteredItemId: meteredItem.stripeSubscriptionItemId, - licensedPriceId: licensedItem.stripePriceId, - meteredPriceId: newMeteredPriceId, - seats: licensedItem.quantity, - proration: 'none', - }); - - await this.syncSubscriptionToDatabase( - billingSubscription.workspaceId, - updated, - ); - } - - private async loadInitialState( - workspace: WorkspaceEntity, - meteredPriceId: string, - ): Promise<{ - billingSubscription: BillingSubscriptionEntity; - subscription: SubscriptionWithSchedule; - schedule: Stripe.SubscriptionSchedule; - currentEditable: Stripe.SubscriptionSchedule.Phase | undefined; - nextEditable: Stripe.SubscriptionSchedule.Phase | undefined; - currentPhaseDetails: Awaited< - ReturnType - >; - nextPhaseDetailsInitial: - | Awaited< - ReturnType - > - | undefined; - currentCap: number; - targetCap: number; - mappedCurrentMeteredId: string; - mappedNextMeteredId: string; - }> { - const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( - { workspaceId: workspace.id }, - ); - let { subscription, schedule, currentEditable, nextEditable } = - await this.loadScheduleEditable(billingSubscription.stripeSubscriptionId); - - if (!isDefined(currentEditable)) { - throw new BillingException( - 'No editable phase found for current subscription', - BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND, - ); - } - const currentPhaseDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - currentEditable as BillingSubscriptionSchedulePhaseDTO, - ); - const hasNextInitially = !!nextEditable; - const nextPhaseDetailsInitial = hasNextInitially - ? await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ) - : undefined; - const currentCap = (currentPhaseDetails.meteredPrice as BillingMeterPrice) - .tiers[0].up_to; - const targetCap = ( - await this.getMeteredBillingPriceByPriceId(meteredPriceId) - ).tiers[0].up_to; - const mappedCurrentMeteredId = await this.mapTargetMeteredForPhase( - currentPhaseDetails.plan.planKey, - currentPhaseDetails.interval, - meteredPriceId, - ); - const mappedNextMeteredId = await this.mapTargetMeteredForPhase( - nextPhaseDetailsInitial?.plan.planKey ?? currentPhaseDetails.plan.planKey, - nextPhaseDetailsInitial?.interval ?? currentPhaseDetails.interval, - meteredPriceId, - ); - - return { - billingSubscription, - subscription, - schedule, - currentEditable, - nextEditable, - currentPhaseDetails, - nextPhaseDetailsInitial, - currentCap, - targetCap, - mappedCurrentMeteredId, - mappedNextMeteredId, - }; - } - - private async maybeUpgradeNowIfHigherTier( - billingSubscription: BillingSubscriptionEntity, - targetCap: number, - currentCap: number, - mappedCurrentMeteredId: string, - ): Promise< - | { - subscription: SubscriptionWithSchedule; - schedule: Stripe.SubscriptionSchedule; - currentEditable: Stripe.SubscriptionSchedule.Phase | undefined; - nextEditable: Stripe.SubscriptionSchedule.Phase | undefined; - } - | undefined - > { - if (targetCap > currentCap) { - await this.replaceCurrentMeteredItem( - billingSubscription, - mappedCurrentMeteredId, - ); - const { subscription, schedule, currentEditable, nextEditable } = - await this.loadScheduleEditable( - billingSubscription.stripeSubscriptionId, - ); - - return { subscription, schedule, currentEditable, nextEditable }; - } - - return undefined; - } - - private async buildSnapshots( - currentEditable: Stripe.SubscriptionSchedule.Phase | undefined, - nextEditable: Stripe.SubscriptionSchedule.Phase | undefined, - currentPhaseDetails: Awaited< - ReturnType - >, - mappedCurrentMeteredId: string, - mappedNextMeteredId: string, - subscriptionCurrentPeriodEnd: number, - mutateCurrentNow: boolean, - ): Promise<{ - currentSnap: Stripe.SubscriptionScheduleUpdateParams.Phase | undefined; - nextSnap: Stripe.SubscriptionScheduleUpdateParams.Phase | undefined; - currentLicensedId: string; - nextLicensedId: string; - currentMutated: Stripe.SubscriptionScheduleUpdateParams.Phase | undefined; - nextMutated: Stripe.SubscriptionScheduleUpdateParams.Phase | undefined; - }> { - const isCurrentEditableDefined = isDefined(currentEditable); - const currentSnap = isCurrentEditableDefined - ? this.billingSubscriptionPhaseService.toSnapshot(currentEditable) - : undefined; - const hasNext = !!nextEditable; - const nextPhaseDetails = hasNext - ? await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ) - : undefined; - const currentLicensedId = currentSnap - ? this.billingSubscriptionPhaseService.getLicensedPriceIdFromSnapshot( - currentSnap, - ) - : currentPhaseDetails.licensedPrice.stripePriceId; - const nextSnap = hasNext - ? this.billingSubscriptionPhaseService.toSnapshot(nextEditable) - : undefined; - const nextLicensedId = nextSnap - ? this.billingSubscriptionPhaseService.getLicensedPriceIdFromSnapshot( - nextSnap, - ) - : currentLicensedId; - const currentMutated = currentSnap - ? mutateCurrentNow - ? await this.billingSubscriptionPhaseService.buildSnapshot( - currentSnap, - currentLicensedId, - currentPhaseDetails.quantity, - mappedCurrentMeteredId, - ) - : currentSnap - : undefined; - - const baseItems = (currentSnap?.items ?? nextSnap?.items) as - | Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] - | undefined; - - if (!baseItems) { - throw new BillingException( - 'Cannot build next phase: no items found on current or next snapshot', - BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND, - ); - } - - const nextPhaseBase: Stripe.SubscriptionScheduleUpdateParams.Phase = { - start_date: subscriptionCurrentPeriodEnd, - items: baseItems, - proration_behavior: 'none', - }; - const nextMutated = - await this.billingSubscriptionPhaseService.buildSnapshot( - nextPhaseBase, - nextLicensedId, - nextPhaseDetails?.quantity ?? currentPhaseDetails.quantity, - mappedNextMeteredId, - ); - - return { - currentSnap, - nextSnap, - currentLicensedId, - nextLicensedId, - currentMutated, - nextMutated, - }; - } - - private async dedupeNextPhase( - currentMutated: Stripe.SubscriptionScheduleUpdateParams.Phase | undefined, - nextMutated: Stripe.SubscriptionScheduleUpdateParams.Phase | undefined, - ): Promise { - return currentMutated && - nextMutated && - (await this.billingSubscriptionPhaseService.isSamePhaseSignature( - currentMutated, - nextMutated, - )) - ? undefined - : nextMutated; - } - - private getCurrentMeteredBillingSubscriptionItemOrThrow( - billingSubscription: BillingSubscriptionEntity, - ) { - return findOrThrow( - billingSubscription.billingSubscriptionItems, - ({ billingProduct }) => - billingProduct.metadata.priceUsageBased === BillingUsageType.METERED, - ); - } - - private getCurrentLicensedBillingSubscriptionItemOrThrow( - billingSubscription: BillingSubscriptionEntity, - ) { - return findOrThrow( - billingSubscription.billingSubscriptionItems, - ({ billingProduct }) => - billingProduct.metadata.priceUsageBased === BillingUsageType.LICENSED, - ) as LicensedBillingSubscriptionItem; - } - getTrialPeriodFreeWorkflowCredits( billingSubscription: BillingSubscriptionEntity, ) { @@ -848,21 +513,410 @@ export class BillingSubscriptionService { ); } - private async resolvePrices({ - interval, - planKey, - meteredPriceId, - updateType, + private async runSubscriptionUpdate({ + stripeSubscriptionId, + licensedStripeItemId, + meteredStripeItemId, + licensedStripePriceId, + meteredStripePriceId, + seats, + anchor, + proration, + metadata, }: { - interval: SubscriptionInterval; - planKey: BillingPlanKey; - meteredPriceId: string; - updateType: 'interval' | 'plan'; + stripeSubscriptionId: string; + licensedStripeItemId: string; + meteredStripeItemId: string; + licensedStripePriceId: string; + meteredStripePriceId: string; + seats: number; + anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor; + proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior; + metadata?: Record; }) { + return await this.stripeSubscriptionService.updateSubscription( + stripeSubscriptionId, + { + items: [ + { + id: licensedStripeItemId, + price: licensedStripePriceId, + quantity: seats, + }, + { id: meteredStripeItemId, price: meteredStripePriceId }, + ], + ...(anchor ? { billing_cycle_anchor: anchor } : {}), + ...(proration ? { proration_behavior: proration } : {}), + ...(metadata ? { metadata } : {}), + billing_thresholds: + await this.billingPriceService.getBillingThresholdsByMeterPriceId( + meteredStripePriceId, + ), + }, + ); + } + + async updateSubscription( + subscriptionId: string, + subscriptionUpdate: SubscriptionUpdate, + ): Promise { + const subscription = await this.billingSubscriptionRepository.findOneOrFail( + { + where: { id: subscriptionId }, + relations: [ + 'billingSubscriptionItems', + 'billingSubscriptionItems.billingProduct', + ], + }, + ); + + const licensedItem = + getCurrentLicensedBillingSubscriptionItemOrThrow(subscription); + const meteredItem = + getCurrentMeteredBillingSubscriptionItemOrThrow(subscription); + + const toUpdateCurrentPrices = await this.computeSubscriptionPricesUpdate( + subscriptionUpdate, + { + licensedPriceId: licensedItem.stripePriceId, + meteredPriceId: meteredItem.stripePriceId, + seats: licensedItem.quantity, + }, + ); + + const { schedule, currentPhase, nextPhase } = + await this.stripeSubscriptionScheduleService.loadSubscriptionSchedule( + subscription.stripeSubscriptionId, + ); + + const shouldUpdateAtSubscriptionPeriodEnd = + await this.shouldUpdateAtSubscriptionPeriodEnd( + subscription, + subscriptionUpdate, + ); + + if (shouldUpdateAtSubscriptionPeriodEnd) { + if (!isDefined(schedule)) { + const { schedule, currentPhase } = + await this.stripeSubscriptionScheduleService.createSubscriptionSchedule( + subscription.stripeSubscriptionId, + ); + + await this.runSubscriptionScheduleUpdate({ + stripeScheduleId: schedule.id, + toUpdateCurrentPrices: undefined, + toUpdateNextPrices: toUpdateCurrentPrices, + currentPhase: + this.billingSubscriptionPhaseService.toPhaseUpdateParams( + currentPhase, + ), + subscriptionCurrentPeriodEnd: Math.floor( + subscription.currentPeriodEnd.getTime() / 1000, + ), + }); + } else { + assertIsDefinedOrThrow(nextPhase); + assertIsDefinedOrThrow(currentPhase); + + const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate( + subscriptionUpdate, + getSubscriptionPricesFromSchedulePhase(nextPhase), + ); + + await this.runSubscriptionScheduleUpdate({ + stripeScheduleId: schedule.id, + toUpdateNextPrices, + toUpdateCurrentPrices: undefined, + currentPhase: + this.billingSubscriptionPhaseService.toPhaseUpdateParams( + currentPhase, + ), + subscriptionCurrentPeriodEnd: Math.floor( + subscription.currentPeriodEnd.getTime() / 1000, + ), + }); + } + } else { + const subscriptionOptions = + computeSubscriptionUpdateOptions(subscriptionUpdate); + + await this.runSubscriptionUpdate({ + stripeSubscriptionId: subscription.stripeSubscriptionId, + licensedStripeItemId: licensedItem.stripeSubscriptionItemId, + meteredStripeItemId: meteredItem.stripeSubscriptionItemId, + licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId, + meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId, + seats: toUpdateCurrentPrices.seats, + ...subscriptionOptions, + }); + + if (isDefined(nextPhase)) { + assertIsDefinedOrThrow(schedule); + const { currentPhase: refreshedCurrentPhase } = + await this.stripeSubscriptionScheduleService.loadSubscriptionSchedule( + subscription.stripeSubscriptionId, + ); + + assertIsDefinedOrThrow(refreshedCurrentPhase); + + const nextPhasePrices = + getSubscriptionPricesFromSchedulePhase(nextPhase); + const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate( + subscriptionUpdate, + nextPhasePrices, + ); + + await this.runSubscriptionScheduleUpdate({ + stripeScheduleId: schedule.id, + toUpdateNextPrices, + toUpdateCurrentPrices: undefined, //subscription update causes schedule current phase update + currentPhase: + this.billingSubscriptionPhaseService.toPhaseUpdateParams( + refreshedCurrentPhase, + ), + subscriptionCurrentPeriodEnd: Math.floor( + subscription.currentPeriodEnd.getTime() / 1000, + ), + }); + } + } + + await this.syncSubscriptionToDatabase( + subscription.workspaceId, + subscription.stripeSubscriptionId, + ); + } + + private async runSubscriptionScheduleUpdate({ + stripeScheduleId, + toUpdateNextPrices, + toUpdateCurrentPrices, + currentPhase, + subscriptionCurrentPeriodEnd, + }: { + stripeScheduleId: string; + toUpdateNextPrices: SubscriptionStripePrices; + toUpdateCurrentPrices: SubscriptionStripePrices | undefined; + currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase; + subscriptionCurrentPeriodEnd: number; + }) { + let toUpdateCurrentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = { + ...currentPhase, + end_date: subscriptionCurrentPeriodEnd, + }; + + if (isDefined(toUpdateCurrentPrices)) { + toUpdateCurrentPhase = + await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({ + licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId, + seats: toUpdateCurrentPrices.seats, + meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId, + endDate: subscriptionCurrentPeriodEnd, + startDate: currentPhase.start_date, + }); + } + + const toUpdateNextPhase = + await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({ + licensedStripePriceId: toUpdateNextPrices.licensedPriceId, + seats: toUpdateNextPrices.seats, + meteredStripePriceId: toUpdateNextPrices.meteredPriceId, + startDate: subscriptionCurrentPeriodEnd, + endDate: undefined, + }); + + if ( + await this.billingSubscriptionPhaseService.isSamePhaseSignature( + toUpdateCurrentPhase, + toUpdateNextPhase, + ) + ) { + return await this.stripeSubscriptionScheduleService.releaseSubscriptionSchedule( + stripeScheduleId, + ); + } + + return await this.stripeSubscriptionScheduleService.updateSchedule( + stripeScheduleId, + { + phases: [toUpdateCurrentPhase, toUpdateNextPhase], + }, + ); + } + + private async shouldUpdateAtSubscriptionPeriodEnd( + subscription: BillingSubscriptionEntity, + update: SubscriptionUpdate, + ): Promise { + switch (update.type) { + case SubscriptionUpdateType.PLAN: { + const currentPlan = + subscription.billingSubscriptionItems[0].billingProduct?.metadata + .planKey; + + const isDowngrade = + currentPlan !== update.newPlan && + update.newPlan === BillingPlanKey.PRO; + + return isDowngrade; + } + case SubscriptionUpdateType.METERED_PRICE: { + const currentMeteredPriceId = + subscription.billingSubscriptionItems.find( + (item) => item.quantity == null, + )?.stripePriceId; + + assertIsDefinedOrThrow(currentMeteredPriceId); + const currentMeteredPrice = + await this.billingPriceRepository.findOneOrFail({ + where: { stripePriceId: currentMeteredPriceId }, + relations: ['billingProduct'], + }); + const newMeteredPrice = await this.billingPriceRepository.findOneOrFail( + { + where: { stripePriceId: update.newMeteredPriceId }, + relations: ['billingProduct'], + }, + ); + + billingValidator.assertIsMeteredPrice(currentMeteredPrice); + billingValidator.assertIsMeteredPrice(newMeteredPrice); + + const currentMeteredCap = currentMeteredPrice.tiers[0].up_to; + const newMeteredCap = newMeteredPrice.tiers[0].up_to; + + const isDowngrade = currentMeteredCap > newMeteredCap; + + return isDowngrade; + } + case SubscriptionUpdateType.SEATS: + return false; + case SubscriptionUpdateType.INTERVAL: { + const currentInterval = subscription.interval; + const isDowngrade = + currentInterval !== update.newInterval && + update.newInterval === SubscriptionInterval.Month; + + return isDowngrade; + } + default: { + return assertUnreachable( + update, + 'Should never occur, add validator for new subscription update type', + ); + } + } + } + + async computeSubscriptionPricesUpdate( + update: SubscriptionUpdate, + currentPrices: SubscriptionStripePrices, + ): Promise { + switch (update.type) { + case SubscriptionUpdateType.PLAN: + return await this.computeSubscriptionPricesUpdateByPlan( + update.newPlan, + currentPrices, + ); + case SubscriptionUpdateType.METERED_PRICE: + return await this.computeSubscriptionPricesUpdateByMeteredPrice( + update.newMeteredPriceId, + currentPrices, + ); + case SubscriptionUpdateType.SEATS: + return this.computeSubscriptionPricesUpdateBySeats( + update.newSeats, + currentPrices, + ); + case SubscriptionUpdateType.INTERVAL: + return await this.computeSubscriptionPricesUpdateByInterval( + update.newInterval, + currentPrices, + ); + } + } + + private computeSubscriptionPricesUpdateBySeats( + newSeats: number, + currentPrices: SubscriptionStripePrices, + ): SubscriptionStripePrices { + return { + ...currentPrices, + seats: newSeats, + }; + } + + private async computeSubscriptionPricesUpdateByMeteredPrice( + newMeteredPriceId: string, + currentPrices: SubscriptionStripePrices, + ): Promise { + const currentLicensedPrice = + await this.billingPriceRepository.findOneOrFail({ + where: { stripePriceId: currentPrices.licensedPriceId }, + relations: ['billingProduct'], + }); + const currentInterval = currentLicensedPrice.interval; + const currentPlanKey = + currentLicensedPrice.billingProduct?.metadata.planKey; + + assertIsDefinedOrThrow(currentPlanKey); + + const newMeteredPrice = await this.billingPriceRepository.findOneOrFail({ + where: { stripePriceId: newMeteredPriceId }, + relations: ['billingProduct'], + }); + + billingValidator.assertIsMeteredPrice(newMeteredPrice); + + const newInterval = newMeteredPrice.interval; + const newPlanKey = newMeteredPrice.billingProduct?.metadata.planKey; + + if (newInterval === currentInterval && currentPlanKey === newPlanKey) { + return { + ...currentPrices, + meteredPriceId: newMeteredPriceId, + }; + } + + const newEquivalentMeteredPrice = + await this.billingPriceService.findEquivalentMeteredPrice({ + meteredPrice: newMeteredPrice, + targetInterval: currentInterval, + targetPlanKey: currentPlanKey, + hasSameInterval: newInterval === currentInterval, + hasSamePlanKey: currentPlanKey === newPlanKey, + }); + + return { + ...currentPrices, + meteredPriceId: newEquivalentMeteredPrice.stripePriceId, + }; + } + + private async computeSubscriptionPricesUpdateByPlan( + newPlan: BillingPlanKey, + currentPrices: SubscriptionStripePrices, + ): Promise { + const currentLicensedPrice = + await this.billingPriceRepository.findOneOrFail({ + where: { stripePriceId: currentPrices.licensedPriceId }, + relations: ['billingProduct'], + }); + + const currentInterval = currentLicensedPrice.interval; + const currentPlanKey = + currentLicensedPrice.billingProduct?.metadata.planKey; + + assertIsDefinedOrThrow(currentPlanKey); + + if (currentPlanKey === newPlan) { + return currentPrices; + } + const billingPricesPerPlanAndIntervalArray = await this.billingProductService.getProductPrices({ - interval, - planKey, + interval: currentInterval, + planKey: newPlan, }); const targetLicensedPrice = findOrThrow( @@ -871,718 +925,85 @@ export class BillingSubscriptionService { billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT, ); + const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail( + { + where: { stripePriceId: currentPrices.meteredPriceId }, + relations: ['billingProduct'], + }, + ); + + billingValidator.assertIsMeteredPrice(currentMeteredPrice); + const targetMeteredPrice = - updateType === 'interval' - ? await this.findMeteredMatchingPriceForIntervalSwitching({ - billingPricesPerPlanAndIntervalArray, - meteredPriceId: meteredPriceId, - targetInterval: interval, - }) - : await this.findMeteredMatchingPriceForPlanSwitching({ - billingPricesPerPlanAndIntervalArray, - meteredPriceId: meteredPriceId, - }); + await this.billingPriceService.findEquivalentMeteredPrice({ + meteredPrice: currentMeteredPrice, + targetInterval: currentInterval, + targetPlanKey: newPlan, + hasSameInterval: true, + hasSamePlanKey: false, + }); return { - targetLicensedPrice, - targetMeteredPrice, + ...currentPrices, + licensedPriceId: targetLicensedPrice.stripePriceId, + meteredPriceId: targetMeteredPrice.stripePriceId, }; } - private async setTargetInterval( - billingSubscription: BillingSubscriptionEntity, - targetInterval: SubscriptionInterval, - ): Promise { - const { currentEditable } = await this.loadScheduleEditable( - billingSubscription.stripeSubscriptionId, + private async computeSubscriptionPricesUpdateByInterval( + newInterval: SubscriptionInterval, + currentPrices: SubscriptionStripePrices, + ): Promise { + const currentLicensedPrice = + await this.billingPriceRepository.findOneOrFail({ + where: { stripePriceId: currentPrices.licensedPriceId }, + relations: ['billingProduct'], + }); + + const currentInterval = currentLicensedPrice.interval; + const currentPlanKey = + currentLicensedPrice.billingProduct?.metadata.planKey; + + assertIsDefinedOrThrow(currentPlanKey); + + if (currentInterval === newInterval) { + return currentPrices; + } + + const billingPricesPerPlanAndIntervalArray = + await this.billingProductService.getProductPrices({ + interval: newInterval, + planKey: currentPlanKey, + }); + + const targetLicensedPrice = findOrThrow( + billingPricesPerPlanAndIntervalArray, + ({ billingProduct }) => + billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT, ); - const currentDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - currentEditable as BillingSubscriptionSchedulePhaseDTO, - ); - const { nextEditable } = await this.loadScheduleEditable( - billingSubscription.stripeSubscriptionId, - ); - - const currentInterval = currentDetails.interval; - const planKey = currentDetails.plan.planKey; - const seats = currentDetails.quantity; - const currentMeteredPriceId = currentDetails.meteredPrice.stripePriceId; - - // Case A: Already on target interval - if (currentInterval === targetInterval) { - const hasNext = !!nextEditable; - - if (!hasNext) return; - - const nextDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ); - - if (nextDetails.interval !== targetInterval) { - const { targetLicensedPrice, targetMeteredPrice } = - await this.resolvePrices({ - interval: targetInterval, - planKey: nextDetails.plan.planKey, - meteredPriceId: nextDetails.meteredPrice.stripePriceId, - updateType: 'interval', - }); - - return this.downgradeDeferred( - billingSubscription.stripeSubscriptionId, - { - current: { - licensedPriceId: ( - await this.resolvePrices({ - interval: currentInterval, - planKey, - meteredPriceId: currentMeteredPriceId, - updateType: 'interval', - }) - ).targetLicensedPrice.stripePriceId, - meteredPriceId: currentMeteredPriceId, - seats, - }, - next: { - licensedPriceId: targetLicensedPrice.stripePriceId, - meteredPriceId: targetMeteredPrice.stripePriceId, - seats: nextDetails.quantity, - }, - }, - ); - } - - return; - } - - // Case B: Month -> Year - if ( - currentInterval === SubscriptionInterval.Month && - targetInterval === SubscriptionInterval.Year - ) { - if (billingSubscription.status === SubscriptionStatus.Trialing) { - throw new BillingException( - 'Interval cannot be changed from Month to Year while trialing', - BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE, - ); - } - const { targetLicensedPrice, targetMeteredPrice } = - await this.resolvePrices({ - interval: SubscriptionInterval.Year, - planKey, - meteredPriceId: currentMeteredPriceId, - updateType: 'interval', - }); - - await this.upgradeIntervalNowWithReanchor( - billingSubscription.stripeSubscriptionId, - { - licensedPriceId: targetLicensedPrice.stripePriceId, - meteredPriceId: targetMeteredPrice.stripePriceId, - seats, - }, - ); - - const { currentEditable, nextEditable, subscription, schedule } = - await this.loadScheduleEditable( - billingSubscription.stripeSubscriptionId, - ); - - if (nextEditable && currentEditable) { - const reloadedNextDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ); - - const mappedNext = await this.resolvePrices({ - interval: SubscriptionInterval.Year, - planKey: reloadedNextDetails.plan.planKey, - meteredPriceId: reloadedNextDetails.meteredPrice.stripePriceId, - updateType: 'interval', - }); - - const currentSnap = - this.billingSubscriptionPhaseService.toSnapshot(currentEditable); - - const nextPhaseForYear = - await this.billingSubscriptionPhaseService.buildSnapshot( - { - start_date: subscription.current_period_end, - items: currentSnap.items, - proration_behavior: 'none', - } as Stripe.SubscriptionScheduleUpdateParams.Phase, - mappedNext.targetLicensedPrice.stripePriceId, - reloadedNextDetails.quantity, - mappedNext.targetMeteredPrice.stripePriceId, - ); - - return await this.scheduleReplaceNext({ - subscription, - scheduleId: schedule.id, - currentPhaseSnapshot: currentSnap, - nextPhase: nextPhaseForYear, - }); - } - - return; - } - - // Case C: Year -> Month - if ( - currentInterval === SubscriptionInterval.Year && - targetInterval === SubscriptionInterval.Month - ) { - const hasNext = !!nextEditable; - - const nextDetails = hasNext - ? await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ) - : undefined; - - const nextPlanKey = nextDetails?.plan.planKey ?? planKey; - const nextMeteredPriceId = - nextDetails?.meteredPrice.stripePriceId ?? currentMeteredPriceId; - - const currentPrices = await this.resolvePrices({ - interval: SubscriptionInterval.Month, - planKey, - meteredPriceId: currentMeteredPriceId, - updateType: 'interval', - }); - - const nextPrices = await this.resolvePrices({ - interval: SubscriptionInterval.Month, - planKey: nextPlanKey, - meteredPriceId: nextMeteredPriceId, - updateType: 'interval', - }); - - return this.downgradeDeferred(billingSubscription.stripeSubscriptionId, { - current: { - licensedPriceId: currentPrices.targetLicensedPrice.stripePriceId, - meteredPriceId: currentMeteredPriceId, - seats, - }, - next: { - licensedPriceId: nextPrices.targetLicensedPrice.stripePriceId, - meteredPriceId: nextPrices.targetMeteredPrice.stripePriceId, - seats, - }, - }); - } - - throw new BillingException( - `Unhandled interval transition from ${currentInterval} to ${targetInterval}`, - BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE, - ); - } - - private async setTargetPlan( - stripeSubscriptionId: string, - targetPlanKey: BillingPlanKey, - ): Promise { - const { currentEditable, nextEditable } = - await this.loadScheduleEditable(stripeSubscriptionId); - - const currentDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - currentEditable as BillingSubscriptionSchedulePhaseDTO, - ); - - const currentPlan = currentDetails.plan.planKey; - const interval = currentDetails.interval; - const seats = currentDetails.quantity; - const currentMeteredPriceId = currentDetails.meteredPrice.stripePriceId; - - // Case A: Already on target plan - if (currentPlan === targetPlanKey) { - const hasNext = !!nextEditable; - - if (!hasNext) return; - - const nextDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ); - - if (nextDetails.plan.planKey !== targetPlanKey) { - const preservedNextInterval = nextDetails.interval; - const preservedNextMeteredId = nextDetails.meteredPrice.stripePriceId; - - const { targetLicensedPrice, targetMeteredPrice } = - await this.resolvePrices({ - interval: preservedNextInterval, - planKey: targetPlanKey, - meteredPriceId: preservedNextMeteredId, - updateType: 'plan', - }); - - await this.downgradeDeferred(stripeSubscriptionId, { - current: { - licensedPriceId: ( - await this.resolvePrices({ - interval, - planKey: currentPlan, - meteredPriceId: currentMeteredPriceId, - updateType: 'plan', - }) - ).targetLicensedPrice.stripePriceId, - meteredPriceId: currentMeteredPriceId, - seats, - }, - next: { - licensedPriceId: targetLicensedPrice.stripePriceId, - meteredPriceId: targetMeteredPrice.stripePriceId, - seats: nextDetails.quantity, - planKey: BillingPlanKey.PRO, - }, - }); - } - - return; - } - - // Case B: PRO -> ENTERPRISE - if ( - currentPlan === BillingPlanKey.PRO && - targetPlanKey === BillingPlanKey.ENTERPRISE - ) { - const { targetLicensedPrice, targetMeteredPrice } = - await this.resolvePrices({ - interval, - planKey: targetPlanKey, - meteredPriceId: currentMeteredPriceId, - updateType: 'plan', - }); - - await this.upgradePlanNow(stripeSubscriptionId, { - licensedPriceId: targetLicensedPrice.stripePriceId, - meteredPriceId: targetMeteredPrice.stripePriceId, - seats, - planMeta: targetPlanKey, - }); - - const { currentEditable, nextEditable, subscription, schedule } = - await this.loadScheduleEditable(stripeSubscriptionId); - - if (nextEditable && currentEditable) { - const nextDetails = - await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ); - - 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: 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; - } - - // Case C: ENTERPRISE -> PRO (deferred) - if ( - currentPlan === BillingPlanKey.ENTERPRISE && - targetPlanKey === BillingPlanKey.PRO - ) { - const hasNext = !!nextEditable; - - const nextDetails = hasNext - ? await this.billingSubscriptionPhaseService.getDetailsFromPhase( - nextEditable as BillingSubscriptionSchedulePhaseDTO, - ) - : undefined; - - const preservedNextInterval = nextDetails?.interval ?? interval; - const preservedNextMeteredId = - nextDetails?.meteredPrice.stripePriceId ?? currentMeteredPriceId; - - const currentPrices = await this.resolvePrices({ - interval, - planKey: currentPlan, - meteredPriceId: currentMeteredPriceId, - updateType: 'plan', - }); - - const nextPrices = await this.resolvePrices({ - interval: preservedNextInterval, - planKey: targetPlanKey, - meteredPriceId: preservedNextMeteredId, - updateType: 'plan', - }); - - return await this.downgradeDeferred(stripeSubscriptionId, { - current: { - licensedPriceId: currentPrices.targetLicensedPrice.stripePriceId, - meteredPriceId: currentMeteredPriceId, - seats, - }, - next: { - licensedPriceId: nextPrices.targetLicensedPrice.stripePriceId, - meteredPriceId: nextPrices.targetMeteredPrice.stripePriceId, - seats, - planKey: BillingPlanKey.PRO, - }, - }); - } - - throw new BillingException( - `Unhandled plan transition from ${currentPlan} to ${targetPlanKey}`, - BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE, - ); - } - - private async updateSubscription(params: { - stripeSubscriptionId: string; - licensedItemId: string; - meteredItemId: string; - licensedPriceId: string; - meteredPriceId: string; - seats: number; - anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor; - proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior; - metadata?: Record; - }) { - const { - stripeSubscriptionId, - licensedItemId, - meteredItemId, - licensedPriceId, - meteredPriceId, - seats, - anchor, - proration, - metadata, - } = params; - - return this.stripeSubscriptionService.updateSubscription( - stripeSubscriptionId, + const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail( { - items: [ - { id: licensedItemId, price: licensedPriceId, quantity: seats }, - { id: meteredItemId, price: meteredPriceId }, - ], - ...(anchor ? { billing_cycle_anchor: anchor } : {}), - ...(proration ? { proration_behavior: proration } : {}), - ...(metadata ? { metadata } : {}), - billing_thresholds: - await this.billingPriceService.getBillingThresholdsByMeterPriceId( - meteredPriceId, - ), + where: { stripePriceId: currentPrices.meteredPriceId }, + relations: ['billingProduct'], }, ); - } - private async scheduleReplaceNext(params: { - scheduleId: string; - subscription: SubscriptionWithSchedule | Stripe.Subscription; - currentPhaseSnapshot: Stripe.SubscriptionScheduleUpdateParams.Phase; - nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase; - }): Promise { - const { scheduleId, subscription } = params; - let { nextPhase, currentPhaseSnapshot } = params; + billingValidator.assertIsMeteredPrice(currentMeteredPrice); - const currentPhaseToPersist: Stripe.SubscriptionScheduleUpdateParams.Phase = - { - ...currentPhaseSnapshot, - end_date: subscription.current_period_end, - }; - - if ( - nextPhase && - (await this.billingSubscriptionPhaseService.isSamePhaseSignature( - currentPhaseSnapshot, - nextPhase, - )) - ) { - nextPhase = undefined; - } - - await this.stripeSubscriptionScheduleService.replaceEditablePhases( - scheduleId, - { - currentPhaseSnapshot: currentPhaseToPersist, - nextPhase, - }, - ); - const refreshed = - await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( - subscription.id, - ); - const workspaceId = ( - await this.billingSubscriptionRepository.findOneOrFail({ - where: { - stripeSubscriptionId: refreshed.id, - }, - }) - ).workspaceId; - - await this.syncSubscriptionToDatabase(workspaceId, refreshed); - } - - private async upgradePlanNow( - stripeSubscriptionId: string, - newPrices: { - licensedPriceId: string; - meteredPriceId: string; - seats: number; - planMeta?: BillingPlanKey; - }, - ): Promise { - const currentSubscription = - await this.billingSubscriptionRepository.findOneOrFail({ - where: { stripeSubscriptionId }, - relations: [ - 'billingSubscriptionItems', - 'billingSubscriptionItems.billingProduct', - ], + const targetMeteredPrice = + await this.billingPriceService.findEquivalentMeteredPrice({ + meteredPrice: currentMeteredPrice, + targetInterval: newInterval, + targetPlanKey: currentPlanKey, + hasSameInterval: false, + hasSamePlanKey: true, }); - const currentLicenseSubsciptionItem = - this.getCurrentLicensedBillingSubscriptionItemOrThrow( - currentSubscription, - ); - const currentMeteredSubsciptionItem = - this.getCurrentMeteredBillingSubscriptionItemOrThrow(currentSubscription); - - const updatedSubscription = await this.updateSubscription({ - stripeSubscriptionId, - licensedItemId: currentLicenseSubsciptionItem.stripeSubscriptionItemId, - meteredItemId: currentMeteredSubsciptionItem.stripeSubscriptionItemId, - licensedPriceId: newPrices.licensedPriceId, - meteredPriceId: newPrices.meteredPriceId, - seats: newPrices.seats, - proration: 'create_prorations', - metadata: newPrices.planMeta - ? { ...(currentSubscription?.metadata || {}), plan: newPrices.planMeta } - : undefined, - }); - - await this.syncSubscriptionToDatabase( - currentSubscription.workspaceId, - updatedSubscription, - ); - } - - private async upgradeIntervalNowWithReanchor( - stripeSubscriptionId: string, - prices: { - licensedPriceId: string; - meteredPriceId: string; - seats: number; - }, - ): Promise { - const sub = await this.billingSubscriptionRepository.findOneOrFail({ - where: { stripeSubscriptionId }, - relations: [ - 'billingSubscriptionItems', - 'billingSubscriptionItems.billingProduct', - ], - }); - - const licensed = this.getCurrentLicensedBillingSubscriptionItemOrThrow(sub); - const metered = this.getCurrentMeteredBillingSubscriptionItemOrThrow(sub); - - const updatedSubscription = await this.updateSubscription({ - stripeSubscriptionId, - licensedItemId: licensed.stripeSubscriptionItemId, - meteredItemId: metered.stripeSubscriptionItemId, - licensedPriceId: prices.licensedPriceId, - meteredPriceId: prices.meteredPriceId, - seats: prices.seats, - anchor: 'now', - proration: 'create_prorations', - }); - - await this.syncSubscriptionToDatabase(sub.workspaceId, updatedSubscription); - } - - private async downgradeDeferred( - stripeSubscriptionId: string, - prices: { - current: { - licensedPriceId: string; - meteredPriceId: string; - seats: number; - }; - next: { - licensedPriceId: string; - meteredPriceId: string; - seats: number; - planKey?: BillingPlanKey; - }; - }, - ): Promise { - const subscription = - await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( - stripeSubscriptionId, - ); - const schedule = - await this.stripeSubscriptionScheduleService.findOrCreateSubscriptionSchedule( - subscription, - ); - - const { currentEditable } = - this.stripeSubscriptionScheduleService.getEditablePhases(schedule); - const currentPhaseSnapshot = - this.billingSubscriptionPhaseService.toSnapshot( - currentEditable as Stripe.SubscriptionSchedule.Phase, - ); - const next = await this.billingSubscriptionPhaseService.buildSnapshot( - { - start_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.scheduleReplaceNext({ - scheduleId: schedule.id, - subscription, - currentPhaseSnapshot, - nextPhase: next, - }); - } - - private scaleCap( - cap: number, - from: SubscriptionInterval, - to: SubscriptionInterval, - ) { - if (from === to) return cap; - - return from === SubscriptionInterval.Month && - to === SubscriptionInterval.Year - ? cap * 12 - : cap / 12; - } - - private filterMeteredCandidates( - catalog: BillingPriceEntity[], - interval?: SubscriptionInterval, - ) { - const pool = interval - ? catalog.filter((p) => p.interval === interval) - : catalog; - - return ( - pool.filter((p) => - billingValidator.isMeteredPrice(p), - ) as BillingMeterPrice[] - ).sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to); - } - - private async findMeteredMatchFloor( - catalog: BillingPriceEntity[], - referencePriceId: string, - targetInterval?: SubscriptionInterval, - ): Promise { - const reference = - await this.getMeteredBillingPriceByPriceId(referencePriceId); - - const refCap = targetInterval - ? this.scaleCap( - reference.tiers[0].up_to, - reference.interval, - targetInterval, - ) - : reference.tiers[0].up_to; - - const candidates = this.filterMeteredCandidates(catalog, targetInterval); - - if (!candidates.length) { - throw new BillingException( - 'No metered candidates found for mapping', - BillingExceptionCode.BILLING_PRICE_NOT_FOUND, - ); - } - - return ( - candidates.filter((p) => p.tiers[0].up_to <= refCap).pop() ?? - candidates[0] - ); - } - - async findMeteredMatchingPriceForIntervalSwitching({ - billingPricesPerPlanAndIntervalArray, - meteredPriceId, - targetInterval, - }: { - billingPricesPerPlanAndIntervalArray: BillingPriceEntity[]; - meteredPriceId: string; - targetInterval: SubscriptionInterval; - }): Promise< - Omit & { tiers: MeterBillingPriceTiers } - > { - const mapped = await this.findMeteredMatchFloor( - billingPricesPerPlanAndIntervalArray, - meteredPriceId, - targetInterval, - ); - - return mapped as BillingMeterPrice; - } - - async findMeteredMatchingPriceForPlanSwitching({ - billingPricesPerPlanAndIntervalArray, - meteredPriceId, - }: { - billingPricesPerPlanAndIntervalArray: BillingPriceEntity[]; - meteredPriceId: string; - }): Promise { - return (await this.findMeteredMatchFloor( - billingPricesPerPlanAndIntervalArray, - meteredPriceId, - )) as BillingMeterPrice; - } - - async findMeteredMatchingPriceForMeteredPriceSwitching({ - billingPricesPerPlanAndIntervalArray, - targetMeteredPriceId, - interval, - }: { - billingPricesPerPlanAndIntervalArray: BillingPriceEntity[]; - targetMeteredPriceId: string; - interval: SubscriptionInterval; - }): Promise { - return this.findMeteredMatchFloor( - billingPricesPerPlanAndIntervalArray, - targetMeteredPriceId, - interval, - ); + return { + ...currentPrices, + licensedPriceId: targetLicensedPrice.stripePriceId, + meteredPriceId: targetMeteredPrice.stripePriceId, + }; } } 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 437a2a1bed..72f54a59db 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 @@ -2,18 +2,17 @@ import { Injectable, Logger } from '@nestjs/common'; -import { findOrThrow } from 'twenty-shared/utils'; +import { findOrThrow, isDefined } from 'twenty-shared/utils'; 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 { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; -import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils'; import { BillingException, BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class StripeSubscriptionScheduleService { @@ -34,40 +33,11 @@ export class StripeSubscriptionScheduleService { ); } - private snapshotFromLivePhase(phase: Stripe.SubscriptionSchedule.Phase) { - return { - start_date: phase.start_date, - end_date: phase.end_date ?? undefined, - items: (phase.items || []).map((i) => ({ - price: normalizePriceRef(i.price) as string, - quantity: i.quantity ?? undefined, - })), - proration_behavior: 'none', - ...(phase.billing_thresholds - ? { billing_thresholds: phase.billing_thresholds } - : {}), - } as Stripe.SubscriptionScheduleUpdateParams.Phase; - } - - private computeBaseStart( - currentEditable: Stripe.SubscriptionSchedule.Phase | undefined, - nextEditable: Stripe.SubscriptionSchedule.Phase | undefined, - live: Stripe.SubscriptionSchedule, - now: number, - ): number { - const cEnd = (currentEditable?.end_date as number | undefined) ?? 0; - const curPhaseEnd = - (live.current_phase?.end_date as number | undefined) ?? 0; - const nStart = (nextEditable?.start_date as number | undefined) ?? 0; - - return Math.max(cEnd, curPhaseEnd, nStart, now + 1); - } - - getEditablePhases(live: Stripe.SubscriptionSchedule) { + getPhases(subscriptionSchedule: Stripe.SubscriptionSchedule) { const now = Math.floor(Date.now() / 1000); - const currentEditable = findOrThrow( - live.phases, + const currentPhase = findOrThrow( + subscriptionSchedule.phases, (p) => { const s = p.start_date ?? 0; const e = p.end_date ?? Infinity; @@ -75,20 +45,20 @@ export class StripeSubscriptionScheduleService { return s <= now && now < e; }, new BillingException( - `Subscription must have at least 1 phase to be editable`, + `Subscription must have at least 1 current phase`, BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND, ), ); - const nextEditable = (live.phases || []) + const nextPhase = (subscriptionSchedule.phases || []) .filter((p) => (p.start_date ?? 0) > now) .sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0] as | Stripe.SubscriptionSchedule.Phase | undefined; return { - currentEditable, - nextEditable, + currentPhase, + nextPhase, }; } @@ -98,95 +68,60 @@ export class StripeSubscriptionScheduleService { })) as SubscriptionWithSchedule; } - async retrieveSchedule(scheduleId: string) { - if (!this.stripe) throw new Error('Billing is disabled'); - - return this.stripe.subscriptionSchedules.retrieve(scheduleId, { - expand: ['subscription'], - }); - } - async updateSchedule( scheduleId: string, params: Stripe.SubscriptionScheduleUpdateParams, ) { if (!this.stripe) throw new Error('Billing is disabled'); - return this.stripe.subscriptionSchedules.update(scheduleId, params); + return await this.stripe.subscriptionSchedules.update(scheduleId, params); } - async createScheduleFromSubscription(subscriptionId: string) { + async createSubscriptionSchedule(stripeSubscriptionId: string) { if (!this.stripe) throw new Error('Billing is disabled'); - return this.stripe.subscriptionSchedules.create({ - from_subscription: subscriptionId, + const schedule = await this.stripe.subscriptionSchedules.create({ + from_subscription: stripeSubscriptionId, }); + + const currentPhase = this.getPhases(schedule).currentPhase; + + return { + schedule, + currentPhase, + }; } - async findOrCreateSubscriptionSchedule( - subscription: SubscriptionWithSchedule, - ) { - if (subscription.schedule) return subscription.schedule; + async loadSubscriptionSchedule(stripeSubscriptionId: string) { + const subscriptionWithSchedule = + await this.getSubscriptionWithSchedule(stripeSubscriptionId); - return this.createScheduleFromSubscription(subscription.id); + if (!isDefined(subscriptionWithSchedule.schedule)) { + return {}; + } + + const { currentPhase, nextPhase } = this.getPhases( + subscriptionWithSchedule.schedule, + ); + + if (!isDefined(nextPhase)) { + await this.releaseSubscriptionSchedule( + subscriptionWithSchedule.schedule.id, + ); + + return {}; + } + + return { + schedule: subscriptionWithSchedule.schedule, + currentPhase, + nextPhase, + }; } - async replaceEditablePhases( - scheduleId: string, - desired: { - currentPhaseSnapshot?: Stripe.SubscriptionScheduleUpdateParams.Phase; - nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase; - }, - ): Promise { + releaseSubscriptionSchedule(scheduleId: string) { if (!this.stripe) throw new Error('Billing is disabled'); - const live = await this.retrieveSchedule(scheduleId); - const { currentEditable, nextEditable } = this.getEditablePhases(live); - const now = Math.floor(Date.now() / 1000); - - const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = []; - - const currentPhaseSnapshot = - desired.currentPhaseSnapshot ?? - this.snapshotFromLivePhase(currentEditable); - - phases.push(currentPhaseSnapshot); - - const hasNextKey = 'nextPhase' in desired; - const wantsNext = hasNextKey && !!desired.nextPhase; - const wantsDeleteNext = hasNextKey && !desired.nextPhase; - const preserveExistingNext = !hasNextKey && !!nextEditable; - - if (wantsNext) { - phases.push({ - ...desired.nextPhase!, - start_date: this.computeBaseStart( - currentEditable, - nextEditable, - live, - now, - ), - proration_behavior: 'none', - }); - } - - if (!wantsNext && !wantsDeleteNext && preserveExistingNext) { - phases.push(this.snapshotFromLivePhase(nextEditable!)); - } - - if (phases.length === 0 && wantsNext) { - phases.push({ - ...desired.nextPhase!, - start_date: Math.max( - (live.current_phase?.end_date as number | undefined) ?? 0, - now + 1, - ), - proration_behavior: 'none', - }); - } - - if (phases.length === 0) return live; - - return this.updateSchedule(scheduleId, { phases }); + return this.stripe.subscriptionSchedules.release(scheduleId); } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-subscription-update.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-subscription-update.type.ts new file mode 100644 index 0000000000..fd688bacbe --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-subscription-update.type.ts @@ -0,0 +1,27 @@ +import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; + +export enum SubscriptionUpdateType { + PLAN = 'PLAN', + METERED_PRICE = 'METERED_PRICE', + SEATS = 'SEATS', + INTERVAL = 'INTERVAL', +} + +export type SubscriptionUpdate = + | { + type: SubscriptionUpdateType.PLAN; + newPlan: BillingPlanKey; + } + | { + type: SubscriptionUpdateType.METERED_PRICE; + newMeteredPriceId: string; + } + | { + type: SubscriptionUpdateType.SEATS; + newSeats: number; + } + | { + type: SubscriptionUpdateType.INTERVAL; + newInterval: SubscriptionInterval; + }; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/compute-subscription-update-options.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/compute-subscription-update-options.util.spec.ts new file mode 100644 index 0000000000..c162f39260 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/compute-subscription-update-options.util.spec.ts @@ -0,0 +1,68 @@ +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 { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type'; +import { computeSubscriptionUpdateOptions } from 'src/engine/core-modules/billing/utils/compute-subscription-update-options.util'; + +describe('computeSubscriptionUpdateOptions', () => { + it('returns proration and plan metadata for PLAN update type', () => { + const result = computeSubscriptionUpdateOptions({ + type: SubscriptionUpdateType.PLAN, + newPlan: BillingPlanKey.PRO, + }); + + expect(result).toEqual({ + proration: 'create_prorations', + metadata: { + plan: BillingPlanKey.PRO, + }, + }); + }); + + it('returns proration and enterprise plan metadata for PLAN update type', () => { + const result = computeSubscriptionUpdateOptions({ + type: SubscriptionUpdateType.PLAN, + newPlan: BillingPlanKey.ENTERPRISE, + }); + + expect(result).toEqual({ + proration: 'create_prorations', + metadata: { + plan: BillingPlanKey.ENTERPRISE, + }, + }); + }); + + it('returns only proration for METERED_PRICE update type', () => { + const result = computeSubscriptionUpdateOptions({ + type: SubscriptionUpdateType.METERED_PRICE, + newMeteredPriceId: 'price_123', + }); + + expect(result).toEqual({ + proration: 'create_prorations', + }); + }); + + it('returns proration and anchor for INTERVAL update type', () => { + const result = computeSubscriptionUpdateOptions({ + type: SubscriptionUpdateType.INTERVAL, + newInterval: SubscriptionInterval.Month, + }); + + expect(result).toEqual({ + proration: 'create_prorations', + anchor: 'now', + }); + }); + + it('returns only proration for SEATS update type', () => { + const result = computeSubscriptionUpdateOptions({ + type: SubscriptionUpdateType.SEATS, + newSeats: 10, + }); + + expect(result).toEqual({ + proration: 'create_prorations', + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-licensed-billing-subscription-item-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-licensed-billing-subscription-item-or-throw.util.spec.ts new file mode 100644 index 0000000000..8945fbb6a9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-licensed-billing-subscription-item-or-throw.util.spec.ts @@ -0,0 +1,97 @@ +import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util'; + +describe('getCurrentLicensedBillingSubscriptionItemOrThrow', () => { + it('returns the licensed billing subscription item when found', () => { + const licensedItem = { + id: 'item_licensed', + quantity: 5, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }; + + const meteredItem = { + id: 'item_metered', + quantity: null, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.METERED, + }, + }, + }; + + const billingSubscription = { + billingSubscriptionItems: [meteredItem, licensedItem], + } as unknown as BillingSubscriptionEntity; + + const result = + getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription); + + expect(result).toBe(licensedItem); + }); + + it('returns the first licensed item when multiple licensed items exist', () => { + const firstLicensedItem = { + id: 'item_licensed_1', + quantity: 5, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }; + + const secondLicensedItem = { + id: 'item_licensed_2', + quantity: 10, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }; + + const billingSubscription = { + billingSubscriptionItems: [firstLicensedItem, secondLicensedItem], + } as unknown as BillingSubscriptionEntity; + + const result = + getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription); + + expect(result).toBe(firstLicensedItem); + }); + + it('throws when no licensed billing subscription item is found', () => { + const meteredItem = { + id: 'item_metered', + quantity: null, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.METERED, + }, + }, + }; + + const billingSubscription = { + billingSubscriptionItems: [meteredItem], + } as unknown as BillingSubscriptionEntity; + + expect(() => + getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription), + ).toThrow(); + }); + + it('throws when subscription has no items', () => { + const billingSubscription = { + billingSubscriptionItems: [], + } as unknown as BillingSubscriptionEntity; + + expect(() => + getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription), + ).toThrow(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-metered-billing-subscription-item-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-metered-billing-subscription-item-or-throw.util.spec.ts new file mode 100644 index 0000000000..68141e9827 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-metered-billing-subscription-item-or-throw.util.spec.ts @@ -0,0 +1,56 @@ +import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { getCurrentMeteredBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util'; + +describe('getCurrentMeteredBillingSubscriptionItemOrThrow', () => { + it('returns the metered billing subscription item when found', () => { + const licensedItem = { + id: 'item_licensed', + quantity: 5, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }; + + const meteredItem = { + id: 'item_metered', + quantity: null, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.METERED, + }, + }, + }; + + const billingSubscription = { + billingSubscriptionItems: [licensedItem, meteredItem], + } as unknown as BillingSubscriptionEntity; + + const result = + getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription); + + expect(result).toBe(meteredItem); + }); + + it('throws when no metered billing subscription item is found', () => { + const licensedItem = { + id: 'item_licensed', + quantity: 5, + billingProduct: { + metadata: { + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }; + + const billingSubscription = { + billingSubscriptionItems: [licensedItem], + } as unknown as BillingSubscriptionEntity; + + expect(() => + getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription), + ).toThrow(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-subscription-prices-from-schedule-phase.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-subscription-prices-from-schedule-phase.util.spec.ts new file mode 100644 index 0000000000..21a5472980 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/get-subscription-prices-from-schedule-phase.util.spec.ts @@ -0,0 +1,69 @@ +import { type Stripe } from 'stripe'; + +import { getSubscriptionPricesFromSchedulePhase } from 'src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util'; + +describe('getSubscriptionPricesFromSchedulePhase', () => { + it('returns licensed price id, metered price id, and seats from phase with string prices', () => { + const phase = { + items: [ + { price: 'price_licensed_123', quantity: 10 }, + { price: 'price_metered_456', quantity: undefined }, + ], + } as unknown as Stripe.SubscriptionSchedule.Phase; + + const result = getSubscriptionPricesFromSchedulePhase(phase); + + expect(result).toEqual({ + licensedPriceId: 'price_licensed_123', + meteredPriceId: 'price_metered_456', + seats: 10, + }); + }); + + it('returns licensed price id, metered price id, and seats from phase with object prices', () => { + const phase = { + items: [ + { price: { id: 'price_licensed_abc' }, quantity: 5 }, + { price: { id: 'price_metered_def' }, quantity: undefined }, + ], + } as unknown as Stripe.SubscriptionSchedule.Phase; + + const result = getSubscriptionPricesFromSchedulePhase(phase); + + expect(result).toEqual({ + licensedPriceId: 'price_licensed_abc', + meteredPriceId: 'price_metered_def', + seats: 5, + }); + }); + + it('throws when no licensed item is found', () => { + const phase = { + items: [ + { price: 'price_metered_1', quantity: undefined }, + { price: 'price_metered_2', quantity: undefined }, + ], + } as unknown as Stripe.SubscriptionSchedule.Phase; + + expect(() => getSubscriptionPricesFromSchedulePhase(phase)).toThrow(); + }); + + it('throws when no metered item is found', () => { + const phase = { + items: [ + { price: 'price_licensed_1', quantity: 5 }, + { price: 'price_licensed_2', quantity: 10 }, + ], + } as unknown as Stripe.SubscriptionSchedule.Phase; + + expect(() => getSubscriptionPricesFromSchedulePhase(phase)).toThrow(); + }); + + it('throws when phase has no items', () => { + const phase = { + items: [], + } as unknown as Stripe.SubscriptionSchedule.Phase; + + expect(() => getSubscriptionPricesFromSchedulePhase(phase)).toThrow(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/normalize-price-ref.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/normalize-price-ref.utils.spec.ts index 528ef1400f..7b4cad7939 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/normalize-price-ref.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/normalize-price-ref.utils.spec.ts @@ -8,16 +8,4 @@ describe('normalizePriceRef', () => { it('returns the id field when input is an object with id', () => { expect(normalizePriceRef({ id: 'price_abc' })).toBe('price_abc'); }); - - it('returns undefined for null input', () => { - expect(normalizePriceRef(null)).toBeUndefined(); - }); - - it('returns undefined for undefined input', () => { - expect(normalizePriceRef(undefined)).toBeUndefined(); - }); - - it('preserves empty string ids', () => { - expect(normalizePriceRef({ id: '' })).toBe(''); - }); }); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/compute-subscription-update-options.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/compute-subscription-update-options.util.ts new file mode 100644 index 0000000000..5667a22b4f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/compute-subscription-update-options.util.ts @@ -0,0 +1,45 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import type Stripe from 'stripe'; + +import { + SubscriptionUpdateType, + type SubscriptionUpdate, +} from 'src/engine/core-modules/billing/types/billing-subscription-update.type'; + +export const computeSubscriptionUpdateOptions = ( + subscriptionUpdate: SubscriptionUpdate, +): { + proration: Stripe.SubscriptionUpdateParams.ProrationBehavior; + metadata?: Record; + anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor; +} => { + switch (subscriptionUpdate.type) { + case SubscriptionUpdateType.PLAN: + return { + proration: 'create_prorations', + metadata: { + plan: subscriptionUpdate.newPlan, + }, + }; + case SubscriptionUpdateType.METERED_PRICE: + return { + proration: 'create_prorations', + }; + + case SubscriptionUpdateType.INTERVAL: + return { + proration: 'create_prorations', + anchor: 'now', + }; + case SubscriptionUpdateType.SEATS: + return { + proration: 'create_prorations', + }; + default: + return assertUnreachable( + subscriptionUpdate, + 'Should never occur, add validator for new subscription update type', + ); + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util.ts new file mode 100644 index 0000000000..f65f32abea --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util.ts @@ -0,0 +1,15 @@ +import { findOrThrow } from 'twenty-shared/utils'; + +import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { type LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type'; + +export const getCurrentLicensedBillingSubscriptionItemOrThrow = ( + billingSubscription: BillingSubscriptionEntity, +) => { + return findOrThrow( + billingSubscription.billingSubscriptionItems, + ({ billingProduct }) => + billingProduct.metadata.priceUsageBased === BillingUsageType.LICENSED, + ) as LicensedBillingSubscriptionItem; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util.ts new file mode 100644 index 0000000000..aed699ff8a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util.ts @@ -0,0 +1,15 @@ +import { findOrThrow } from 'twenty-shared/utils'; + +import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { type MeteredBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type'; + +export const getCurrentMeteredBillingSubscriptionItemOrThrow = ( + billingSubscription: BillingSubscriptionEntity, +) => { + return findOrThrow( + billingSubscription.billingSubscriptionItems, + ({ billingProduct }) => + billingProduct.metadata.priceUsageBased === BillingUsageType.METERED, + ) as MeteredBillingSubscriptionItem; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util.ts new file mode 100644 index 0000000000..7eea42e8a8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util.ts @@ -0,0 +1,22 @@ +import { type Stripe } from 'stripe'; +import { assertIsDefinedOrThrow, findOrThrow } from 'twenty-shared/utils'; + +import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils'; + +export const getSubscriptionPricesFromSchedulePhase = ( + phase: Stripe.SubscriptionSchedule.Phase, +) => { + const licensedItem = findOrThrow( + phase.items, + (item) => item.quantity != null, + ); + + assertIsDefinedOrThrow(licensedItem.quantity); + const meteredItem = findOrThrow(phase.items, (item) => item.quantity == null); + + return { + licensedPriceId: normalizePriceRef(licensedItem.price), + meteredPriceId: normalizePriceRef(meteredItem.price), + seats: licensedItem.quantity, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/normalize-price-ref.utils.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/normalize-price-ref.utils.ts index 25323b5a0b..c399160bb5 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/normalize-price-ref.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/normalize-price-ref.utils.ts @@ -1,7 +1,5 @@ -export function normalizePriceRef( - p: string | { id: string } | null | undefined, -): string | undefined { - if (!p) return undefined; - - return typeof p === 'string' ? p : p.id; -} +export const normalizePriceRef = ( + priceRef: string | { id: string }, +): string => { + return typeof priceRef === 'string' ? priceRef : priceRef.id; +};