From 002ffc25a889a18a15a9d8c9d0032d9047a2f843 Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Fri, 22 May 2026 09:06:29 -0500 Subject: [PATCH 1/2] [PM-37083] feat: Add per-phase price resolution to UpdateOrganizationSubscriptionCommand Resolve source vs. target plan pricing per schedule phase so item changes target the correct phase-specific price ID. Move cohort metadata onto the schedule phases themselves to avoid Stripe normalization triggered by direct subscription metadata updates. Filter the schedule-aware update path to phases where EndDate > now, and drop the feature-flag gate on PriceIncreaseScheduler.Release so schedule existence is the gate. --- .../UpdateOrganizationSubscriptionCommand.cs | 207 +++-- .../OrganizationPlanMigrationPriceMapper.cs | 41 + .../Billing/Pricing/PriceIncreaseScheduler.cs | 95 +-- ...ateOrganizationSubscriptionCommandTests.cs | 763 +++++++++++++++++- ...ganizationPlanMigrationPriceMapperTests.cs | 106 +++ .../Pricing/PriceIncreaseSchedulerTests.cs | 182 ++++- 6 files changed, 1237 insertions(+), 157 deletions(-) create mode 100644 src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs create mode 100644 test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs index e385589cd932..ba95c374ecbf 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs @@ -2,12 +2,17 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.PlanMigration; +using Bit.Core.Billing.Organizations.PlanMigration.Repositories; +using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Utilities; using Bit.Core.Services; using Microsoft.Extensions.Logging; using OneOf; using Stripe; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Billing.Organizations.Commands; @@ -37,6 +42,9 @@ Task> Run( public class UpdateOrganizationSubscriptionCommand( IFeatureService featureService, ILogger logger, + IOrganizationPlanMigrationCohortAssignmentRepository assignmentRepository, + IOrganizationPlanMigrationCohortRepository cohortRepository, + IPricingClient pricingClient, IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IUpdateOrganizationSubscriptionCommand { private static readonly List _validSubscriptionStatusesForUpdate = @@ -114,76 +122,17 @@ public Task> Run( var activeSchedule = schedules.Data.FirstOrDefault(s => s.Status == SubscriptionScheduleStatus.Active && s.SubscriptionId == subscription.Id); - /* An active schedule here means PriceIncreaseScheduler created a schedule to defer a - * Families price migration to renewal. A 2-phase schedule is the standard migration - * state; a 1-phase schedule means the subscription was cancelled (end-of-period) while - * a schedule was attached (PM-33897). Either way, we update via the schedule to avoid - * conflicting with Stripe's schedule ownership of the subscription. */ if (activeSchedule is { Phases.Count: > 0 }) { - if (activeSchedule.Phases.Count > 2) - { - _logger.LogWarning( - "{Command}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1-2), only updating first two", - CommandName, activeSchedule.Id, activeSchedule.Phases.Count); - } - - _logger.LogInformation( - "{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases", - CommandName, activeSchedule.Id, subscription.Id); - - var phase1 = activeSchedule.Phases[0]; var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - /* This applies the change set's price IDs (which are Phase 1 / current-plan prices) - * to all active phases. This works because storage prices are uniform across the - * Families migration. If storage prices ever differ between phases, both this command - * and UpdatePremiumStorageCommand would need plan-aware price resolution (e.g. matching - * Phase 2's seat price to determine the correct storage price). */ - var phases = new List(); - - // Stripe rejects schedule updates that include phases whose end_date is in the past. - // A phase ending at exactly `now` has effectively ended (strict > is intentional). - if (phase1.EndDate > now) - { - phases.Add(new SubscriptionSchedulePhaseOptions - { - StartDate = phase1.StartDate, - EndDate = phase1.EndDate, - Items = ApplyChangesToPhaseItems(phase1.Items, changeSet.Changes), - Discounts = phase1.Discounts?.Select(d => - new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(), - ProrationBehavior = phase1.ProrationBehavior - }); - } - else - { - _logger.LogWarning( - "{Command}: Phase 1 has already ended (EndDate: {EndDate}), updating only active phase(s)", - CommandName, phase1.EndDate); - } - - var phase1Ended = phase1.EndDate <= now; - - if (activeSchedule.Phases.Count >= 2) - { - var phase2 = activeSchedule.Phases[1]; - phases.Add(new SubscriptionSchedulePhaseOptions - { - StartDate = phase2.StartDate, - EndDate = phase2.EndDate, - Items = ApplyChangesToPhaseItems(phase2.Items, changeSet.Changes), - // When Phase 2 is already active, its one-time migration discount has been - // applied and consumed. Re-including it would cause Stripe to re-apply it. - Discounts = phase1Ended - ? [] - : phase2.Discounts?.Select(d => - new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(), - ProrationBehavior = phase2.ProrationBehavior - }); - } + // Stripe normalizes attached schedules into 3 phases when the subscription is mutated: + // an anchor phase covering current_period_start -> schedule.created becomes phases[0]. + // Strict > on EndDate: a phase ending exactly at `now` has effectively ended, and Stripe + // rejects schedule updates that include past phases. + var migrationPhases = activeSchedule.Phases.Where(p => p.EndDate > now).ToList(); - if (phases.Count == 0) + if (migrationPhases.Count == 0) { _logger.LogWarning( "{Command}: Schedule ({ScheduleId}) has no updatable phases remaining", @@ -191,22 +140,21 @@ public Task> Run( return DefaultConflict; } - /* Note: the schedule phase API does not support PendingInvoiceItemInterval. For annual - * subscribers, the non-schedule path invoices prorations monthly. When the top-level - * ProrationBehavior is AlwaysInvoice (structural changes), Stripe invoices immediately. - * When it is CreateProrations (non-structural changes), prorations remain pending until - * the next invoice (~15 days). Accepted trade-off for the migration window. */ + _logger.LogInformation( + "{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating {PhaseCount} active phase(s)", + CommandName, activeSchedule.Id, subscription.Id, migrationPhases.Count); + + var (sourcePlan, targetPlan) = await ResolvePhasePlansAsync(organization); + var phases = BuildUpdatedPhases(migrationPhases, changeSet.Changes, sourcePlan, targetPlan); + await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, new SubscriptionScheduleUpdateOptions { - EndBehavior = activeSchedule.EndBehavior, + EndBehavior = SubscriptionScheduleEndBehavior.Release, Phases = phases, ProrationBehavior = prorationBehavior }); - /* Note: this returns the pre-update subscription. The schedule update modified the - * subscription via Stripe, but we don't re-fetch it. Callers currently only check - * success/failure. If a caller ever needs the post-update state, re-fetch here. */ return subscription; } @@ -267,6 +215,37 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, } } + private async Task<(Plan source, Plan target)> ResolvePhasePlansAsync(Organization organization) + { + var migrationPath = await TryResolveMigrationPathAsync(organization.Id); + if (migrationPath is null) + { + var current = await pricingClient.GetPlanOrThrow(organization.PlanType); + return (current, current); + } + + var source = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan); + var target = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan); + return (source, target); + } + + private async Task TryResolveMigrationPathAsync(Guid organizationId) + { + var assignment = await assignmentRepository.GetByOrganizationIdAsync(organizationId); + if (assignment is null) + { + return null; + } + + var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId); + if (cohort?.MigrationPathId is null) + { + return null; + } + + return MigrationPaths.FromId(cohort.MigrationPathId.Value); + } + private async Task ReconcileTaxExemptionAsync(Customer customer) { var determinedTaxExemptStatus = TaxHelpers.DetermineTaxExemptStatus(customer.Address?.Country, customer.TaxExempt); @@ -352,14 +331,63 @@ private static OneOf ValidateItemRemoval( }; } + private static List BuildUpdatedPhases( + List migrationPhases, + IReadOnlyList changes, + Plan sourcePlan, + Plan targetPlan) + { + var phase1Ended = migrationPhases.Count == 1; + + var phases = new List(); + + var phase1 = migrationPhases[0]; + phases.Add(BuildPhaseOptions( + phase1, changes, + source: sourcePlan, + target: phase1Ended ? targetPlan : sourcePlan, + suppressDiscounts: phase1Ended)); + + if (migrationPhases.Count >= 2) + { + phases.Add(BuildPhaseOptions( + migrationPhases[1], changes, + source: sourcePlan, + target: targetPlan, + suppressDiscounts: false)); + } + + return phases; + } + + private static SubscriptionSchedulePhaseOptions BuildPhaseOptions( + SubscriptionSchedulePhase sourcePhase, + IReadOnlyList changes, + Plan source, + Plan target, + bool suppressDiscounts) => + new() + { + StartDate = sourcePhase.StartDate, + EndDate = sourcePhase.EndDate, + Items = ApplyChangesToPhaseItems(sourcePhase.Items, changes, source, target), + Discounts = suppressDiscounts + ? [] + : sourcePhase.Discounts?.Select(d => + new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(), + Metadata = sourcePhase.Metadata, + ProrationBehavior = sourcePhase.ProrationBehavior + }; + private static List ApplyChangesToPhaseItems( IList phaseItems, - IReadOnlyList changes) + IReadOnlyList changes, + Plan sourcePlan, + Plan targetPlan) { - /* Note: when a change targets a price ID not present in this phase (e.g. Phase 2 has - * migrated prices), the change is silently skipped. This is safe because subscription- - * level validation (ValidateItemAddition, ValidateItemPriceChange, etc.) already ran - * before this method is called. */ + string Translate(string priceId) => + OrganizationPlanMigrationPriceMapper.MapOrPassThrough(priceId, sourcePlan, targetPlan); + var items = phaseItems .Select(i => new SubscriptionSchedulePhaseItemOptions { Price = i.PriceId, Quantity = i.Quantity }) .ToList(); @@ -369,31 +397,38 @@ private static List ApplyChangesToPhaseIte change.Switch( addItem => items.Add(new SubscriptionSchedulePhaseItemOptions { - Price = addItem.PriceId, + Price = Translate(addItem.PriceId), Quantity = addItem.Quantity }), changeItemPrice => { - var existing = items.FirstOrDefault(i => i.Price == changeItemPrice.CurrentPriceId); + var translatedCurrent = Translate(changeItemPrice.CurrentPriceId); + var translatedUpdated = Translate(changeItemPrice.UpdatedPriceId); + var existing = items.FirstOrDefault(i => i.Price == translatedCurrent); if (existing != null) { - existing.Price = changeItemPrice.UpdatedPriceId; + existing.Price = translatedUpdated; if (changeItemPrice.Quantity.HasValue) { existing.Quantity = changeItemPrice.Quantity.Value; } } }, - removeItem => items.RemoveAll(i => i.Price == removeItem.PriceId), + removeItem => + { + var translated = Translate(removeItem.PriceId); + items.RemoveAll(i => i.Price == translated); + }, updateItemQuantity => { + var translated = Translate(updateItemQuantity.PriceId); if (updateItemQuantity.Quantity == 0) { - items.RemoveAll(i => i.Price == updateItemQuantity.PriceId); + items.RemoveAll(i => i.Price == translated); } else { - var existing = items.FirstOrDefault(i => i.Price == updateItemQuantity.PriceId); + var existing = items.FirstOrDefault(i => i.Price == translated); if (existing != null) { existing.Quantity = updateItemQuantity.Quantity; @@ -402,7 +437,7 @@ private static List ApplyChangesToPhaseIte { items.Add(new SubscriptionSchedulePhaseItemOptions { - Price = updateItemQuantity.PriceId, + Price = translated, Quantity = updateItemQuantity.Quantity }); } diff --git a/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs new file mode 100644 index 000000000000..8d84457e37f2 --- /dev/null +++ b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs @@ -0,0 +1,41 @@ +using Plan = Bit.Core.Models.StaticStore.Plan; + +namespace Bit.Core.Billing.Organizations.PlanMigration; + +internal static class OrganizationPlanMigrationPriceMapper +{ + /// + /// Returns the target plan's equivalent price ID, or null if no mapping exists. + /// + public static string? MapOrNull(string sourcePriceId, Plan source, Plan target) => + Resolve(sourcePriceId, source, target); + + /// + /// Maps as ; returns the input unchanged on miss. Short-circuits when + /// source and target are the same instance. Pass-through is intentional for Families and + /// uniform-price slots — callers should not log misses. + /// + public static string MapOrPassThrough(string sourcePriceId, Plan source, Plan target) + { + if (ReferenceEquals(source, target)) + { + return sourcePriceId; + } + return Resolve(sourcePriceId, source, target) ?? sourcePriceId; + } + + private static string? Resolve(string sourcePriceId, Plan source, Plan target) => sourcePriceId switch + { + _ when sourcePriceId == source.PasswordManager.StripeSeatPlanId => + target.PasswordManager.StripeSeatPlanId, + _ when sourcePriceId == source.PasswordManager.StripeStoragePlanId => + target.PasswordManager.StripeStoragePlanId, + _ when source.SecretsManager is not null && target.SecretsManager is not null && + sourcePriceId == source.SecretsManager.StripeSeatPlanId => + target.SecretsManager.StripeSeatPlanId, + _ when source.SecretsManager is not null && target.SecretsManager is not null && + sourcePriceId == source.SecretsManager.StripeServiceAccountPlanId => + target.SecretsManager.StripeServiceAccountPlanId, + _ => null + }; +} diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index dee000af3bbd..567e28c62cb0 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.PlanMigration; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; @@ -9,7 +10,6 @@ using Microsoft.Extensions.Logging; using Stripe; using static Bit.Core.Billing.Constants.StripeConstants; -using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Billing.Pricing; @@ -140,26 +140,13 @@ public async Task ScheduleBusinessPriceIncrease( return false; } - await CreateAndConfigureScheduleAsync(subscription, phase2); - - try + var phaseMetadata = new Dictionary { - await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, - new SubscriptionUpdateOptions - { - Metadata = new Dictionary - { - [MetadataKeys.MigrationCohortId] = cohort.Id.ToString(), - [MetadataKeys.MigrationCohortName] = cohort.Name - } - }); - } - catch (Exception ex) - { - logger.LogError(ex, - "Failed to attach cohort metadata to subscription ({SubscriptionId}) for cohort ({CohortId}); migration is scheduled but Stripe dashboard attribution will be missing.", - subscription.Id, cohort.Id); - } + [MetadataKeys.MigrationCohortId] = cohort.Id.ToString(), + [MetadataKeys.MigrationCohortName] = cohort.Name + }; + + await CreateAndConfigureScheduleAsync(subscription, phase2, phaseMetadata); var assignment = await assignmentRepository.GetByOrganizationIdAsync(organizationId); if (assignment is null) @@ -184,12 +171,6 @@ await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, public async Task Release(string customerId, string subscriptionId) { - if (!featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) && - !featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)) - { - return; - } - try { var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync( @@ -232,7 +213,8 @@ private async Task ActiveScheduleExistsAsync(Subscription subscription) private async Task CreateAndConfigureScheduleAsync( Subscription subscription, - SubscriptionSchedulePhaseOptions phase2) + SubscriptionSchedulePhaseOptions phase2Options, + Dictionary? phaseMetadata = null) { var schedule = await stripeAdapter.CreateSubscriptionScheduleAsync( new SubscriptionScheduleCreateOptions { FromSubscription = subscription.Id }); @@ -241,32 +223,36 @@ private async Task CreateAndConfigureScheduleAsync( { var phase1 = schedule.Phases[0]; + var phase1Options = new SubscriptionSchedulePhaseOptions + { + StartDate = phase1.StartDate, + EndDate = phase1.EndDate, + Items = [.. phase1.Items.Select(i => new SubscriptionSchedulePhaseItemOptions + { + Price = i.PriceId, + Quantity = i.Quantity + })], + Discounts = phase1.Discounts is null ? null : + [ + .. phase1.Discounts.Select(d => new SubscriptionSchedulePhaseDiscountOptions + { + Coupon = d.CouponId + }) + ], + ProrationBehavior = ProrationBehavior.None + }; + + if (phaseMetadata is not null) + { + phase1Options.Metadata = phaseMetadata; + phase2Options.Metadata = phaseMetadata; + } + await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, new SubscriptionScheduleUpdateOptions { 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 - })], - Discounts = phase1.Discounts is null ? null : - [ - .. phase1.Discounts.Select(d => new SubscriptionSchedulePhaseDiscountOptions - { - Coupon = d.CouponId - }) - ], - ProrationBehavior = ProrationBehavior.None - }, - phase2 - ] + Phases = [phase1Options, phase2Options] }); return schedule; @@ -505,7 +491,7 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, var items = new List(); foreach (var item in subscription.Items.Data) { - var targetPriceId = MapToTargetPriceId(item.Price.Id, sourcePlan, targetPlan); + var targetPriceId = OrganizationPlanMigrationPriceMapper.MapOrNull(item.Price.Id, sourcePlan, targetPlan); if (targetPriceId is null) { logger.LogWarning( @@ -561,13 +547,4 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, }; } - private static string? MapToTargetPriceId(string sourcePriceId, Plan source, Plan target) => sourcePriceId switch - { - _ when sourcePriceId == source.PasswordManager.StripeSeatPlanId => target.PasswordManager.StripeSeatPlanId, - _ when sourcePriceId == source.PasswordManager.StripeStoragePlanId => target.PasswordManager.StripeStoragePlanId, - _ when sourcePriceId == source.SecretsManager?.StripeSeatPlanId => target.SecretsManager?.StripeSeatPlanId, - _ when sourcePriceId == source.SecretsManager?.StripeServiceAccountPlanId => target.SecretsManager?.StripeServiceAccountPlanId, - _ => null - }; - } diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs index 261666339a10..08e71c6c0ca8 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs @@ -1,9 +1,15 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; +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.Services; +using Bit.Core.Test.Billing.Mocks; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; @@ -17,6 +23,11 @@ public class UpdateOrganizationSubscriptionCommandTests { private readonly IFeatureService _featureService = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IOrganizationPlanMigrationCohortAssignmentRepository _assignmentRepository = + Substitute.For(); + private readonly IOrganizationPlanMigrationCohortRepository _cohortRepository = + Substitute.For(); private readonly UpdateOrganizationSubscriptionCommand _command; public UpdateOrganizationSubscriptionCommandTests() @@ -24,9 +35,19 @@ public UpdateOrganizationSubscriptionCommandTests() _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); + // Default: no migration assignment; any plan resolution returns a single mock instance so + // ReferenceEquals(source, target) is true and the price mapper short-circuits. + _assignmentRepository.GetByOrganizationIdAsync(Arg.Any()) + .Returns((OrganizationPlanMigrationCohortAssignment?)null); + _pricingClient.GetPlanOrThrow(Arg.Any()) + .Returns(MockPlans.Get(PlanType.EnterpriseAnnually2020)); + _command = new UpdateOrganizationSubscriptionCommand( _featureService, Substitute.For>(), + _assignmentRepository, + _cohortRepository, + _pricingClient, _stripeAdapter); } @@ -1156,7 +1177,7 @@ public async Task Run_AddItem_WithSinglePhaseSchedule_UpdatesOnlyPhase1() await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( schedule.Id, Arg.Is(opts => - opts.EndBehavior == SubscriptionScheduleEndBehavior.Cancel && + opts.EndBehavior == SubscriptionScheduleEndBehavior.Release && opts.Phases.Count == 1 && opts.Phases[0].Items.Any(i => i.Price == "price_storage" && i.Quantity == 3) && opts.Phases[0].Items.Any(i => i.Price == "price_seats" && i.Quantity == 5))); @@ -1234,6 +1255,745 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task Run_BusinessMigration_AddItem_PhaseSpecificTranslation() + { + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var subscription = CreateSubscription(items: [(source.PasswordManager.StripeSeatPlanId, "si_1", 10)]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule( + subscription.Id, + [(source.PasswordManager.StripeSeatPlanId, 10)], + [(target.PasswordManager.StripeSeatPlanId, 10)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem(source.SecretsManager.StripeServiceAccountPlanId, 5)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases.Count == 2 && + opts.Phases[0].Items.Any(i => + i.Price == source.SecretsManager.StripeServiceAccountPlanId && i.Quantity == 5) && + opts.Phases[1].Items.Any(i => + i.Price == target.SecretsManager.StripeServiceAccountPlanId && i.Quantity == 5))); + } + + [Fact] + public async Task Run_BusinessMigration_ChangeItemPrice_QuantityOnly_TranslatesBothIds() + { + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var sourceSeat = source.PasswordManager.StripeSeatPlanId; + var targetSeat = target.PasswordManager.StripeSeatPlanId; + + var subscription = CreateSubscription(items: [(sourceSeat, "si_1", 10)]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule( + subscription.Id, + [(sourceSeat, 10)], + [(targetSeat, 10)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new ChangeItemPrice(sourceSeat, sourceSeat, 20)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Items.Any(i => i.Price == sourceSeat && i.Quantity == 20) && + opts.Phases[1].Items.Any(i => i.Price == targetSeat && i.Quantity == 20))); + } + + [Fact] + public async Task Run_BusinessMigration_RemoveItem_TranslatesOnPhase2() + { + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var sourceSeat = source.PasswordManager.StripeSeatPlanId; + var targetSeat = target.PasswordManager.StripeSeatPlanId; + + var subscription = CreateSubscription(items: [(sourceSeat, "si_1", 10)]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule( + subscription.Id, + [(sourceSeat, 10)], + [(targetSeat, 10)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new RemoveItem(sourceSeat)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Items.All(i => i.Price != sourceSeat) && + opts.Phases[1].Items.All(i => i.Price != targetSeat))); + } + + [Fact] + public async Task Run_BusinessMigration_UpdateItemQuantity_Zero_TranslatesRemovalOnPhase2() + { + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var sourceSa = source.SecretsManager.StripeServiceAccountPlanId; + var targetSa = target.SecretsManager.StripeServiceAccountPlanId; + + var subscription = CreateSubscription(items: [(sourceSa, "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule( + subscription.Id, + [(sourceSa, 5)], + [(targetSa, 5)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity(sourceSa, 0)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Items.All(i => i.Price != sourceSa) && + opts.Phases[1].Items.All(i => i.Price != targetSa))); + } + + [Fact] + public async Task Run_BusinessMigration_MultipleChanges_TranslatesAcrossSequence() + { + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var sourceSeat = source.PasswordManager.StripeSeatPlanId; + var targetSeat = target.PasswordManager.StripeSeatPlanId; + var sourceSa = source.SecretsManager.StripeServiceAccountPlanId; + var targetSa = target.SecretsManager.StripeServiceAccountPlanId; + var sourceStorage = source.PasswordManager.StripeStoragePlanId; + var targetStorage = target.PasswordManager.StripeStoragePlanId; + + var subscription = CreateSubscription(items: + [ + (sourceSeat, "si_1", 5), + (sourceSa, "si_2", 3) + ]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule( + subscription.Id, + [(sourceSeat, 5), (sourceSa, 3)], + [(targetSeat, 5), (targetSa, 3)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = + [ + new UpdateItemQuantity(sourceSeat, 10), + new RemoveItem(sourceSa), + new AddItem(sourceStorage, 1) + ] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Items.Any(i => i.Price == sourceSeat && i.Quantity == 10) && + opts.Phases[0].Items.All(i => i.Price != sourceSa) && + opts.Phases[0].Items.Any(i => i.Price == sourceStorage && i.Quantity == 1) && + opts.Phases[1].Items.Any(i => i.Price == targetSeat && i.Quantity == 10) && + opts.Phases[1].Items.All(i => i.Price != targetSa) && + opts.Phases[1].Items.Any(i => i.Price == targetStorage && i.Quantity == 1))); + } + + [Fact] + public async Task Run_NoMigrationAssignment_DoesNotTranslate() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule(subscription.Id, [("price_seats", 5)], [("price_seats_new", 5)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 3)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases.Count == 2 && + opts.Phases[0].Items.Any(i => i.Price == "price_storage" && i.Quantity == 3) && + opts.Phases[0].Items.Any(i => i.Price == "price_seats" && i.Quantity == 5) && + opts.Phases[1].Items.Any(i => i.Price == "price_storage" && i.Quantity == 3) && + opts.Phases[1].Items.Any(i => i.Price == "price_seats_new" && i.Quantity == 5))); + } + + [Fact] + public async Task Run_WithSchedule_FiltersExpiredPhases() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var now = DateTime.UtcNow; + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_123", + SubscriptionId = subscription.Id, + Status = SubscriptionScheduleStatus.Active, + EndBehavior = SubscriptionScheduleEndBehavior.Release, + Phases = + [ + new SubscriptionSchedulePhase + { + StartDate = now.AddDays(-30), + EndDate = now.AddMinutes(-5), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_anchor", Quantity = 1 }], + ProrationBehavior = ProrationBehavior.None + }, + new SubscriptionSchedulePhase + { + StartDate = now.AddMinutes(-5), + EndDate = now.AddYears(1), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_seats", Quantity = 5 }], + ProrationBehavior = ProrationBehavior.None + }, + new SubscriptionSchedulePhase + { + StartDate = now.AddYears(1), + EndDate = now.AddYears(2), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_seats_new", Quantity = 5 }], + ProrationBehavior = ProrationBehavior.None + } + ] + }; + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases.Count == 2 && + opts.Phases[0].Items.Any(i => i.Price == "price_seats") && + opts.Phases[0].Items.Any(i => i.Price == "price_storage") && + opts.Phases[1].Items.Any(i => i.Price == "price_seats_new") && + opts.Phases[1].Items.Any(i => i.Price == "price_storage"))); + } + + [Fact] + public async Task Run_WithSchedule_SinglePhaseCancellation_HandlesPhase1Only() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var now = DateTime.UtcNow; + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_123", + SubscriptionId = subscription.Id, + Status = SubscriptionScheduleStatus.Active, + EndBehavior = SubscriptionScheduleEndBehavior.Release, + Phases = + [ + new SubscriptionSchedulePhase + { + StartDate = now.AddDays(-30), + EndDate = now.AddMinutes(-5), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_anchor", Quantity = 1 }], + ProrationBehavior = ProrationBehavior.None + }, + new SubscriptionSchedulePhase + { + StartDate = now.AddMinutes(-5), + EndDate = now.AddDays(7), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_seats", Quantity = 5 }], + ProrationBehavior = ProrationBehavior.None + } + ] + }; + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases.Count == 1 && + opts.Phases[0].Discounts != null && + opts.Phases[0].Discounts.Count == 0 && + opts.Phases[0].Items.Any(i => i.Price == "price_seats" && i.Quantity == 10))); + } + + [Fact] + public async Task Run_WithSchedule_AllPhasesExpired_ReturnsConflict() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var now = DateTime.UtcNow; + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_123", + SubscriptionId = subscription.Id, + Status = SubscriptionScheduleStatus.Active, + EndBehavior = SubscriptionScheduleEndBehavior.Release, + Phases = + [ + new SubscriptionSchedulePhase + { + StartDate = now.AddDays(-30), + EndDate = now.AddMinutes(-1), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_seats", Quantity = 5 }], + ProrationBehavior = ProrationBehavior.None + } + ] + }; + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT2); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_WithSchedule_PreservesPhaseMetadata() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var metadata = new Dictionary + { + [MetadataKeys.MigrationCohortId] = "foo", + [MetadataKeys.MigrationCohortName] = "bar" + }; + + var schedule = CreateMockSchedule(subscription.Id, [("price_seats", 5)], [("price_seats_new", 5)]); + schedule.Phases[0].Metadata = metadata; + schedule.Phases[1].Metadata = metadata; + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Metadata != null && + opts.Phases[0].Metadata[MetadataKeys.MigrationCohortId] == "foo" && + opts.Phases[0].Metadata[MetadataKeys.MigrationCohortName] == "bar" && + opts.Phases[1].Metadata != null && + opts.Phases[1].Metadata[MetadataKeys.MigrationCohortId] == "foo" && + opts.Phases[1].Metadata[MetadataKeys.MigrationCohortName] == "bar")); + } + + [Fact] + public async Task Run_WithSchedule_PhaseMetadataNull_StaysNull() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule(subscription.Id, [("price_seats", 5)], [("price_seats_new", 5)]); + schedule.Phases[0].Metadata = null; + schedule.Phases[1].Metadata = null; + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Metadata == null && + opts.Phases[1].Metadata == null)); + } + + [Fact] + public async Task Run_WithSchedule_PhaseMetadataEmpty_StaysEmpty() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule(subscription.Id, [("price_seats", 5)], [("price_seats_new", 5)]); + schedule.Phases[0].Metadata = new Dictionary(); + schedule.Phases[1].Metadata = new Dictionary(); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Metadata != null && opts.Phases[0].Metadata.Count == 0 && + opts.Phases[1].Metadata != null && opts.Phases[1].Metadata.Count == 0)); + } + + [Fact] + public async Task Run_BusinessMigration_OnNormalized3PhaseSchedule_PreservesEverything() + { + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var sourceSeat = source.PasswordManager.StripeSeatPlanId; + var targetSeat = target.PasswordManager.StripeSeatPlanId; + + var subscription = CreateSubscription(items: [(sourceSeat, "si_1", 10)]); + SetupGetSubscription(organization, subscription); + + var now = DateTime.UtcNow; + var cohortMetadata = new Dictionary + { + [MetadataKeys.MigrationCohortId] = "cohort-1", + [MetadataKeys.MigrationCohortName] = "ent-2020" + }; + + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_123", + SubscriptionId = subscription.Id, + Status = SubscriptionScheduleStatus.Active, + EndBehavior = SubscriptionScheduleEndBehavior.Release, + Phases = + [ + new SubscriptionSchedulePhase + { + StartDate = now.AddDays(-30), + EndDate = now.AddMinutes(-5), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_anchor", Quantity = 1 }], + ProrationBehavior = ProrationBehavior.None + }, + new SubscriptionSchedulePhase + { + StartDate = now.AddMinutes(-5), + EndDate = now.AddYears(1), + Items = [new SubscriptionSchedulePhaseItem { PriceId = sourceSeat, Quantity = 10 }], + Metadata = cohortMetadata, + ProrationBehavior = ProrationBehavior.None + }, + new SubscriptionSchedulePhase + { + StartDate = now.AddYears(1), + EndDate = now.AddYears(2), + Items = [new SubscriptionSchedulePhaseItem { PriceId = targetSeat, Quantity = 10 }], + Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "five-percent-once" }], + Metadata = cohortMetadata, + ProrationBehavior = ProrationBehavior.None + } + ] + }; + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity(sourceSeat, 5)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases.Count == 2 && + opts.Phases[0].Metadata != null && + opts.Phases[0].Metadata[MetadataKeys.MigrationCohortId] == "cohort-1" && + opts.Phases[0].Items.Any(i => i.Price == sourceSeat && i.Quantity == 5) && + opts.Phases[1].Metadata != null && + opts.Phases[1].Metadata[MetadataKeys.MigrationCohortId] == "cohort-1" && + opts.Phases[1].Discounts != null && + opts.Phases[1].Discounts.Any(d => d.Coupon == "five-percent-once") && + opts.Phases[1].Items.Any(i => i.Price == targetSeat && i.Quantity == 5))); + } + + [Fact] + public async Task ResolvePhasePlansAsync_AssignmentNull_ReturnsSameInstancePair() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + _assignmentRepository.GetByOrganizationIdAsync(organization.Id) + .Returns((OrganizationPlanMigrationCohortAssignment?)null); + + var plan = MockPlans.Get(PlanType.EnterpriseAnnually2020); + _pricingClient.GetPlanOrThrow(Arg.Any()).Returns(plan); + + var schedule = CreateMockSchedule(subscription.Id, [("price_seats", 5)], [("price_seats_new", 5)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + // Same instance pair → mapper short-circuits → both phases keep original IDs. + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Items.Any(i => i.Price == "price_seats" && i.Quantity == 10) && + opts.Phases[1].Items.Any(i => i.Price == "price_seats_new" && i.Quantity == 5))); + } + + [Fact] + public async Task ResolvePhasePlansAsync_CohortMissingMigrationPathId_ReturnsSameInstancePair() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + CohortId = Guid.NewGuid() + }; + _assignmentRepository.GetByOrganizationIdAsync(organization.Id).Returns(assignment); + _cohortRepository.GetByIdAsync(assignment.CohortId).Returns(new OrganizationPlanMigrationCohort + { + Id = assignment.CohortId, + Name = "churn-only", + MigrationPathId = null + }); + + var plan = MockPlans.Get(PlanType.EnterpriseAnnually2020); + _pricingClient.GetPlanOrThrow(Arg.Any()).Returns(plan); + + var schedule = CreateMockSchedule(subscription.Id, [("price_seats", 5)], [("price_seats_new", 5)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Items.Any(i => i.Price == "price_seats" && i.Quantity == 10) && + opts.Phases[1].Items.Any(i => i.Price == "price_seats_new" && i.Quantity == 5))); + } + + [Fact] + public async Task ResolvePhasePlansAsync_ValidPath_ReturnsDistinctSourceTargetPair() + { + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + var sourceSeat = source.PasswordManager.StripeSeatPlanId; + var targetSeat = target.PasswordManager.StripeSeatPlanId; + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var subscription = CreateSubscription(items: [(sourceSeat, "si_1", 5)]); + SetupGetSubscription(organization, subscription); + + var schedule = CreateMockSchedule( + subscription.Id, + [(sourceSeat, 5)], + [(targetSeat, 5)]); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity(sourceSeat, 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + // Phase 1 uses source IDs; Phase 2 uses target IDs. + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases[0].Items.Any(i => i.Price == sourceSeat && i.Quantity == 10) && + opts.Phases[1].Items.Any(i => i.Price == targetSeat && i.Quantity == 10))); + } + + private void SetupMigration( + Organization organization, + MigrationPathId pathId, + PlanType sourcePlanType, + Bit.Core.Models.StaticStore.Plan sourcePlan, + PlanType targetPlanType, + Bit.Core.Models.StaticStore.Plan targetPlan) + { + var cohortId = Guid.NewGuid(); + _assignmentRepository.GetByOrganizationIdAsync(organization.Id).Returns(new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + CohortId = cohortId + }); + _cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = $"cohort-{pathId}", + MigrationPathId = pathId, + IsActive = true + }); + _pricingClient.GetPlanOrThrow(sourcePlanType).Returns(sourcePlan); + _pricingClient.GetPlanOrThrow(targetPlanType).Returns(targetPlan); + } + private static Organization CreateOrganization() => new() { Id = Guid.NewGuid(), @@ -1314,6 +2074,7 @@ private static SubscriptionSchedule CreateMockSchedule( phases.Add(new SubscriptionSchedulePhase { StartDate = phase1End, + EndDate = phase1End.AddYears(1), Items = phase2Items.Select(i => new SubscriptionSchedulePhaseItem { PriceId = i.priceId, Quantity = i.quantity }).ToList(), ProrationBehavior = ProrationBehavior.None diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs new file mode 100644 index 000000000000..802506627f4f --- /dev/null +++ b/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs @@ -0,0 +1,106 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.PlanMigration; +using Bit.Core.Test.Billing.Mocks; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.PlanMigration; + +public class OrganizationPlanMigrationPriceMapperTests +{ + [Fact] + public void MapOrNull_PmSeat_ReturnsTargetPmSeat() + { + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.PasswordManager.StripeSeatPlanId, source, target); + + Assert.Equal(target.PasswordManager.StripeSeatPlanId, result); + } + + [Fact] + public void MapOrNull_SmServiceAccount_ReturnsTargetSmServiceAccount() + { + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.SecretsManager.StripeServiceAccountPlanId, source, target); + + Assert.Equal(target.SecretsManager.StripeServiceAccountPlanId, result); + } + + [Fact] + public void MapOrNull_UnknownPriceId_ReturnsNull() + { + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull("unmapped-price", source, target); + + Assert.Null(result); + } + + [Fact] + public void MapOrNull_SmSeatWhenSourceSmIsNull_ReturnsNull() + { + var source = MockPlans.Get(PlanType.FamiliesAnnually); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + target.SecretsManager.StripeSeatPlanId, source, target); + + Assert.Null(result); + } + + [Fact] + public void MapOrNull_SmSeatWhenTargetSmIsNull_ReturnsNull() + { + var source = MockPlans.Get(PlanType.EnterpriseAnnually); + var target = MockPlans.Get(PlanType.FamiliesAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.SecretsManager.StripeSeatPlanId, source, target); + + Assert.Null(result); + } + + [Fact] + public void MapOrPassThrough_SamePlanInstance_ReturnsInputUnchanged() + { + var plan = MockPlans.Get(PlanType.EnterpriseAnnually2020); + + var resultForKnownSlot = OrganizationPlanMigrationPriceMapper.MapOrPassThrough( + plan.PasswordManager.StripeSeatPlanId, plan, plan); + var resultForUnknown = OrganizationPlanMigrationPriceMapper.MapOrPassThrough( + "unmapped", plan, plan); + + Assert.Equal(plan.PasswordManager.StripeSeatPlanId, resultForKnownSlot); + Assert.Equal("unmapped", resultForUnknown); + } + + [Fact] + public void MapOrPassThrough_UnknownPriceId_ReturnsInput() + { + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrPassThrough( + "unmapped-price", source, target); + + Assert.Equal("unmapped-price", result); + } + + [Fact] + public void MapOrPassThrough_MappedPriceId_ReturnsTargetSlotValue() + { + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrPassThrough( + source.PasswordManager.StripeSeatPlanId, source, target); + + Assert.Equal(target.PasswordManager.StripeSeatPlanId, result); + } +} diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index 2a030af25142..1692aca69020 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -586,17 +586,22 @@ await _stripeAdapter.DidNotReceiveWithAnyArgs() } [Fact] - public async Task Release_BothFeatureFlagsOff_DoesNothing() + public async Task Release_BothFeatureFlagsOff_StillReleasesWhenScheduleExists() { _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false); _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [CreateSchedule("sched_1", "sub_1", SubscriptionScheduleStatus.Active)] + }); + var sut = CreateSut(); await sut.Release("cus_1", "sub_1"); - await _stripeAdapter.DidNotReceiveWithAnyArgs() - .ListSubscriptionSchedulesAsync(Arg.Any()); + await _stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync("sched_1", null); } [Fact] @@ -782,10 +787,13 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( await _assignmentRepository.Received(1).ReplaceAsync(Arg.Is(a => a.OrganizationId == orgId && a.ScheduledDate != null)); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] - public async Task ScheduleBusinessPriceIncrease_OnSuccess_UpdatesSubscriptionMetadataWithCohort() + public async Task ScheduleBusinessPriceIncrease_OnSuccess_StampsCohortMetadataOnSchedulePhases() { _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); @@ -818,13 +826,138 @@ public async Task ScheduleBusinessPriceIncrease_OnSuccess_UpdatesSubscriptionMet var result = await sut.ScheduleBusinessPriceIncrease(subscription, cohort); Assert.True(result); - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - "sub_1", - Arg.Is(o => - o.Metadata != null && - o.Metadata.Count == 2 && - o.Metadata["migration_cohort_id"] == cohort.Id.ToString() && - o.Metadata["migration_cohort_name"] == cohort.Name)); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[0].Metadata != null && + o.Phases[0].Metadata[MetadataKeys.MigrationCohortId] == cohort.Id.ToString() && + o.Phases[0].Metadata[MetadataKeys.MigrationCohortName] == cohort.Name && + o.Phases[1].Metadata != null && + o.Phases[1].Metadata[MetadataKeys.MigrationCohortId] == cohort.Id.ToString() && + o.Phases[1].Metadata[MetadataKeys.MigrationCohortName] == cohort.Name)); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + "sub_1", Arg.Any()); + } + + [Fact] + public async Task ScheduleBusinessPriceIncrease_DoesNotInvokeUpdateSubscription() + { + _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 subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.PasswordManager.StripeSeatPlanId, 10)); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }); + + var sut = CreateSut(); + + await sut.ScheduleBusinessPriceIncrease(subscription, cohort); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task SchedulePersonalPriceIncrease_DoesNotSetMetadataOnPhases() + { + _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(); + + await sut.SchedulePersonalPriceIncrease(subscription); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[0].Metadata == null && + o.Phases[1].Metadata == null)); + } + + [Fact] + public async Task ScheduleBusinessPriceIncrease_LineItemUsingMapper_PicksUpSecretsManagerSeat() + { + _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 subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.SecretsManager.StripeSeatPlanId, 4)); + var cohort = CreateCohort(MigrationPathId.Enterprise2020AnnualToCurrent); + + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(CreateScheduleWithPhase("sched_1", "sub_1")); + + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }); + + var sut = CreateSut(); + + await sut.ScheduleBusinessPriceIncrease(subscription, cohort); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases[1].Items.Any(i => i.Price == target.SecretsManager.StripeSeatPlanId && i.Quantity == 4))); } [Fact] @@ -881,6 +1014,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( await _assignmentRepository.Received(1).ReplaceAsync(Arg.Is(a => a.OrganizationId == orgId && a.ScheduledDate != null)); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -926,6 +1062,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( o.Phases[1].Discounts != null && o.Phases[1].Discounts.Count == 1 && o.Phases[1].Discounts[0].Coupon == "grandfather")); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -977,6 +1116,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( o.Phases[1].Discounts.Count == 2 && o.Phases[1].Discounts[0].Coupon == "retention" && o.Phases[1].Discounts[1].Coupon == "grandfather")); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -1023,6 +1165,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( o.Phases[1].Discounts != null && o.Phases[1].Discounts.Count == 1 && o.Phases[1].Discounts[0].Coupon == "retention")); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -1069,6 +1214,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( o.Phases[1].Discounts.Count == 2 && o.Phases[1].Discounts[0].Coupon == "grandfather" && o.Phases[1].Discounts[1].Coupon == "PROACT-25")); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -1114,6 +1262,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( o.Phases[1].Discounts != null && o.Phases[1].Discounts.Count == 1 && o.Phases[1].Discounts[0].Coupon == "grandfather")); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -1158,6 +1309,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( o.Phases[1].Items.Any(i => i.Price == target.PasswordManager.StripeSeatPlanId && i.Quantity == 10) && o.Phases[1].Items.Any(i => i.Price == target.SecretsManager.StripeSeatPlanId && i.Quantity == 4) && o.Phases[1].Items.Any(i => i.Price == target.SecretsManager.StripeServiceAccountPlanId && i.Quantity == 50))); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -1198,6 +1352,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( "sched_1", Arg.Is(o => o.Phases[1].Items.Any(i => i.Price == target.PasswordManager.StripeStoragePlanId && i.Quantity == 3))); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] @@ -1329,6 +1486,9 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( "sched_1", Arg.Any()); await _assignmentRepository.DidNotReceiveWithAnyArgs() .ReplaceAsync(Arg.Any()); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); } [Fact] From f898848655ed3c9ebb5ac18a0da132e4ecc4448f Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Fri, 22 May 2026 09:44:14 -0500 Subject: [PATCH 2/2] Add defensive guard for source-priced single-phase migration schedules --- .../UpdateOrganizationSubscriptionCommand.cs | 42 +++++++++++- ...ateOrganizationSubscriptionCommandTests.cs | 67 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs index ba95c374ecbf..613161fb7af7 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs @@ -140,6 +140,13 @@ public Task> Run( return DefaultConflict; } + if (migrationPhases.Count > 2) + { + _logger.LogWarning( + "{Command}: Schedule ({ScheduleId}) has {PhaseCount} active phases — expected at most 2. Only the first two will be updated.", + CommandName, activeSchedule.Id, migrationPhases.Count); + } + _logger.LogInformation( "{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating {PhaseCount} active phase(s)", CommandName, activeSchedule.Id, subscription.Id, migrationPhases.Count); @@ -337,7 +344,8 @@ private static List BuildUpdatedPhases( Plan sourcePlan, Plan targetPlan) { - var phase1Ended = migrationPhases.Count == 1; + var phase1IsPostMigration = migrationPhases.Count == 1 + && IsPostMigrationPhase(migrationPhases[0], sourcePlan, targetPlan); var phases = new List(); @@ -345,8 +353,8 @@ private static List BuildUpdatedPhases( phases.Add(BuildPhaseOptions( phase1, changes, source: sourcePlan, - target: phase1Ended ? targetPlan : sourcePlan, - suppressDiscounts: phase1Ended)); + target: phase1IsPostMigration ? targetPlan : sourcePlan, + suppressDiscounts: phase1IsPostMigration)); if (migrationPhases.Count >= 2) { @@ -360,6 +368,34 @@ private static List BuildUpdatedPhases( return phases; } + // For non-migrations (source == target), a lone remaining phase always means Stripe has rolled + // past phase 1. For migrations, require the phase to actually use target-plan price IDs — a + // legacy source-priced single-phase schedule (cancelled without releasing) would otherwise have + // its still-valid migration discount wrongly suppressed. + private static bool IsPostMigrationPhase(SubscriptionSchedulePhase phase, Plan source, Plan target) + { + if (ReferenceEquals(source, target)) + { + return true; + } + + var targetIds = new HashSet(StringComparer.Ordinal) + { + target.PasswordManager.StripeSeatPlanId, + target.PasswordManager.StripeStoragePlanId + }; + if (target.SecretsManager?.StripeSeatPlanId is { } smSeat) + { + targetIds.Add(smSeat); + } + if (target.SecretsManager?.StripeServiceAccountPlanId is { } smServiceAccount) + { + targetIds.Add(smServiceAccount); + } + + return phase.Items.Any(item => targetIds.Contains(item.PriceId)); + } + private static SubscriptionSchedulePhaseOptions BuildPhaseOptions( SubscriptionSchedulePhase sourcePhase, IReadOnlyList changes, diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs index 08e71c6c0ca8..bb3ba1c7bab6 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs @@ -1477,6 +1477,73 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( opts.Phases[1].Items.Any(i => i.Price == targetStorage && i.Quantity == 1))); } + [Fact] + public async Task Run_BusinessMigration_SinglePhaseSourcePriced_PreservesPricingAndDiscount() + { + // Legacy scenario: a single source-priced phase remains (e.g. cancellation flow left the + // schedule unreleased). count == 1 alone would mis-classify this as post-migration and + // wrongly translate prices + clear the migration coupon. The IsPostMigrationPhase check + // requires items to actually use target-plan price IDs, so this stays source-priced. + var organization = CreateOrganization(); + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + SetupMigration(organization, + MigrationPathId.Enterprise2020AnnualToCurrent, + PlanType.EnterpriseAnnually2020, source, + PlanType.EnterpriseAnnually, target); + + var sourceSeat = source.PasswordManager.StripeSeatPlanId; + var subscription = CreateSubscription(items: [(sourceSeat, "si_1", 10)]); + SetupGetSubscription(organization, subscription); + + var now = DateTime.UtcNow; + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_123", + SubscriptionId = subscription.Id, + Status = SubscriptionScheduleStatus.Active, + EndBehavior = SubscriptionScheduleEndBehavior.Release, + Phases = + [ + new SubscriptionSchedulePhase + { + StartDate = now.AddDays(-30), + EndDate = now.AddMinutes(-5), + Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_anchor", Quantity = 1 }], + ProrationBehavior = ProrationBehavior.None + }, + new SubscriptionSchedulePhase + { + StartDate = now.AddMinutes(-5), + EndDate = now.AddDays(7), + Items = [new SubscriptionSchedulePhaseItem { PriceId = sourceSeat, Quantity = 10 }], + Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "migration-coupon" }], + ProrationBehavior = ProrationBehavior.None + } + ] + }; + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [schedule] }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity(sourceSeat, 20)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + schedule.Id, + Arg.Is(opts => + opts.Phases.Count == 1 && + opts.Phases[0].Items.Any(i => i.Price == sourceSeat && i.Quantity == 20) && + opts.Phases[0].Discounts != null && + opts.Phases[0].Discounts.Any(d => d.Coupon == "migration-coupon"))); + } + [Fact] public async Task Run_NoMigrationAssignment_DoesNotTranslate() {