diff --git a/src/Billing/Services/IStripeWebhookHandler.cs b/src/Billing/Services/IStripeWebhookHandler.cs index 753b773402..ac6e0539c0 100644 --- a/src/Billing/Services/IStripeWebhookHandler.cs +++ b/src/Billing/Services/IStripeWebhookHandler.cs @@ -77,3 +77,4 @@ public interface ICouponDeletedHandler : IStripeWebhookHandler; /// Defines the contract for handling Stripe checkout session completed events. /// public interface ICheckoutSessionCompletedHandler : IStripeWebhookHandler; + diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 81dd310d86..a1b775072f 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -93,6 +93,7 @@ public static class StripeConstants public const string OriginatingPlatform = "originatingPlatform"; public const string OriginatingAppVersion = "originatingAppVersion"; public const string TrialInitiationPath = "trialInitiationPath"; + public const string CancelledDuringDeferredPriceIncrease = "cancelled_during_deferred_price_increase"; } public static class PaymentBehavior diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 7872b36644..375025aead 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -261,7 +261,12 @@ public class SubscriberService( SubscriptionCancellationDetailsOptions? cancellationDetails, Dictionary? cancellingUserMetadata) { - var updateOptions = new SubscriptionUpdateOptions(); + var updateOptions = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true, + CancellationDetails = cancellationDetails, + Metadata = cancellingUserMetadata + }; if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) { @@ -269,59 +274,20 @@ public class SubscriberService( if (activeSchedule is { Phases.Count: > 0 }) { - if (activeSchedule.Phases.Count > 2) - { - logger.LogWarning( - "{Service}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1-2), updating to only one phase for cancellation", - GetType().Name, activeSchedule.Id, activeSchedule.Phases.Count); - } - logger.LogInformation( - "{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases", + "{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), releasing schedule before cancellation", GetType().Name, activeSchedule.Id, subscription.Id); - var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - var currentPhase = activeSchedule.Phases.FirstOrDefault(p => p.EndDate > now) - ?? activeSchedule.Phases[^1]; + await stripeAdapter.ReleaseSubscriptionScheduleAsync(activeSchedule.Id); - await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, - new SubscriptionScheduleUpdateOptions - { - EndBehavior = SubscriptionScheduleEndBehavior.Cancel, - Phases = - [ - new SubscriptionSchedulePhaseOptions - { - StartDate = currentPhase.StartDate, - EndDate = currentPhase.EndDate, - Items = currentPhase.Items.Select(i => new SubscriptionSchedulePhaseItemOptions - { - Price = i.PriceId, - Quantity = i.Quantity - }).ToList(), - Discounts = currentPhase.StartDate <= now - ? [] - : currentPhase.Discounts?.Select(d => - new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(), - ProrationBehavior = ProrationBehavior.None, - Metadata = cancellingUserMetadata - } - ] - }); - return; + updateOptions.Metadata = new Dictionary(cancellingUserMetadata ?? []) + { + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true" + }; } } - updateOptions.CancelAtPeriodEnd = true; - - if (cancellationDetails != null) - { - updateOptions.CancellationDetails = cancellationDetails; - updateOptions.Metadata = cancellingUserMetadata; - } - await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions); - } private async Task GetActiveScheduleAsync(Subscription subscription) diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 659ee922cc..5d39e513ed 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -41,55 +41,25 @@ public class ReinstateSubscriptionCommand( if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) { - var activeSchedule = await GetActiveScheduleAsync(subscription); - - // if there is an active schedule, we need to update it to include Phase 2 because it was removed during cancellation - if (activeSchedule is { Phases.Count: > 0 }) + if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true) { - if (activeSchedule.Phases.Count > 1) - { - _logger.LogError( - "{Command}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1 after cancellation), updating to add Phase 2", - CommandName, activeSchedule.Id, activeSchedule.Phases.Count); - return DefaultConflict; - } - _logger.LogInformation( - "{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases", - CommandName, activeSchedule.Id, subscription.Id); + "{Command}: Subscription ({SubscriptionId}) has pending price increase, clearing flag and recreating schedule", + CommandName, subscription.Id); - var phase2 = await priceIncreaseScheduler.ResolvePhase2Async(subscription); - if (phase2 == null) + // Clear pending cancellation and flag BEFORE attaching a schedule. + // Stripe discourages direct subscription updates once a schedule is attached as it can create inconsistencies in phases. + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { - _logger.LogError("Failed to resolve Phase 2 for Subscription {SubscriptionId}", subscription.Id); - return DefaultConflict; - } - var phase1 = activeSchedule.Phases[0]; - - await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, - new SubscriptionScheduleUpdateOptions + CancelAtPeriodEnd = false, + Metadata = new Dictionary { - EndBehavior = SubscriptionScheduleEndBehavior.Release, - Phases = - [ - new SubscriptionSchedulePhaseOptions - { - StartDate = phase1.StartDate, - EndDate = phase1.EndDate, - Items = phase1.Items.Select(i => new SubscriptionSchedulePhaseItemOptions - { - Price = i.PriceId, - Quantity = i.Quantity - }).ToList(), - Discounts = phase1.Discounts?.Select(d => new SubscriptionSchedulePhaseDiscountOptions - { - Coupon = d.CouponId - }).ToList(), - ProrationBehavior = ProrationBehavior.None - }, - phase2 - ] - }); + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "" + } + }); + + await priceIncreaseScheduler.Schedule(subscription); + return new None(); } } @@ -103,14 +73,4 @@ public class ReinstateSubscriptionCommand( return new None(); }); - - private async Task GetActiveScheduleAsync(Subscription subscription) - { - var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync( - new SubscriptionScheduleListOptions { Customer = subscription.CustomerId }); - - return schedules.Data.FirstOrDefault(s => - s.SubscriptionId == subscription.Id && - s.Status == SubscriptionScheduleStatus.Active); - } } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index c8f692ef07..0b57b6811e 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -362,7 +362,7 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_UpdatesScheduleEndBehaviorToCancel( + public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_ReleasesScheduleAndSetsCancelAtPeriodEnd( Organization organization, SutProvider sutProvider) { @@ -413,21 +413,20 @@ public class SubscriberServiceTests await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false); - await stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(scheduleId, - Arg.Is(o => - o.EndBehavior == StripeConstants.SubscriptionScheduleEndBehavior.Cancel && - o.Phases.Count == 1 && - o.Phases[0].Items.Any(i => i.Price == "old-price") && - o.Phases[0].Metadata == null)); + await stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync(scheduleId); + await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, + Arg.Is(o => + o.CancelAtPeriodEnd == true && + o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease))); await stripeAdapter.DidNotReceiveWithAnyArgs() - .CancelSubscriptionAsync(Arg.Any(), Arg.Any()); + .UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()); await stripeAdapter.DidNotReceiveWithAnyArgs() - .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + .CancelSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] - public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_WithSurvey_AlsoUpdatesSubscriptionWithCancellationDetails( + public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_WithSurvey_ReleasesScheduleAndUpdatesCancellationDetails( Organization organization, SutProvider sutProvider) { @@ -486,12 +485,17 @@ public class SubscriberServiceTests await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false, offboardingSurveyResponse); - await stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(scheduleId, - Arg.Is(o => - o.EndBehavior == StripeConstants.SubscriptionScheduleEndBehavior.Cancel && - o.Phases.Count == 1 && - o.Phases[0].Metadata["cancellingUserId"] == userId.ToString())); + await stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync(scheduleId); + await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, + Arg.Is(o => + o.CancelAtPeriodEnd == true && + o.CancellationDetails.Comment == "Too pricey" && + o.CancellationDetails.Feedback == "too_expensive" && + o.Metadata["cancellingUserId"] == userId.ToString() && + o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease))); + await stripeAdapter.DidNotReceiveWithAnyArgs() + .UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()); await stripeAdapter.DidNotReceiveWithAnyArgs() .CancelSubscriptionAsync(Arg.Any(), Arg.Any()); } diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index 4f328a433f..0711de926c 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -1,10 +1,8 @@ -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Entities; using Bit.Core.Services; -using Bit.Core.Test.Billing.Mocks; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; @@ -82,9 +80,6 @@ public class ReinstateSubscriptionCommandTests _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) - .Returns(new StripeList { Data = [] }); - var result = await _command.Run(user); Assert.True(result.IsT0); @@ -95,7 +90,7 @@ public class ReinstateSubscriptionCommandTests } [Fact] - public async Task Run_FlagOn_ScheduleExistsWithZeroPhases_FallsThroughToStandardReinstate() + public async Task Run_FlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -106,460 +101,24 @@ public class ReinstateSubscriptionCommandTests Status = SubscriptionStatus.Active, CancelAt = DateTime.UtcNow.AddDays(30), CustomerId = "cus_1", - Metadata = new Dictionary { ["userId"] = user.Id.ToString() }, + Metadata = new Dictionary + { + ["userId"] = user.Id.ToString(), + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true" + }, Items = new StripeList { Data = [] } }); _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) - .Returns(new StripeList - { - Data = - [ - new SubscriptionSchedule - { - Id = "sched_1", - SubscriptionId = "sub_1", - Status = SubscriptionScheduleStatus.Active, - Phases = [] - } - ] - }); - var result = await _command.Run(user); Assert.True(result.IsT0); - await _stripeAdapter.DidNotReceiveWithAnyArgs() - .UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", - Arg.Is(o => o.CancelAtPeriodEnd == false)); + Arg.Is(o => + o.CancelAtPeriodEnd == false && + o.Metadata[MetadataKeys.CancelledDuringDeferredPriceIncrease] == "")); + await _priceIncreaseScheduler.Received(1).Schedule(Arg.Any()); } - [Fact] - public async Task Run_FlagOn_OnePhaseCancelSchedule_NoMigratingPrice_ReturnsConflict() - { - var user = new User { GatewaySubscriptionId = "sub_1" }; - - _stripeAdapter.GetSubscriptionAsync("sub_1") - .Returns(new Subscription - { - Id = "sub_1", - Status = SubscriptionStatus.Active, - CancelAt = DateTime.UtcNow.AddDays(30), - CustomerId = "cus_1", - Metadata = new Dictionary { ["userId"] = user.Id.ToString() }, - Items = new StripeList - { - Data = [new SubscriptionItem { Price = new Price { Id = "non-migrating-price" }, Quantity = 1 }] - } - }); - - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) - .Returns(new StripeList - { - Data = - [ - new SubscriptionSchedule - { - Id = "sched_1", - SubscriptionId = "sub_1", - Status = SubscriptionScheduleStatus.Active, - Phases = [new SubscriptionSchedulePhase()] - } - ] - }); - - // ResolvePhase2Async returns null (no price migration path found) - _priceIncreaseScheduler.ResolvePhase2Async(Arg.Any()).Returns((SubscriptionSchedulePhaseOptions)null); - - var result = await _command.Run(user); - - Assert.True(result.IsT2); - var conflict = result.AsT2; - Assert.Equal("We had a problem reinstating your subscription. Please contact support for assistance.", conflict.Response); - } - - [Fact] - public async Task Run_FlagOn_OnePhaseCancelSchedule_PremiumMigratingPrice_ReAddsPhase2WithReleaseEndBehavior() - { - var user = new User { GatewaySubscriptionId = "sub_1" }; - var scheduleStartDate = DateTime.UtcNow; - var scheduleEndDate = scheduleStartDate.AddYears(1); - var currentPeriodEnd = scheduleEndDate; - - var subscription = new Subscription - { - Id = "sub_1", - Status = SubscriptionStatus.Active, - CancelAt = DateTime.UtcNow.AddDays(30), - CustomerId = "cus_1", - Metadata = new Dictionary { ["userId"] = user.Id.ToString() }, - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Price = new Price { Id = "premium-old-seat" }, - Quantity = 1, - CurrentPeriodEnd = currentPeriodEnd - } - ] - } - }; - - _stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription); - - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) - .Returns(new StripeList - { - Data = - [ - new SubscriptionSchedule - { - Id = "sched_1", - SubscriptionId = "sub_1", - Status = SubscriptionScheduleStatus.Active, - Phases = - [ - new SubscriptionSchedulePhase - { - StartDate = scheduleStartDate, - EndDate = scheduleEndDate, - Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-old-seat", Quantity = 1 }] - } - ] - } - ] - }); - - var phase2 = new SubscriptionSchedulePhaseOptions - { - StartDate = currentPeriodEnd, - Items = - [ - new SubscriptionSchedulePhaseItemOptions - { - Price = "premium-new-seat", - Quantity = 1 - } - ], - Discounts = [new SubscriptionSchedulePhaseDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }], - ProrationBehavior = ProrationBehavior.None - }; - - _priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2); - - var result = await _command.Run(user); - - Assert.True(result.IsT0); - await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1", - Arg.Is(o => - o.EndBehavior == SubscriptionScheduleEndBehavior.Release && - o.Phases.Count == 2 && - o.Phases[0].Items.Any(i => i.Price == "premium-old-seat") && - o.Phases[1].Items.Any(i => i.Price == "premium-new-seat" && i.Quantity == 1) && - o.Phases[1].Discounts.Any(d => d.Coupon == CouponIDs.Milestone2SubscriptionDiscount))); - await _stripeAdapter.DidNotReceiveWithAnyArgs() - .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Run_FlagOn_OnePhaseCancelSchedule_Families2019MigratingPrice_ReAddsPhase2WithDiscount() - { - var organization = new Bit.Core.AdminConsole.Entities.Organization { GatewaySubscriptionId = "sub_1" }; - var orgId = organization.Id; - var scheduleStartDate = DateTime.UtcNow; - var scheduleEndDate = scheduleStartDate.AddYears(1); - var currentPeriodEnd = scheduleEndDate; - - var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); - var familiesTarget = MockPlans.Get(PlanType.FamiliesAnnually); - - var subscription = new Subscription - { - Id = "sub_1", - Status = SubscriptionStatus.Active, - CancelAt = DateTime.UtcNow.AddDays(30), - CustomerId = "cus_1", - Metadata = new Dictionary { ["organizationId"] = orgId.ToString() }, - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Price = new Price { Id = families2019.PasswordManager.StripePlanId }, - Quantity = 1, - CurrentPeriodEnd = currentPeriodEnd - } - ] - } - }; - - _stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription); - - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) - .Returns(new StripeList - { - Data = - [ - new SubscriptionSchedule - { - Id = "sched_1", - SubscriptionId = "sub_1", - Status = SubscriptionScheduleStatus.Active, - Phases = - [ - new SubscriptionSchedulePhase - { - StartDate = scheduleStartDate, - EndDate = scheduleEndDate, - Items = - [ - new SubscriptionSchedulePhaseItem - { - PriceId = families2019.PasswordManager.StripePlanId, - Quantity = 1 - } - ] - } - ] - } - ] - }); - - var phase2 = new SubscriptionSchedulePhaseOptions - { - StartDate = currentPeriodEnd, - Items = - [ - new SubscriptionSchedulePhaseItemOptions - { - Price = familiesTarget.PasswordManager.StripePlanId, - Quantity = 1 - } - ], - Discounts = [new SubscriptionSchedulePhaseDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }], - ProrationBehavior = ProrationBehavior.None - }; - - _priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2); - - var result = await _command.Run(organization); - - Assert.True(result.IsT0); - await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1", - Arg.Is(o => - o.EndBehavior == SubscriptionScheduleEndBehavior.Release && - o.Phases.Count == 2 && - o.Phases[1].Items.Any(i => i.Price == familiesTarget.PasswordManager.StripePlanId && i.Quantity == 1) && - o.Phases[1].Discounts != null && - o.Phases[1].Discounts.Any(d => d.Coupon == CouponIDs.Milestone3SubscriptionDiscount))); - } - - [Fact] - public async Task Run_FlagOn_OnePhaseCancelSchedule_Families2025MigratingPrice_ReAddsPhase2WithoutDiscount() - { - var organization = new Bit.Core.AdminConsole.Entities.Organization { GatewaySubscriptionId = "sub_1" }; - var orgId = organization.Id; - var scheduleStartDate = DateTime.UtcNow; - var scheduleEndDate = scheduleStartDate.AddYears(1); - var currentPeriodEnd = scheduleEndDate; - - var families2025 = MockPlans.Get(PlanType.FamiliesAnnually2025); - var familiesTarget = MockPlans.Get(PlanType.FamiliesAnnually); - - var subscription = new Subscription - { - Id = "sub_1", - Status = SubscriptionStatus.Active, - CancelAt = DateTime.UtcNow.AddDays(30), - CustomerId = "cus_1", - Metadata = new Dictionary { ["organizationId"] = orgId.ToString() }, - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Price = new Price { Id = families2025.PasswordManager.StripePlanId }, - Quantity = 1, - CurrentPeriodEnd = currentPeriodEnd - } - ] - } - }; - - _stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription); - - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) - .Returns(new StripeList - { - Data = - [ - new SubscriptionSchedule - { - Id = "sched_1", - SubscriptionId = "sub_1", - Status = SubscriptionScheduleStatus.Active, - Phases = - [ - new SubscriptionSchedulePhase - { - StartDate = scheduleStartDate, - EndDate = scheduleEndDate, - Items = - [ - new SubscriptionSchedulePhaseItem - { - PriceId = families2025.PasswordManager.StripePlanId, - Quantity = 1 - } - ] - } - ] - } - ] - }); - - var phase2 = new SubscriptionSchedulePhaseOptions - { - StartDate = currentPeriodEnd, - Items = - [ - new SubscriptionSchedulePhaseItemOptions - { - Price = familiesTarget.PasswordManager.StripePlanId, - Quantity = 1 - } - ], - Discounts = null, - ProrationBehavior = ProrationBehavior.None - }; - - _priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2); - - var result = await _command.Run(organization); - - Assert.True(result.IsT0); - await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1", - Arg.Is(o => - o.EndBehavior == SubscriptionScheduleEndBehavior.Release && - o.Phases.Count == 2 && - o.Phases[1].Items.Any(i => i.Price == familiesTarget.PasswordManager.StripePlanId && i.Quantity == 1) && - o.Phases[1].Discounts == null)); - } - - [Fact] - public async Task Run_FlagOn_MultipleSchedules_SelectsActiveScheduleMatchingSubscription() - { - var user = new User { GatewaySubscriptionId = "sub_1" }; - var scheduleStartDate = DateTime.UtcNow; - var scheduleEndDate = scheduleStartDate.AddYears(1); - var currentPeriodEnd = scheduleEndDate; - - var subscription = new Subscription - { - Id = "sub_1", - Status = SubscriptionStatus.Active, - CancelAt = DateTime.UtcNow.AddDays(30), - CustomerId = "cus_1", - Metadata = new Dictionary { ["userId"] = user.Id.ToString() }, - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Price = new Price { Id = "premium-old-seat" }, - Quantity = 1, - CurrentPeriodEnd = currentPeriodEnd - } - ] - } - }; - - _stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription); - - _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); - - // Return multiple schedules, but only one matches the subscription ID and is active - _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) - .Returns(new StripeList - { - Data = - [ - new SubscriptionSchedule - { - Id = "sched_other_1", - SubscriptionId = "sub_other", - Status = SubscriptionScheduleStatus.Active, - Phases = [new SubscriptionSchedulePhase()] - }, - new SubscriptionSchedule - { - Id = "sched_1", - SubscriptionId = "sub_1", - Status = SubscriptionScheduleStatus.Active, - Phases = - [ - new SubscriptionSchedulePhase - { - StartDate = scheduleStartDate, - EndDate = scheduleEndDate, - Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-old-seat", Quantity = 1 }] - } - ] - }, - new SubscriptionSchedule - { - Id = "sched_completed", - SubscriptionId = "sub_1", - Status = SubscriptionScheduleStatus.Completed, - Phases = [new SubscriptionSchedulePhase()] - } - ] - }); - - var phase2 = new SubscriptionSchedulePhaseOptions - { - StartDate = currentPeriodEnd, - Items = - [ - new SubscriptionSchedulePhaseItemOptions - { - Price = "premium-new-seat", - Quantity = 1 - } - ], - Discounts = [new SubscriptionSchedulePhaseDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }], - ProrationBehavior = ProrationBehavior.None - }; - - _priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2); - - var result = await _command.Run(user); - - Assert.True(result.IsT0); - // Should only update the matching active schedule for sub_1 - await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1", - Arg.Is(o => - o.EndBehavior == SubscriptionScheduleEndBehavior.Release && - o.Phases.Count == 2 && - o.Phases[0].Items.Any(i => i.Price == "premium-old-seat") && - o.Phases[1].Items.Any(i => i.Price == "premium-new-seat" && i.Quantity == 1))); - // Should not update other schedules - await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync("sched_other_1", Arg.Any()); - await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync("sched_completed", Arg.Any()); - } }