From 23b295167ab1b9cc2f096d1f159b806efcdb5ffa Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 15:31:11 -0400 Subject: [PATCH 01/29] feat(billing): introduce unified subscription price increase scheduler API --- src/Core/Billing/Pricing/PriceIncreaseScheduler.cs | 11 +++++++++++ .../Billing/Pricing/PriceIncreaseSchedulerTests.cs | 9 +++++++-- .../Commands/ReinstateSubscriptionCommandTests.cs | 3 ++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index dee000af3bbd..06698894ff0e 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Repositories; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; @@ -45,6 +46,14 @@ public interface IPriceIncreaseScheduler /// True if a new schedule was created; false if skipped. Task ScheduleBusinessPriceIncrease(Subscription subscription, OrganizationPlanMigrationCohort cohort); + /// + /// Creates a deferred price-increase schedule for the given subscription, + /// dispatching to the correct path based on the subscription owner. + /// + /// The Stripe subscription to recover a schedule for. + /// True if a new schedule was created; false if skipped. + Task ScheduleForSubscription(Subscription subscription); + /// /// Releases any active subscription schedule for the given subscription, cancelling a pending /// deferred price increase. Use when the subscription operation makes the scheduled migration @@ -61,7 +70,9 @@ public class PriceIncreaseScheduler( IStripeAdapter stripeAdapter, IFeatureService featureService, IPricingClient pricingClient, + IOrganizationRepository organizationRepository, IOrganizationPlanMigrationCohortAssignmentRepository assignmentRepository, + IOrganizationPlanMigrationCohortRepository cohortRepository, ILogger logger) : IPriceIncreaseScheduler { public async Task SchedulePersonalPriceIncrease(Subscription subscription) diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index 2a030af25142..cff601e0b3ac 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -1,9 +1,11 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Microsoft.Extensions.Logging; @@ -22,12 +24,15 @@ public class PriceIncreaseSchedulerTests private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IFeatureService _featureService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); private readonly IOrganizationPlanMigrationCohortAssignmentRepository _assignmentRepository = Substitute.For(); + private readonly IOrganizationPlanMigrationCohortRepository _cohortRepository = + Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private PriceIncreaseScheduler CreateSut() => - new(_stripeAdapter, _featureService, _pricingClient, _assignmentRepository, _logger); + new(_stripeAdapter, _featureService, _pricingClient, _organizationRepository, _assignmentRepository, _cohortRepository, _logger); [Fact] public async Task SchedulePersonalPriceIncrease_FeatureFlagOff_DoesNothing() diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index 79622de95e0a..24352d54a5e1 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Pricing; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Entities; From af04330db0df7a3d17cd015eb66374eb622d8b1f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 15:31:11 -0400 Subject: [PATCH 02/29] feat(billing): implement unified subscription price increase scheduler logic --- .../Billing/Pricing/PriceIncreaseScheduler.cs | 97 +++++++++++++++++++ .../Pricing/PriceIncreaseSchedulerTests.cs | 5 + 2 files changed, 102 insertions(+) diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 06698894ff0e..635734e60cf7 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -193,6 +193,31 @@ await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, return true; } + public async Task ScheduleForSubscription(Subscription subscription) + { + try + { + SubscriberId subscriberId = subscription; + return await subscriberId.Match( + _ => SchedulePersonalPriceIncrease(subscription), + orgId => DispatchOrganizationScheduleAsync(subscription, orgId.Value), + _ => + { + logger.LogWarning( + "Provider subscriptions do not support schedule recovery ({SubscriptionId})", + subscription.Id); + return Task.FromResult(false); + }); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to resolve subscriber type for subscription ({SubscriptionId}), cannot recover schedule", + subscription.Id); + return false; + } + } + public async Task Release(string customerId, string subscriptionId) { if (!featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) && @@ -581,4 +606,76 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, _ => null }; + /// + /// Dispatches a price increase schedule for a subscription to an organization. + /// + /// The subscription to schedule a price increase for. + /// The ID of the organization associated with the subscription. + /// True if the schedule was dispatched successfully, false otherwise. + private async Task DispatchOrganizationScheduleAsync(Subscription subscription, Guid organizationId) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization is null) + { + logger.LogError( + "Organization ({OrganizationId}) not found; cannot recover schedule for subscription ({SubscriptionId})", + organizationId, subscription.Id); + return false; + } + + if (!IsTrackABusinessPlanType(organization.PlanType)) + { + return await SchedulePersonalPriceIncrease(subscription); + } + + var assignment = await assignmentRepository.GetByOrganizationIdAsync(organizationId); + if (assignment is null) + { + return false; + } + + var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); + if (cohort is null || !cohort.IsActive) + { + return false; + } + + if (cohort.MigrationPathId is null) + { + // Churn-only cohort — no migration to schedule. + return false; + } + + var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value); + if (migrationPath is null) + { + logger.LogError( + "Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}); skipping schedule recovery for subscription ({SubscriptionId})", + cohort.MigrationPathId, cohort.Id, subscription.Id); + return false; + } + + if (organization.PlanType != migrationPath.FromPlan) + { + logger.LogWarning( + "Skipping schedule recovery for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}", + organizationId, organization.PlanType, cohort.Name, migrationPath.FromPlan); + return false; + } + + return await ScheduleBusinessPriceIncrease(subscription, cohort); + } + + /// + /// Returns true if the plan type is a Track A business plan type. + /// + /// The plan type to check. + /// True if the plan type is a Track A business plan type, otherwise false. + /// This method should be expanded to include other track business plan types as needed. + private static bool IsTrackABusinessPlanType(PlanType planType) => planType is + PlanType.TeamsMonthly2020 or + PlanType.TeamsAnnually2020 or + PlanType.EnterpriseMonthly2020 or + PlanType.EnterpriseAnnually2020; + } diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index cff601e0b3ac..ae75838679b8 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -1522,4 +1522,9 @@ private static OrganizationPlanMigrationCohort CreateCohort( ProactiveDiscountCouponCode = proactiveCoupon, IsActive = true }; + + private static Organization CreateOrganization(Guid id, PlanType planType) => + new() { Id = id, PlanType = planType }; + + } From 18f84c779e7a8a1ac0253017650d047835681f43 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 15:31:11 -0400 Subject: [PATCH 03/29] refactor(billing): update subscription handlers to use unified scheduler --- .../Services/Implementations/SubscriptionUpdatedHandler.cs | 3 +-- .../Subscriptions/Commands/ReinstateSubscriptionCommand.cs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index ec4cb9d63d03..3c8db511650d 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -338,8 +338,6 @@ private async Task SetSubscriptionToCancelAsync(Subscription subscription) private async Task RemovePendingCancellationAsync(Subscription subscription) { - await _priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); - await _stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false, @@ -352,6 +350,7 @@ private async Task RemovePendingCancellationAsync(Subscription subscription) [MetadataKeys.CancellationOrigin] = string.Empty } }); + await _priceIncreaseScheduler.ScheduleForSubscription(subscription); } /// diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 6f0ea2789687..681c2fadf149 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -56,11 +56,11 @@ public Task> Run(ISubscriber subscriber) => HandleAsy CancelAtPeriodEnd = false, Metadata = new Dictionary { - [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "" + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = string.Empty } }); - await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription); + await priceIncreaseScheduler.ScheduleForSubscription(subscription); return new None(); } From 67eede5d217af302680ff819a88d4adea8afb2bf Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 15:31:11 -0400 Subject: [PATCH 04/29] feat(billing): extend price migration feature flag checks --- .../Subscriptions/Commands/ReinstateSubscriptionCommand.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 681c2fadf149..18627465d5ab 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -41,7 +41,8 @@ public Task> Run(ISubscriber subscriber) => HandleAsy return new BadRequest("Subscription is not pending cancellation."); } - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true) { From 2fdfacbabca1674421e1a4ccc850dd3c88ee97f4 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 15:31:11 -0400 Subject: [PATCH 05/29] test(billing): add and update tests for unified price increase scheduler --- .../SubscriptionUpdatedHandlerTests.cs | 2 +- .../Pricing/PriceIncreaseSchedulerTests.cs | 227 ++++++++++++++++++ .../ReinstateSubscriptionCommandTests.cs | 48 +++- 3 files changed, 269 insertions(+), 8 deletions(-) diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index f65fc5c091bf..90351ed759af 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1436,7 +1436,7 @@ public async Task HandleAsync_ActiveSubscription_SchedulesBeforeRemovingCancella await _sut.HandleAsync(parsedEvent); // Assert - await _priceIncreaseScheduler.Received(1).SchedulePersonalPriceIncrease(subscription); + await _priceIncreaseScheduler.Received(1).ScheduleForSubscription(subscription); await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, Arg.Is(o => o.CancelAtPeriodEnd == false)); } diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index ae75838679b8..5276dafd12fb 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -1442,6 +1442,233 @@ await _assignmentRepository.DidNotReceiveWithAnyArgs() .GetByOrganizationIdAsync(Arg.Any()); } + [Fact] + public async Task ScheduleForSubscription_UserSubscription_RoutesToPersonalPath_CreatesSchedule() + { + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + + var oldPremium = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + Seat = new Purchasable { StripePriceId = "premium-old-seat", Price = 10, Provided = 1 }, + Storage = new Purchasable { StripePriceId = "premium-old-storage", Price = 4, Provided = 1 } + }; + + var newPremium = new PremiumPlan + { + Name = "Premium", + Available = true, + Seat = new Purchasable { StripePriceId = "premium-new-seat", Price = 15, Provided = 1 }, + Storage = new Purchasable { StripePriceId = "premium-new-storage", Price = 4, Provided = 1 } + }; + + _pricingClient.ListPremiumPlans().Returns([oldPremium, newPremium]); + + var subscription = CreateSubscription("sub_1", "cus_1", + CreateSubscriptionItem("premium-old-seat", 1)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.True(result); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items.Any(i => i.Price == "premium-new-seat"))); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_ActiveCohortMatchingPlan_RoutesToBusinessPath_CreatesSchedule() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.PasswordManager.StripeSeatPlanId, 10)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.True(result); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items.Any(i => i.Price == target.PasswordManager.StripeSeatPlanId))); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_NoAssignment_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns((OrganizationPlanMigrationCohortAssignment?)null); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_InactiveCohort_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + var cohortId = Guid.NewGuid(); + var inactiveCohort = new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = "inactive", + MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent, + IsActive = false + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId) + .Returns(new OrganizationPlanMigrationCohortAssignment { OrganizationId = orgId, CohortId = cohortId }); + _cohortRepository.GetByIdAsync(cohortId).Returns(inactiveCohort); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_TrackAOrg_PlanTypeDrifted_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + + // Org PlanType is EnterpriseAnnually (already migrated), not EnterpriseAnnually2020 + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually)); + _assignmentRepository.GetByOrganizationIdAsync(orgId) + .Returns(new OrganizationPlanMigrationCohortAssignment { OrganizationId = orgId, CohortId = cohort.Id }); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_NonTrackAOrg_FamiliesOrg_RoutesToPersonalPath() + { + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + + var orgId = Guid.NewGuid(); + var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019); + var familiesTarget = MockPlans.Get(PlanType.FamiliesAnnually); + var families2025 = MockPlans.Get(PlanType.FamiliesAnnually2025); + + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesTarget); + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.FamiliesAnnually2019)); + + var subscription = CreateSubscription("sub_1", "cus_1", + new Dictionary { { "organizationId", orgId.ToString() } }, + CreateSubscriptionItem(families2019.PasswordManager.StripePlanId, 1)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.True(result); + await _stripeAdapter.Received(1).CreateSubscriptionScheduleAsync(Arg.Any()); + await _cohortRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _assignmentRepository.DidNotReceiveWithAnyArgs().GetByOrganizationIdAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_ProviderSubscription_ReturnsFalse() + { + var providerId = Guid.NewGuid(); + var subscription = CreateSubscription("sub_1", "cus_1", + new Dictionary { { "providerId", providerId.ToString() } }); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + private static Subscription CreateSubscription(string id, string customerId, params SubscriptionItem[] items) => CreateSubscription(id, customerId, new Dictionary { { "userId", Guid.NewGuid().ToString() } }, items); diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index 24352d54a5e1..a35ecd0a3d63 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -40,7 +40,7 @@ public async Task Run_SubscriptionNotPendingCancellation_ReturnsBadRequest() } [Fact] - public async Task Run_FlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() + public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -56,7 +56,7 @@ public async Task Run_FlagOff_FallsThroughToStandardReinstate_NoScheduleCheck() var result = await _command.Run(user); - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.DidNotReceiveWithAnyArgs() .ListSubscriptionSchedulesAsync(Arg.Any()); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", @@ -64,7 +64,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_FlagOn_NoSchedule_FallsThroughToStandardReinstate() + public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_FallsThroughToStandardReinstate() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -83,7 +83,7 @@ public async Task Run_FlagOn_NoSchedule_FallsThroughToStandardReinstate() var result = await _command.Run(user); - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.DidNotReceiveWithAnyArgs() .UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", @@ -91,7 +91,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_FlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + public async Task Run_PM32645_DeferPriceMigrationToRenewalFlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() { var user = new User { GatewaySubscriptionId = "sub_1" }; @@ -114,12 +114,46 @@ public async Task Run_FlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_Rec var result = await _command.Run(user); - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", Arg.Is(o => o.CancelAtPeriodEnd == false && o.Metadata[MetadataKeys.CancelledDuringDeferredPriceIncrease] == "")); - await _priceIncreaseScheduler.Received(1).SchedulePersonalPriceIncrease(Arg.Any()); + await _priceIncreaseScheduler.Received(1).ScheduleForSubscription(Arg.Any()); + } + + [Fact] + public async Task Run_BusinessPlanPriceMigrationFlagOn_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag() + { + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_1" }; + + _stripeAdapter.GetSubscriptionAsync("sub_1", Arg.Any()) + .Returns(new Subscription + { + Id = "sub_1", + Status = SubscriptionStatus.Active, + CancelAt = DateTime.UtcNow.AddDays(30), + CustomerId = "cus_1", + Metadata = new Dictionary + { + ["organizationId"] = organizationId.ToString(), + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true" + }, + Items = new StripeList { Data = [] } + }); + + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var result = await _command.Run(organization); + + Assert.True(result.Success); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", + Arg.Is(o => + o.CancelAtPeriodEnd == false && + o.Metadata[MetadataKeys.CancelledDuringDeferredPriceIncrease] == "")); + await _priceIncreaseScheduler.Received(1).ScheduleForSubscription(Arg.Any()); } [Fact] From ff70af003376ceb99ab28bb616536875f805c004 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 16:08:06 -0400 Subject: [PATCH 06/29] fix(billing): run dotnet format --- src/Core/Billing/Pricing/PriceIncreaseScheduler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 635734e60cf7..ff3a09f8404c 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,11 +1,11 @@ using Bit.Core.Billing.Enums; -using Bit.Core.Repositories; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; From b1bb09916061e1991d4fb1291c55140edb2db6e1 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 19:10:55 -0400 Subject: [PATCH 07/29] feat(billing): expand customer and customer.discount on subscription fetch --- .../Subscriptions/Commands/ReinstateSubscriptionCommand.cs | 2 +- .../Commands/ReinstateSubscriptionCommandTests.cs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 18627465d5ab..c15c74819d36 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -30,7 +30,7 @@ public Task> Run(ISubscriber subscriber) => HandleAsy { var subscription = await stripeAdapter.GetSubscriptionAsync( subscriber.GatewaySubscriptionId, - new SubscriptionGetOptions { Expand = ["discounts"] }); + new SubscriptionGetOptions { Expand = ["discounts", "customer", "customer.discount"] }); if (subscription is not { diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index a35ecd0a3d63..b10a872d0351 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -168,6 +168,10 @@ public async Task Run_FetchesSubscriptionWithDiscountsExpanded() await _stripeAdapter.Received(1).GetSubscriptionAsync( "sub_1", - Arg.Is(o => o.Expand != null && o.Expand.Contains("discounts"))); + Arg.Is(o => + o.Expand != null && + o.Expand.Contains("discounts") && + o.Expand.Contains("customer") && + o.Expand.Contains("customer.discount"))); } } From c4d5f305c6cb7e33f51ff0f512003ff0e217c91f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 19:10:55 -0400 Subject: [PATCH 08/29] refactor(ReinstateSubscriptionCommandTests): rename test method for broader scope --- .../Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index b10a872d0351..fce8c74e24be 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -157,7 +157,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1", } [Fact] - public async Task Run_FetchesSubscriptionWithDiscountsExpanded() + public async Task Run_FetchesSubscriptionWithRequiredExpansions() { var user = new User { GatewaySubscriptionId = "sub_1" }; From 18ecb4d2b42d64826fa3385bc51eccb68e4b7708 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 19:22:11 -0400 Subject: [PATCH 09/29] feat(billing): expand customer.discount in update handler --- .../Services/Implementations/SubscriptionUpdatedHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 3c8db511650d..5cb6a2d8ce16 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -83,7 +83,7 @@ public SubscriptionUpdatedHandler( public async Task HandleAsync(Event parsedEvent) { - var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); + var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "customer.discount", "discounts", "latest_invoice", "test_clock"]); SubscriberId subscriberId = subscription; var subscriber = await GetSubscriberAsync(subscriberId); From 365f5799edd08440c6da6c4e74c54f9275c0b868 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 20 May 2026 19:30:35 -0400 Subject: [PATCH 10/29] test(billing): update test name --- test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 90351ed759af..d70f9772d2f7 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1401,7 +1401,7 @@ public async Task HandleAsync_UnpaidSubscription_ReleasesScheduleBeforeCancellat } [Fact] - public async Task HandleAsync_ActiveSubscription_SchedulesBeforeRemovingCancellation() + public async Task HandleAsync_ActiveSubscription_RemovesCancellationAndAddsSchedules() { // Arrange var organizationId = Guid.NewGuid(); From 36bd4d2bd2b6ef7a0daf7e2d7b980c05637a565d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 13:56:01 -0400 Subject: [PATCH 11/29] feat(billing): add test clock waiting mechanism for upcoming invoices --- .../Implementations/UpcomingInvoiceHandler.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 8bc5a07ce366..4c5a7c2380ef 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -22,6 +22,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; +using Stripe.TestHelpers; using Event = Stripe.Event; using Plan = Bit.Core.Models.StaticStore.Plan; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; @@ -392,6 +393,11 @@ private async Task ScheduleBusinessPlanPriceMigrationAsync( return false; } + if (subscription.TestClock != null) + { + await WaitForTestClockToAdvanceAsync(subscription.TestClock); + } + var scheduled = await priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort); if (!scheduled) { @@ -428,6 +434,19 @@ private Task SendBusinessRenewalEmailAsync( return Task.CompletedTask; } + private async Task WaitForTestClockToAdvanceAsync(TestClock testClock) + { + while (testClock.Status != "ready") + { + await Task.Delay(TimeSpan.FromSeconds(2)); + testClock = await stripeAdapter.GetTestClockAsync(testClock.Id); + if (testClock.Status == "internal_failure") + { + throw new Exception("Stripe Test Clock encountered an internal failure"); + } + } + } + #endregion #region Premium Users From 2cbbac10c237aa81e21f07e6b0158ecd17b1b029 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 13:56:01 -0400 Subject: [PATCH 12/29] feat(billing): introduce cancelling user ID metadata key --- src/Core/Billing/Constants/StripeConstants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index a75251f3fe30..8ee64c03e213 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -99,6 +99,7 @@ public static class MetadataKeys public const string CancelledDuringDeferredPriceIncrease = "cancelled_during_deferred_price_increase"; public const string MigrationCohortId = "migration_cohort_id"; public const string MigrationCohortName = "migration_cohort_name"; + public const string CancellingUserId = "cancellingUserId"; } public static class CancellationOrigins From 1582b864cd3421364d970264d349dcd467a3b09f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 13:56:01 -0400 Subject: [PATCH 13/29] feat(billing): store cancelling user ID on subscription cancellation --- src/Core/Billing/Services/Implementations/SubscriberService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index c0c90988c3e5..043e1dceab57 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -80,7 +80,7 @@ public async Task CancelSubscription( var cancellingUserMetadata = offboardingSurveyResponse != null ? new Dictionary { - { "cancellingUserId", offboardingSurveyResponse.UserId.ToString() } + { MetadataKeys.CancellingUserId, offboardingSurveyResponse.UserId.ToString() } } : null; From 157ead75bcef68f58a6cc42be985b5dcb8be8d87 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 13:56:01 -0400 Subject: [PATCH 14/29] feat(billing): clear cancelling user ID on subscription reinstatement --- .../Commands/ReinstateSubscriptionCommand.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index c15c74819d36..23ba0ccc25d6 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -50,14 +50,15 @@ public Task> Run(ISubscriber subscriber) => HandleAsy "{Command}: Subscription ({SubscriptionId}) has pending price increase, clearing flag and recreating schedule", CommandName, subscription.Id); - // Clear pending cancellation and flag BEFORE attaching a schedule. + // Clear pending cancellation, cancelling user, 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 { CancelAtPeriodEnd = false, Metadata = new Dictionary { - [MetadataKeys.CancelledDuringDeferredPriceIncrease] = string.Empty + [MetadataKeys.CancelledDuringDeferredPriceIncrease] = string.Empty, + [MetadataKeys.CancellingUserId] = string.Empty } }); @@ -71,7 +72,11 @@ public Task> Run(ISubscriber subscriber) => HandleAsy // active schedules is to simply not cancel at the end of the period. await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { - CancelAtPeriodEnd = false + CancelAtPeriodEnd = false, + Metadata = new Dictionary + { + [MetadataKeys.CancellingUserId] = string.Empty + } }); return new None(); From 36b3453a7764a75b224bbe078e45455d4ec06f2f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 13:56:01 -0400 Subject: [PATCH 15/29] test(billing): update subscriber service tests for cancelling user ID --- test/Core.Test/Billing/Services/SubscriberServiceTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index b04a7dec4988..02139c517100 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -93,7 +93,7 @@ public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_Upd await stripeAdapter .Received(1) .UpdateSubscriptionAsync(subscriptionId, Arg.Is( - options => options.Metadata["cancellingUserId"] == userId.ToString())); + options => options.Metadata[StripeConstants.MetadataKeys.CancellingUserId] == userId.ToString())); await stripeAdapter .Received(1) @@ -188,7 +188,7 @@ await stripeAdapter options.CancelAtPeriodEnd == true && options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason && - options.Metadata["cancellingUserId"] == userId.ToString())); + options.Metadata[StripeConstants.MetadataKeys.CancellingUserId] == userId.ToString())); await stripeAdapter .DidNotReceiveWithAnyArgs() @@ -491,7 +491,7 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, o.CancelAtPeriodEnd == true && o.CancellationDetails.Comment == "Too pricey" && o.CancellationDetails.Feedback == "too_expensive" && - o.Metadata["cancellingUserId"] == userId.ToString() && + o.Metadata[StripeConstants.MetadataKeys.CancellingUserId] == userId.ToString() && o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease))); await stripeAdapter.DidNotReceiveWithAnyArgs() From 68ffb1db471cb2e3e63b1ce84936eb2cd503f147 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 17:24:14 -0400 Subject: [PATCH 16/29] style(SubscriberService): use 'is not null' pattern matching --- src/Core/Billing/Services/Implementations/SubscriberService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 043e1dceab57..3234fc973611 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -67,7 +67,7 @@ public async Task CancelSubscription( ]; // Build once from survey — null when survey is absent (system-initiated cancellation) - var cancellationDetails = offboardingSurveyResponse != null + var cancellationDetails = offboardingSurveyResponse is not null ? new SubscriptionCancellationDetailsOptions { Comment = offboardingSurveyResponse.Feedback, From 59ec48080f925ee2df0d83ed8a6356bfdaa7fda4 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 17:24:14 -0400 Subject: [PATCH 17/29] feat(SubscriberService): add PM35215 migration cohort metadata handling --- .../Implementations/SubscriberService.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 3234fc973611..cffed271c86d 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -77,21 +77,25 @@ public async Task CancelSubscription( } : null; - var cancellingUserMetadata = offboardingSurveyResponse != null + var cancellingUserMetadata = offboardingSurveyResponse is not null ? new Dictionary { { MetadataKeys.CancellingUserId, offboardingSurveyResponse.UserId.ToString() } } : null; - if (cancelImmediately) + if (featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { - await CancelSubscriptionImmediatelyAsync(subscription, cancellationDetails, cancellingUserMetadata); - } - else - { - await CancelSubscriptionAtPeriodEndAsync(subscription, cancellationDetails, cancellingUserMetadata); + cancellingUserMetadata = new Dictionary(cancellingUserMetadata ?? []) + { + [MetadataKeys.MigrationCohortId] = string.Empty, + [MetadataKeys.MigrationCohortName] = string.Empty, + }; } + + await (cancelImmediately + ? CancelSubscriptionImmediatelyAsync(subscription, cancellationDetails, cancellingUserMetadata) + : CancelSubscriptionAtPeriodEndAsync(subscription, cancellationDetails, cancellingUserMetadata)); } public async Task CreateBraintreeCustomer( From cb962e2371a82db36ebed7bfa155c248e6ba2389 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 17:24:14 -0400 Subject: [PATCH 18/29] feat(SubscriberService): extend price migration deferral to PM35215 --- .../Billing/Services/Implementations/SubscriberService.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index cffed271c86d..a53b9fe61fe6 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -237,7 +237,8 @@ private async Task CancelSubscriptionImmediatelyAsync( SubscriptionCancellationDetailsOptions? cancellationDetails, Dictionary? cancellingUserMetadata) { - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { var activeSchedule = await GetActiveScheduleAsync(subscription); if (activeSchedule != null) @@ -272,7 +273,8 @@ private async Task CancelSubscriptionAtPeriodEndAsync( Metadata = cancellingUserMetadata }; - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) || + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) { var activeSchedule = await GetActiveScheduleAsync(subscription); From bcf9fe824d6f4a5240a287f8b839f686cad49720 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 21 May 2026 17:24:14 -0400 Subject: [PATCH 19/29] test(SubscriberService): add and update tests for PM35215 feature --- .../Services/SubscriberServiceTests.cs | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 02139c517100..4d8029d0321c 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -304,7 +304,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task CancelSubscription_CancelImmediately_FlagOff_DoesNotCheckOrReleaseSchedule( + public async Task CancelSubscription_CancelImmediately_BothFlagsOff_DoesNotCheckOrReleaseSchedule( Organization organization, SutProvider sutProvider) { @@ -322,6 +322,7 @@ public async Task CancelSubscription_CancelImmediately_FlagOff_DoesNotCheckOrRel var featureService = sutProvider.GetDependency(); featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); @@ -332,6 +333,92 @@ await sutProvider.GetDependency() await stripeAdapter.Received(1).CancelSubscriptionAsync(subscriptionId, Arg.Any()); } + [Theory, BitAutoData] + public async Task CancelSubscription_CancelImmediately_PM35215FlagOn_WithActiveSchedule_ReleasesSchedule( + Organization organization, + SutProvider sutProvider) + { + const string subscriptionId = "sub_1"; + const string scheduleId = "sched_1"; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = "active", + CustomerId = "cus_1", + Metadata = new Dictionary() + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList + { + Data = + [ + new SubscriptionSchedule + { + Id = scheduleId, + SubscriptionId = subscriptionId, + Status = StripeConstants.SubscriptionScheduleStatus.Active + } + ] + }); + + var featureService = sutProvider.GetDependency(); + featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: true); + + await sutProvider.GetDependency() + .Received(1).Release("cus_1", subscriptionId); + await stripeAdapter.Received(1).CancelSubscriptionAsync(subscriptionId, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_PM35215FlagOn_ClearsMigrationCohortMetadataOnCancel( + Organization organization, + SutProvider sutProvider) + { + const string subscriptionId = "sub_1"; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = "active", + CustomerId = "cus_1", + Metadata = new Dictionary + { + { "organizationId", organization.Id.ToString() }, + { StripeConstants.MetadataKeys.MigrationCohortId, "some-cohort-id" }, + { StripeConstants.MetadataKeys.MigrationCohortName, "A1(a)" } + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()).Returns(subscription); + stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var featureService = sutProvider.GetDependency(); + featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = Guid.NewGuid(), + Reason = "too_expensive", + Feedback = "Too expensive" + }; + + await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false, offboardingSurveyResponse); + + await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, + Arg.Is(o => + o.Metadata[StripeConstants.MetadataKeys.MigrationCohortId] == string.Empty && + o.Metadata[StripeConstants.MetadataKeys.MigrationCohortName] == string.Empty)); + } + [Theory, BitAutoData] public async Task CancelSubscription_CancelImmediately_FlagOn_NoSchedule_ProceedsNormally( Organization organization, From 3baa358c0b35343c7627d97959888fcb21be2db1 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:36:51 -0400 Subject: [PATCH 20/29] feat(billing): Introduce OrganizationPriceIncreaseOptions --- .../Pricing/OrganizationPriceIncreaseOptions.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs diff --git a/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs b/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs new file mode 100644 index 000000000000..f2672f553cf4 --- /dev/null +++ b/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.Billing.Pricing; + +/// +/// Controls optional guard behavior when scheduling an organization price increase via +/// . Guards are applied +/// during cohort validation before any Stripe calls are made. +/// +public record OrganizationPriceIncreaseOptions +{ + /// + /// Skip scheduling if a price increase has already been scheduled for this + /// organization (i.e. assignment.ScheduledDate is set). + /// + public bool SkipIfAlreadyScheduled { get; init; } +} From 66a3e8eb9a1ca7d170c3261f90bccf502536d099 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:36:51 -0400 Subject: [PATCH 21/29] refactor(billing): Centralize price increase eligibility in scheduler --- .../Billing/Pricing/PriceIncreaseScheduler.cs | 77 +++++++++++++------ 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index ff3a09f8404c..a58f063f1353 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; @@ -50,9 +51,14 @@ public interface IPriceIncreaseScheduler /// Creates a deferred price-increase schedule for the given subscription, /// dispatching to the correct path based on the subscription owner. /// - /// The Stripe subscription to recover a schedule for. + /// The Stripe subscription to schedule a price increase for. + /// + /// Optional guards applied before scheduling. + /// /// True if a new schedule was created; false if skipped. - Task ScheduleForSubscription(Subscription subscription); + Task ScheduleForSubscription( + Subscription subscription, + OrganizationPriceIncreaseOptions? options = null); /// /// Releases any active subscription schedule for the given subscription, cancelling a pending @@ -193,14 +199,16 @@ await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, return true; } - public async Task ScheduleForSubscription(Subscription subscription) + public async Task ScheduleForSubscription( + Subscription subscription, + OrganizationPriceIncreaseOptions? options = null) { try { SubscriberId subscriberId = subscription; return await subscriberId.Match( _ => SchedulePersonalPriceIncrease(subscription), - orgId => DispatchOrganizationScheduleAsync(subscription, orgId.Value), + orgId => ScheduleForOrganizationAsync(subscription, orgId.Value, options), _ => { logger.LogWarning( @@ -607,12 +615,14 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, }; /// - /// Dispatches a price increase schedule for a subscription to an organization. + /// Coordinates the full organization scheduling flow. Resolves the organization, routes + /// non-business plan types (personal, family, and 2019-era plans) to the personal scheduling + /// path, then validates cohort eligibility before scheduling. /// - /// The subscription to schedule a price increase for. - /// The ID of the organization associated with the subscription. - /// True if the schedule was dispatched successfully, false otherwise. - private async Task DispatchOrganizationScheduleAsync(Subscription subscription, Guid organizationId) + private async Task ScheduleForOrganizationAsync( + Subscription subscription, + Guid organizationId, + OrganizationPriceIncreaseOptions? options) { var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization is null) @@ -628,21 +638,46 @@ private async Task DispatchOrganizationScheduleAsync(Subscription subscrip return await SchedulePersonalPriceIncrease(subscription); } - var assignment = await assignmentRepository.GetByOrganizationIdAsync(organizationId); + var assignment = await assignmentRepository.GetByOrganizationIdAsync(organization.Id); if (assignment is null) { return false; } var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); - if (cohort is null || !cohort.IsActive) + if (cohort is null) { return false; } - if (cohort.MigrationPathId is null) + if (!IsEligibleForScheduling(organization, assignment, cohort, options)) + { + return false; + } + + return await ScheduleBusinessPriceIncrease(subscription, cohort); + } + + /// + /// Returns true if the organization is eligible for a business plan price increase. + /// Guards from are evaluated first against all resolved data; + /// structural eligibility checks follow. Add new option guards here — + /// never needs to change. + /// Does not make any database or Stripe calls. + /// + private bool IsEligibleForScheduling( + Organization organization, + OrganizationPlanMigrationCohortAssignment assignment, + OrganizationPlanMigrationCohort cohort, + OrganizationPriceIncreaseOptions? options) + { + if (options?.SkipIfAlreadyScheduled == true && assignment.ScheduledDate is not null) + { + return false; + } + + if (!cohort.IsActive || cohort.MigrationPathId is null) { - // Churn-only cohort — no migration to schedule. return false; } @@ -650,28 +685,26 @@ private async Task DispatchOrganizationScheduleAsync(Subscription subscrip if (migrationPath is null) { logger.LogError( - "Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}); skipping schedule recovery for subscription ({SubscriptionId})", - cohort.MigrationPathId, cohort.Id, subscription.Id); + "Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}); skipping schedule for organization ({OrganizationId})", + cohort.MigrationPathId, cohort.Id, organization.Id); return false; } if (organization.PlanType != migrationPath.FromPlan) { logger.LogWarning( - "Skipping schedule recovery for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}", - organizationId, organization.PlanType, cohort.Name, migrationPath.FromPlan); + "Skipping schedule for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}", + organization.Id, organization.PlanType, cohort.Name, migrationPath.FromPlan); return false; } - return await ScheduleBusinessPriceIncrease(subscription, cohort); + return true; } /// /// Returns true if the plan type is a Track A business plan type. /// - /// The plan type to check. - /// True if the plan type is a Track A business plan type, otherwise false. - /// This method should be expanded to include other track business plan types as needed. + /// Expand to include additional business plan types as new tracks are added. private static bool IsTrackABusinessPlanType(PlanType planType) => planType is PlanType.TeamsMonthly2020 or PlanType.TeamsAnnually2020 or From 59d3c27190e839a434450ffad510795c7bcae4e5 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:36:51 -0400 Subject: [PATCH 22/29] refactor(billing): Delegate price increase validation from UpcomingInvoiceHandler --- .../Implementations/UpcomingInvoiceHandler.cs | 40 ++++--------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 4c5a7c2380ef..c985fb16b1d8 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -357,53 +357,29 @@ private async Task ScheduleBusinessPlanPriceMigrationAsync( } var assignment = await assignmentRepository.GetByOrganizationIdAsync(organization.Id); - if (assignment is null || assignment.ScheduledDate is not null) { return false; } - var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); - - if (cohort is null || !cohort.IsActive) - { - return false; - } - - if (cohort.MigrationPathId is null) - { - // Churn-only cohort — no migration to schedule. - return false; - } - - var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value); - if (migrationPath is null) - { - logger.LogError( - "Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}) for Organization ({OrganizationId})", - cohort.MigrationPathId, cohort.Id, organization.Id); - return false; - } - - if (organization.PlanType != migrationPath.FromPlan) - { - logger.LogWarning( - "Skipping business price migration for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}", - organization.Id, organization.PlanType, cohort.Name, migrationPath.FromPlan); - return false; - } - if (subscription.TestClock != null) { await WaitForTestClockToAdvanceAsync(subscription.TestClock); } - var scheduled = await priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort); + var scheduled = await priceIncreaseScheduler.ScheduleForSubscription(subscription); + if (!scheduled) { return true; } + var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); + if (cohort?.MigrationPathId is null) return true; + + var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value); + if (migrationPath is null) return true; + var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan); var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan); From 8cdee4c76eea39edeb671510471ebf2c22f4a99b Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:36:51 -0400 Subject: [PATCH 23/29] feat(billing): Manage price increase schedules during subscription lifecycle events --- .../Billing/Services/Implementations/SubscriberService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index a53b9fe61fe6..157bdf968035 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -584,6 +584,8 @@ subscription.Metadata is null || } }); + await priceIncreaseScheduler.ScheduleForSubscription(subscription); + logger.LogInformation( "Cleared pending unpaid-lifecycle cancellation for subscription ({SubscriptionId}) after subscriber re-enable", subscription.Id); @@ -608,6 +610,8 @@ public async Task ScheduleUnpaidCancellationAsync(ISubscriber subscriber) var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + await priceIncreaseScheduler.Release(subscription.CustomerId, subscription.Id); + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { CancelAt = now.AddDays(7), From c9eba37407ad87af10a7ecf8be9c7e028774329b Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:36:51 -0400 Subject: [PATCH 24/29] test(billing): Update UpcomingInvoiceHandlerTests for centralized validation --- .../Services/UpcomingInvoiceHandlerTests.cs | 72 ++++++++----------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 09dc9fa09ddb..f085c04d7a67 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -3525,15 +3525,15 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortInactive_FallsThroughToS // Act await _sut.HandleAsync(parsedEvent); - // Assert - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + // Assert — cohort validation now handled internally by scheduler; handler falls through silently + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3579,7 +3579,7 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortHasNoMigrationPath_Falls // Act await _sut.HandleAsync(parsedEvent); - // Assert — silent fall-through: no migration logs at any severity. + // Assert — churn-only cohort validation handled internally by scheduler; no migration logs at handler level. _logger.DidNotReceive().Log( LogLevel.Warning, Arg.Any(), @@ -3592,14 +3592,14 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortHasNoMigrationPath_Falls Arg.Is(o => o.ToString()!.Contains("MigrationPathId")), Arg.Any(), Arg.Any>()); - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3645,23 +3645,15 @@ public async Task HandleAsync_WhenBusinessTier_AndUnknownMigrationPathId_LogsErr // Act await _sut.HandleAsync(parsedEvent); - // Assert - _logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => - o.ToString()!.Contains("Unknown MigrationPathId") && - o.ToString()!.Contains(_organizationId.ToString())), - Arg.Any(), - Arg.Any>()); - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + // Assert — unknown migration path is logged and handled internally by scheduler; handler falls through silently + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3707,23 +3699,15 @@ public async Task HandleAsync_WhenBusinessTier_AndOrgPlanDriftedFromCohortSource // Act await _sut.HandleAsync(parsedEvent); - // Assert - _logger.Received(1).Log( - LogLevel.Warning, - Arg.Any(), - Arg.Is(o => - o.ToString()!.Contains("Skipping business price migration") && - o.ToString()!.Contains(_organizationId.ToString())), - Arg.Any(), - Arg.Any>()); - await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs() - .ScheduleBusinessPriceIncrease(default!, default!); - await _mailService.Received(1).SendInvoiceUpcoming( - Arg.Is>(emails => emails.Contains("org@example.com")), + // Assert — plan drift logged and handled internally by scheduler; handler falls through silently + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any>(), - Arg.Is(b => b)); + Arg.Any()); } [Fact] @@ -3767,13 +3751,15 @@ public async Task HandleAsync_WhenBusinessTier_AndSchedulerReturnsTrue_InvokesPl _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); _assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment); _cohortRepository.GetByIdAsync(cohortId).Returns(cohort); - _priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort).Returns(true); + _priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any()) + .Returns(true); // Act await _sut.HandleAsync(parsedEvent); // Assert - await _priceIncreaseScheduler.Received(1).ScheduleBusinessPriceIncrease(subscription, cohort); + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); _logger.Received(1).Log( LogLevel.Information, Arg.Any(), @@ -3893,7 +3879,7 @@ public async Task HandleAsync_WhenBusinessTier_AndSchedulerThrows_LogsErrorAndFa _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); _assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment); _cohortRepository.GetByIdAsync(cohortId).Returns(cohort); - _priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort) + _priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any()) .ThrowsAsync(new Exception("boom")); // Act From 53cacd73b15ded278136c25d477c04136750a08f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:36:51 -0400 Subject: [PATCH 25/29] test(billing): Add PriceIncreaseScheduler tests for SkipIfAlreadyScheduled option --- .../Pricing/PriceIncreaseSchedulerTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index 5276dafd12fb..d1d517389c67 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -1669,6 +1669,83 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() .CreateSubscriptionScheduleAsync(Arg.Any()); } + [Fact] + public async Task ScheduleForSubscription_SkipIfAlreadyScheduled_ScheduledDateSet_ReturnsFalse() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id, + ScheduledDate = DateTime.UtcNow + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription( + subscription, + new OrganizationPriceIncreaseOptions { SkipIfAlreadyScheduled = true }); + + Assert.False(result); + await _stripeAdapter.DidNotReceiveWithAnyArgs() + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + + [Fact] + public async Task ScheduleForSubscription_DefaultOptions_ScheduledDateSet_ProceedsToSchedule() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id, + ScheduledDate = DateTime.UtcNow // already scheduled, but no SkipIfAlreadyScheduled guard + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.EnterpriseAnnually2020)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.PasswordManager.StripeSeatPlanId, 10)); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + var sut = CreateSut(); + var result = await sut.ScheduleForSubscription(subscription); // default options + + Assert.True(result); + await _stripeAdapter.Received(1) + .CreateSubscriptionScheduleAsync(Arg.Any()); + } + private static Subscription CreateSubscription(string id, string customerId, params SubscriptionItem[] items) => CreateSubscription(id, customerId, new Dictionary { { "userId", Guid.NewGuid().ToString() } }, items); From 64bc9ee6fefd8cca8c9fa6104c806339765b4b07 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:36:52 -0400 Subject: [PATCH 26/29] test(billing): Add SubscriberService tests for price increase schedule management --- .../Services/SubscriberServiceTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 4d8029d0321c..762db13dc3de 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1905,4 +1905,62 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync( options.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancellationOrigin) && options.Metadata[StripeConstants.MetadataKeys.CancellationOrigin] == StripeConstants.CancellationOrigins.UnpaidSubscription)); } + + [Theory, BitAutoData] + public async Task ScheduleUnpaidCancellationAsync_UnpaidAndUnscheduled_ReleasesScheduleBeforeUpdatingSubscription( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription + { + Id = organization.GatewaySubscriptionId, + CustomerId = "cus_1", + Status = StripeConstants.SubscriptionStatus.Unpaid, + Metadata = new Dictionary() + }; + + sutProvider.GetDependency() + .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + + await sutProvider.Sut.ScheduleUnpaidCancellationAsync(organization); + + Received.InOrder(() => + { + sutProvider.GetDependency() + .Release("cus_1", organization.GatewaySubscriptionId); + sutProvider.GetDependency() + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + }); + } + + [Theory, BitAutoData] + public async Task ResumeFromUnpaidCancellationAsync_UnpaidWithMatchingMetadata_SchedulesAfterClearing( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription + { + Id = organization.GatewaySubscriptionId, + Status = StripeConstants.SubscriptionStatus.Unpaid, + Metadata = new Dictionary + { + { StripeConstants.MetadataKeys.CancellationOrigin, StripeConstants.CancellationOrigins.UnpaidSubscription } + } + }; + + sutProvider.GetDependency() + .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + + await sutProvider.Sut.ResumeFromUnpaidCancellationAsync(organization); + + Received.InOrder(() => + { + sutProvider.GetDependency() + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .ScheduleForSubscription(subscription, Arg.Any()); + }); + } } From a0f1caa4e842f34477596c2ac8c52ddfe1ef2de2 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:55:16 -0400 Subject: [PATCH 27/29] fix(billing): run dotnet format --- src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs | 2 +- src/Core/Billing/Pricing/PriceIncreaseScheduler.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs b/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs index f2672f553cf4..0567a66697f4 100644 --- a/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs +++ b/src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Pricing; +namespace Bit.Core.Billing.Pricing; /// /// Controls optional guard behavior when scheduling an organization price increase via diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 2b3fd5ca235b..3bcb8de7e998 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.PlanMigration; From eb5f9043c42971f04b2952e8aa23f2d6b1cdda20 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 16:57:36 -0400 Subject: [PATCH 28/29] fix(billing): remove redundant customer expansion --- .../Services/Implementations/SubscriptionUpdatedHandler.cs | 2 +- .../Subscriptions/Commands/ReinstateSubscriptionCommand.cs | 2 +- .../Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 5cb6a2d8ce16..56bef5af8375 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -83,7 +83,7 @@ public SubscriptionUpdatedHandler( public async Task HandleAsync(Event parsedEvent) { - var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "customer.discount", "discounts", "latest_invoice", "test_clock"]); + var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer.discount", "discounts", "latest_invoice", "test_clock"]); SubscriberId subscriberId = subscription; var subscriber = await GetSubscriberAsync(subscriberId); diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs index 23ba0ccc25d6..5910d2bf7113 100644 --- a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -30,7 +30,7 @@ public Task> Run(ISubscriber subscriber) => HandleAsy { var subscription = await stripeAdapter.GetSubscriptionAsync( subscriber.GatewaySubscriptionId, - new SubscriptionGetOptions { Expand = ["discounts", "customer", "customer.discount"] }); + new SubscriptionGetOptions { Expand = ["discounts", "customer.discount"] }); if (subscription is not { diff --git a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs index fce8c74e24be..5cf5d7066420 100644 --- a/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs @@ -171,7 +171,6 @@ await _stripeAdapter.Received(1).GetSubscriptionAsync( Arg.Is(o => o.Expand != null && o.Expand.Contains("discounts") && - o.Expand.Contains("customer") && o.Expand.Contains("customer.discount"))); } } From 3babcfee128edacda98fa91278bce5e821a5b03a Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 22 May 2026 17:16:40 -0400 Subject: [PATCH 29/29] fix(billing): expand discounts for customer and subscription --- src/Core/Billing/Services/Implementations/SubscriberService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 157bdf968035..2cd268e6c13b 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -563,7 +563,8 @@ await braintreeGateway.Customer.UpdateAsync( public async Task ResumeFromUnpaidCancellationAsync(ISubscriber subscriber) { - var subscription = await GetSubscription(subscriber); + var subscription = await GetSubscription(subscriber, + new SubscriptionGetOptions { Expand = ["customer.discount", "discounts"] }); if (subscription is null || subscription.Status != SubscriptionStatus.Unpaid ||