diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 9b415de92ee8..617dc40a0f45 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -240,7 +240,7 @@ private Task AlignOrganizationSubscriptionConcernsAsync( { ProductTierType.Families => ScheduleFamiliesPriceMigrationAsync(organization, @event, subscription, plan), - ProductTierType.Teams or ProductTierType.Enterprise => + ProductTierType.Teams or ProductTierType.Enterprise or ProductTierType.TeamsStarter => ScheduleBusinessPlanPriceMigrationAsync(organization, @event, subscription), _ => Task.FromResult(false) }; @@ -456,7 +456,7 @@ private async Task SendBusinessRenewalEmailAsync( } var culture = new CultureInfo("en-US"); - var seats = ResolveSeatCount(subscription, sourcePlan, organization); + var seats = await ResolveSeatCountAsync(subscription, sourcePlan, organization); // SeatPrice is a per-year figure on annual plans and a per-month figure on monthly plans. The per-user // monthly line always shows a monthly rate (annual ÷ 12); the recurring total is quoted in the plan's own @@ -503,8 +503,16 @@ private static string FormatCurrency(decimal amount, CultureInfo culture) => ? amount.ToString("C0", culture) : amount.ToString("C2", culture); - private int ResolveSeatCount(Subscription subscription, Plan sourcePlan, Organization organization) + private async Task ResolveSeatCountAsync(Subscription subscription, Plan sourcePlan, Organization organization) { + // A packaged source has no per-seat line, so the fallback below would quote organization.Seats (the + // bundle cap). Match the billed quantity instead: the org's actual occupied seats, floored at 1. + if (sourcePlan.HasNonSeatBasedPasswordManagerPlan()) + { + var occupied = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + return Math.Max(1, occupied.Total); + } + var seatItem = subscription.Items.Data .FirstOrDefault(item => item.Price?.Id == sourcePlan.PasswordManager.StripeSeatPlanId); diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs index a0dafdd08b62..324403ec1ade 100644 --- a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs @@ -23,4 +23,6 @@ public enum MigrationPathId : byte Teams2020MonthlyToCurrent = 4, Enterprise2019AnnualToCurrent = 5, Enterprise2019MonthlyToCurrent = 6, + TeamsStarterToCurrent = 7, + TeamsStarter2023ToCurrent = 8, } diff --git a/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs index 8d84457e37f2..db922c878a62 100644 --- a/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs +++ b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs @@ -26,6 +26,12 @@ public static string MapOrPassThrough(string sourcePriceId, Plan source, Plan ta private static string? Resolve(string sourcePriceId, Plan source, Plan target) => sourcePriceId switch { + // Packaged -> Scalable PM base price (Teams Starter -> Teams Current): a packaged source holds its + // flat price in StripePlanId, mapped to the target's per-seat price. The IsNullOrEmpty guard keeps a + // null == null match from mis-mapping Scalable sources, whose StripePlanId is null. + _ when !string.IsNullOrEmpty(source.PasswordManager.StripePlanId) && + sourcePriceId == source.PasswordManager.StripePlanId => + target.PasswordManager.StripeSeatPlanId, _ when sourcePriceId == source.PasswordManager.StripeSeatPlanId => target.PasswordManager.StripeSeatPlanId, _ when sourcePriceId == source.PasswordManager.StripeStoragePlanId => diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs index c3362011d781..1337cc749693 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs @@ -48,6 +48,18 @@ public static class MigrationPaths FromPlan: PlanType.EnterpriseMonthly2019, ToPlan: PlanType.EnterpriseMonthly); + public static readonly MigrationPath TeamsStarterToCurrent = new( + Id: MigrationPathId.TeamsStarterToCurrent, + Name: nameof(TeamsStarterToCurrent), + FromPlan: PlanType.TeamsStarter, + ToPlan: PlanType.TeamsMonthly); + + public static readonly MigrationPath TeamsStarter2023ToCurrent = new( + Id: MigrationPathId.TeamsStarter2023ToCurrent, + Name: nameof(TeamsStarter2023ToCurrent), + FromPlan: PlanType.TeamsStarter2023, + ToPlan: PlanType.TeamsMonthly); + public static IReadOnlyList All { get; } = [ Enterprise2020AnnualToCurrent, @@ -56,6 +68,8 @@ public static class MigrationPaths Teams2020MonthlyToCurrent, Enterprise2019AnnualToCurrent, Enterprise2019MonthlyToCurrent, + TeamsStarterToCurrent, + TeamsStarter2023ToCurrent, ]; /// diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 8ccb5c57d4f3..a20d03339805 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -159,7 +159,7 @@ public async Task ScheduleBusinessPriceIncrease( return false; } - var phase2 = await ResolvePhase2ForBusinessAsync(subscription, cohort); + var phase2 = await ResolvePhase2ForBusinessAsync(subscription, cohort, organizationId); if (phase2 is null) { return false; @@ -526,7 +526,8 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, private async Task ResolvePhase2ForBusinessAsync( Subscription subscription, - OrganizationPlanMigrationCohort cohort) + OrganizationPlanMigrationCohort cohort, + Guid organizationId) { // Stripe.NET deserializes an unexpanded "discounts" array as a list of null entries; // proceeding would silently drop pre-existing discounts from Phase 2. @@ -558,6 +559,16 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan); var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan); + // A packaged source (e.g. Teams Starter) carries its flat bundle at quantity ~1, so bill the org's + // actual occupied seats on the per-seat target — applied unconditionally and floored at 1. + var overrideSeatQuantity = + sourcePlan.HasNonSeatBasedPasswordManagerPlan() + ? Math.Max(1, (await organizationRepository + .GetOccupiedSeatCountByOrganizationIdAsync(organizationId)).Total) + : (long?)null; + + var targetSeatPriceId = targetPlan.PasswordManager.StripeSeatPlanId; + var items = new List(); foreach (var item in subscription.Items.Data) { @@ -569,10 +580,16 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, subscription.Id, item.Price.Id, migrationPath.Name); return null; } + + var quantity = + overrideSeatQuantity is { } seats && targetPriceId == targetSeatPriceId + ? seats + : item.Quantity; + items.Add(new SubscriptionSchedulePhaseItemOptions { Price = targetPriceId, - Quantity = item.Quantity + Quantity = quantity }); } @@ -636,7 +653,8 @@ private async Task DispatchOrganizationScheduleAsync( return false; } - if (organization.PlanType.GetProductTier() is not (ProductTierType.Teams or ProductTierType.Enterprise)) + if (organization.PlanType.GetProductTier() is not + (ProductTierType.Teams or ProductTierType.Enterprise or ProductTierType.TeamsStarter)) { return await SchedulePersonalPriceIncrease(subscription); } diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index ce05b0d512cb..7fb525658c5d 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -3948,6 +3948,205 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Assert.NotNull(assignment.MigratedDate); } + // PM-37512: Teams Starter 2023's SM baseline (50) exceeds Teams Current's (20), so the generic grace + // machinery writes grace 30 automatically — no SM-specific production code. + [Fact] + public async Task HandleAsync_BusinessMigration_TeamsStarter2023ToCurrent_WritesGrace30Metadata() + { + // Arrange + var organizationId = Guid.NewGuid(); + var cohortId = Guid.NewGuid(); + var teamsStarter2023 = new TeamsStarterPlan2023(); // source SM base 50 + var teamsMonthly = new TeamsPlan(false); // target SM base 20 + + var previousSubscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Price = new Price { Id = teamsStarter2023.PasswordManager.StripePlanId }, + Plan = new Plan { Id = teamsStarter2023.PasswordManager.StripePlanId } + } + ] + } + }; + + var subscription = new Subscription + { + Id = "sub_grace_ts2023", + ScheduleId = "sub_sched_x", + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Price = new Price { Id = teamsMonthly.PasswordManager.StripeSeatPlanId }, + Plan = new Plan { Id = teamsMonthly.PasswordManager.StripeSeatPlanId } + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } + }; + + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.TeamsStarter2023, + Plan = teamsStarter2023.Name, + Seats = 10, + SmServiceAccounts = 50 + }; + + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + CohortId = cohortId + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter2023).Returns(teamsStarter2023); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthly); + _pricingClient.ListPlans().Returns(MockPlans.Plans); + _cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = "TeamsStarter2023", + MigrationPathId = MigrationPathId.TeamsStarter2023ToCurrent, + IsActive = true + }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — grace of 30 (50 - 20) written, merged with existing metadata, no item change. + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + subscription.Id, + Arg.Is(options => + options.Metadata[MetadataKeys.MigrationGraceServiceAccounts] == "30" && + options.Metadata["organizationId"] == organizationId.ToString() && + options.Items == null)); + + Assert.NotNull(assignment.MigratedDate); + } + + // PM-37512: plain Teams Starter's SM baseline (20) already equals Teams Current's (20), so 20 - 20 = 0: + // no grace metadata is written, but the migration still completes. + [Fact] + public async Task HandleAsync_BusinessMigration_TeamsStarterToCurrent_DoesNotWriteGrace() + { + // Arrange + var organizationId = Guid.NewGuid(); + var cohortId = Guid.NewGuid(); + var teamsStarter = new TeamsStarterPlan(); // source SM base 20 + var teamsMonthly = new TeamsPlan(false); // target SM base 20 + + var previousSubscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Price = new Price { Id = teamsStarter.PasswordManager.StripePlanId }, + Plan = new Plan { Id = teamsStarter.PasswordManager.StripePlanId } + } + ] + } + }; + + var subscription = new Subscription + { + Id = "sub_no_grace_ts", + ScheduleId = "sub_sched_x", + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Price = new Price { Id = teamsMonthly.PasswordManager.StripeSeatPlanId }, + Plan = new Plan { Id = teamsMonthly.PasswordManager.StripeSeatPlanId } + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } + }; + + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.TeamsStarter, + Plan = teamsStarter.Name, + Seats = 10, + SmServiceAccounts = 20 + }; + + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + CohortId = cohortId + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter).Returns(teamsStarter); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthly); + _pricingClient.ListPlans().Returns(MockPlans.Plans); + _cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = "TeamsStarter", + MigrationPathId = MigrationPathId.TeamsStarterToCurrent, + IsActive = true + }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — no grace metadata write; migration still completes. + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + subscription.Id, + Arg.Is(options => + options.Metadata != null && + options.Metadata.ContainsKey(MetadataKeys.MigrationGraceServiceAccounts))); + + Assert.NotNull(assignment.MigratedDate); + } + // Grace is the per-path entitlement constant (50 -> 20 => 30), independent of the org's own account count. [Fact] public async Task HandleAsync_BusinessMigration_TeamsMonthly_OrgAboveBaseline_GraceIsEntitlementBased_NoQuantityForced() diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 896e42006afc..d252a57c1f09 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Billing.Services; using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Mail.Billing.Renewal.BusinessPlanRenewal2020Migration; using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; @@ -4645,34 +4646,127 @@ await _mailService.DidNotReceive().SendInvoiceUpcoming( Arg.Any()); } + // PM-37512: a Teams Starter org now routes to the business-migration path (previously it fell to the + // default arm and got the standard email); the standard email is suppressed once migration is scheduled. [Fact] - public async Task HandleAsync_WhenTeamsStarter_DispatcherReturnsFalse() + public async Task HandleAsync_TeamsStarterOrg_RoutesToBusinessMigration_SchedulesAndSuppressesStandardEmail() { // Arrange + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.TeamsStarter2023); var organization = new Organization { Id = _organizationId, BillingEmail = "org@example.com", - PlanType = PlanType.TeamsStarter2023 + PlanType = PlanType.TeamsStarter2023, + Seats = 10 + }; + var sourcePlan = new TeamsStarterPlan2023(); + var targetPlan = new TeamsPlan(isAnnual: false); + var cohortId = Guid.NewGuid(); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = _organizationId, + CohortId = cohortId, + ScheduledDate = null + }; + var cohort = new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = "teams-starter-2023", + MigrationPathId = MigrationPathId.TeamsStarter2023ToCurrent, + IsActive = true }; - var teamsStarterPlan = new TeamsStarterPlan2023(); _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeAdapter.GetCustomerAsync(customer.Id, Arg.Any()).Returns(customer); _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); - _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter2023).Returns(teamsStarterPlan); + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter2023).Returns(sourcePlan); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(targetPlan); _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohortId).Returns(cohort); + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(_organizationId) + .Returns(new OrganizationSeatCounts { Users = 6 }); + _priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any()) + .Returns(true); // Act await _sut.HandleAsync(parsedEvent); - // Assert - await _assignmentRepository.DidNotReceiveWithAnyArgs() - .GetByOrganizationIdAsync(Arg.Any()); + // Assert — the business path took ownership and the standard upcoming-invoice email is suppressed. + await _priceIncreaseScheduler.Received(1) + .ScheduleForSubscription(subscription, Arg.Any()); + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + // PM-37512: the renewal email must quote the org's occupied seat count, not organization.Seats (the + // bundle cap of 10), and the per-user monthly reflects TeamsMonthly's $5 seat price. + [Fact] + public async Task SendBusinessRenewalEmail_TeamsStarter_QuotesOccupiedSeatCount() + { + // Arrange + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.TeamsStarter2023); + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.TeamsStarter2023, + Seats = 10 + }; + var sourcePlan = new TeamsStarterPlan2023(); + var targetPlan = new TeamsPlan(isAnnual: false); + var cohortId = Guid.NewGuid(); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = _organizationId, + CohortId = cohortId, + ScheduledDate = null + }; + var cohort = new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = "teams-starter-2023", + MigrationPathId = MigrationPathId.TeamsStarter2023ToCurrent, + IsActive = true + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customer.Id, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter2023).Returns(sourcePlan); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(targetPlan); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohortId).Returns(cohort); + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(_organizationId) + .Returns(new OrganizationSeatCounts { Users = 7 }); + _priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any()) + .Returns(true); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — the email quotes 7 occupied seats (not the bundle cap of 10) at the $5 monthly seat price. + await _mailer.Received(1).SendEmail(Arg.Is(mail => + mail.ToEmails.Contains("org@example.com") && + !mail.View.IsAnnual && + mail.View.Seats == 7 && + mail.View.PerUserMonthlyPrice == "$5")); } [Fact] diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs index b2b9626e89b4..fc790ac3a4ed 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs @@ -175,4 +175,77 @@ public void MapOrPassThrough_MappedPriceId_ReturnsTargetSlotValue() Assert.Equal(target.PasswordManager.StripeSeatPlanId, result); } + + // PM-37512: a Packaged Teams Starter source carries its flat base price in StripePlanId + // (StripeSeatPlanId is null). It must map to the Scalable target's per-seat price. + [Fact] + public void MapOrNull_PackagedPmBasePrice_ReturnsTargetSeatPlan() + { + var source = MockPlans.Get(PlanType.TeamsStarter); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.PasswordManager.StripePlanId, source, target); + + Assert.Equal(target.PasswordManager.StripeSeatPlanId, result); + Assert.Equal("2023-teams-org-seat-monthly", result); + } + + [Fact] + public void MapOrNull_PackagedPmBasePrice2023_ReturnsTargetSeatPlan() + { + var source = MockPlans.Get(PlanType.TeamsStarter2023); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.PasswordManager.StripePlanId, source, target); + + Assert.Equal(target.PasswordManager.StripeSeatPlanId, result); + Assert.Equal("2023-teams-org-seat-monthly", result); + } + + // The Packaged->Scalable case is guarded by !IsNullOrEmpty(source.StripePlanId). A Scalable source + // (StripePlanId null) must not let an unknown price match the case via null == null and mis-map to + // the target seat plan; it must still return null. + [Fact] + public void MapOrNull_ScalableSourceUnknownPrice_DoesNotMisfireToSeatPlan() + { + var source = MockPlans.Get(PlanType.EnterpriseAnnually2020); + var target = MockPlans.Get(PlanType.EnterpriseAnnually); + + Assert.Null(source.PasswordManager.StripePlanId); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull("unmapped-price", source, target); + + Assert.Null(result); + } + + // The new base-price case must not steal the storage slot: a packaged source's storage id still + // maps to the target storage id. + [Fact] + public void MapOrNull_PackagedStorageStillMaps() + { + var source = MockPlans.Get(PlanType.TeamsStarter); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.PasswordManager.StripeStoragePlanId, source, target); + + Assert.Equal(target.PasswordManager.StripeStoragePlanId, result); + } + + // The SM service-account slot is unaffected by the new base-price case, and the price ids genuinely + // differ for Teams Starter 2023 (0.50 -> 1.00), so this asserts a real swap, not a pass-through. + [Fact] + public void MapOrNull_PackagedSmServiceAccount_StillMaps() + { + var source = MockPlans.Get(PlanType.TeamsStarter2023); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.SecretsManager.StripeServiceAccountPlanId, source, target); + + Assert.Equal(target.SecretsManager.StripeServiceAccountPlanId, result); + Assert.NotEqual(source.SecretsManager.StripeServiceAccountPlanId, result); + } } diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs index e4b8977e2c80..27f7307649a6 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs @@ -39,6 +39,8 @@ public void MigrationPathId_ByteValues_AreImmutable() Assert.Equal((byte)4, (byte)MigrationPathId.Teams2020MonthlyToCurrent); Assert.Equal((byte)5, (byte)MigrationPathId.Enterprise2019AnnualToCurrent); Assert.Equal((byte)6, (byte)MigrationPathId.Enterprise2019MonthlyToCurrent); + Assert.Equal((byte)7, (byte)MigrationPathId.TeamsStarterToCurrent); + Assert.Equal((byte)8, (byte)MigrationPathId.TeamsStarter2023ToCurrent); } [Fact] @@ -59,6 +61,10 @@ public void MigrationPaths_RegistryEntries_PointAtMatchingIds() MigrationPaths.Enterprise2019AnnualToCurrent.Id); Assert.Equal(MigrationPathId.Enterprise2019MonthlyToCurrent, MigrationPaths.Enterprise2019MonthlyToCurrent.Id); + Assert.Equal(MigrationPathId.TeamsStarterToCurrent, + MigrationPaths.TeamsStarterToCurrent.Id); + Assert.Equal(MigrationPathId.TeamsStarter2023ToCurrent, + MigrationPaths.TeamsStarter2023ToCurrent.Id); } [Fact] @@ -92,6 +98,14 @@ public void MigrationPaths_RegistryEntries_HaveImmutableFromAndToPlans() MigrationPaths.Enterprise2019MonthlyToCurrent.FromPlan); Assert.Equal(PlanType.EnterpriseMonthly, MigrationPaths.Enterprise2019MonthlyToCurrent.ToPlan); + Assert.Equal(PlanType.TeamsStarter, + MigrationPaths.TeamsStarterToCurrent.FromPlan); + Assert.Equal(PlanType.TeamsMonthly, + MigrationPaths.TeamsStarterToCurrent.ToPlan); + Assert.Equal(PlanType.TeamsStarter2023, + MigrationPaths.TeamsStarter2023ToCurrent.FromPlan); + Assert.Equal(PlanType.TeamsMonthly, + MigrationPaths.TeamsStarter2023ToCurrent.ToPlan); } [Fact] @@ -99,7 +113,7 @@ public void MigrationPaths_All_CountIsExpected() { // Guards against accidental removal. Increment when intentionally adding a // new path. - Assert.Equal(6, MigrationPaths.All.Count); + Assert.Equal(8, MigrationPaths.All.Count); } [Fact] diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathTests.cs index 3d61586f5fd1..10d7c86413d2 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathTests.cs @@ -14,6 +14,15 @@ public void FromId_KnownId_ReturnsTheMatchingPath() Assert.Same(MigrationPaths.Enterprise2020AnnualToCurrent, result); } + [Fact] + public void FromId_TeamsStarterIds_ReturnTheMatchingPaths() + { + Assert.Same(MigrationPaths.TeamsStarterToCurrent, + MigrationPaths.FromId(MigrationPaths.TeamsStarterToCurrent.Id)); + Assert.Same(MigrationPaths.TeamsStarter2023ToCurrent, + MigrationPaths.FromId(MigrationPaths.TeamsStarter2023ToCurrent.Id)); + } + [Fact] public void FromId_UnknownId_ReturnsNull() { diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index bfa9b718d5c8..3613d02e73bd 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; @@ -1840,6 +1841,257 @@ await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( o.Phases[1].Items.Any(i => i.Price == target.PasswordManager.StripeSeatPlanId))); } + // PM-37512: the packaged Teams Starter base line (teams-org-starter, qty 1) must swap to the Scalable + // per-seat price at the org's occupied seat count — exercises routing, the mapper case, and the override. + [Fact] + public async Task ScheduleForSubscription_TeamsStarter_RoutesToBusinessPath_CreatesScheduleWithSeatPriceSwap() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.TeamsStarter); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.TeamsStarterToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.TeamsStarter)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(orgId) + .Returns(new OrganizationSeatCounts { Users = 7 }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.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).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items.Any(i => + i.Price == target.PasswordManager.StripeSeatPlanId && i.Quantity == 7))); + } + + [Fact] + public async Task ScheduleForSubscription_TeamsStarter2023_RoutesToBusinessPath_CreatesSchedule() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.TeamsStarter2023); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter2023).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.TeamsStarter2023ToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.TeamsStarter2023)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(orgId) + .Returns(new OrganizationSeatCounts { Users = 4 }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.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).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items.Any(i => + i.Price == target.PasswordManager.StripeSeatPlanId && i.Quantity == 4))); + } + + // An org whose members are all revoked reports 0 occupied seats; Stripe still needs a valid quantity, + // so the override floors at 1. + [Fact] + public async Task ScheduleBusinessPriceIncrease_TeamsStarter_OccupiedSeatsFlooredAtOne() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.TeamsStarter); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.TeamsStarterToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.TeamsStarter)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(orgId) + .Returns(new OrganizationSeatCounts { Users = 0 }); + + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.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).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases[1].Items.Any(i => + i.Price == target.PasswordManager.StripeSeatPlanId && i.Quantity == 1))); + } + + // Teams Starter's override is unconditional — it bills the occupied count even below the bundle cap, + // unlike Teams 2019's <5 rule (PM-37514). Here 3 occupied seats => quantity 3. + [Fact] + public async Task ScheduleBusinessPriceIncrease_TeamsStarter_AppliesOverrideUnconditionally_NotJustBelowThreshold() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.TeamsStarter); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + _pricingClient.GetPlanOrThrow(PlanType.TeamsStarter).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(target); + + var orgId = Guid.NewGuid(); + var cohort = CreateCohort(MigrationPathId.TeamsStarterToCurrent); + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + CohortId = cohort.Id + }; + + _organizationRepository.GetByIdAsync(orgId) + .Returns(CreateOrganization(orgId, PlanType.TeamsStarter)); + _assignmentRepository.GetByOrganizationIdAsync(orgId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohort.Id).Returns(cohort); + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(orgId) + .Returns(new OrganizationSeatCounts { Users = 3 }); + + // Storage line is also present; it must keep its copied quantity (only the seat line is overridden). + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.PasswordManager.StripePlanId, 1), + CreateSubscriptionItem(source.PasswordManager.StripeStoragePlanId, 5)); + + _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[1].Items.Any(i => + i.Price == target.PasswordManager.StripeSeatPlanId && i.Quantity == 3) && + o.Phases[1].Items.Any(i => + i.Price == target.PasswordManager.StripeStoragePlanId && i.Quantity == 5))); + } + + // Boundary contrast: a Scalable source (Enterprise 2020) is not packaged, so the seat override is + // skipped and the copied quantity (10) is kept even though occupied count is stubbed lower. + [Fact] + public async Task ScheduleBusinessPriceIncrease_NonStarterScalable_DoesNotOverrideSeatQuantity() + { + _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); + // Occupied count differs from the copied seat quantity; it must be ignored for Scalable sources. + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(orgId) + .Returns(new OrganizationSeatCounts { Users = 4 }); + + 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); + + Assert.True(result); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases[1].Items.Any(i => + i.Price == target.PasswordManager.StripeSeatPlanId && i.Quantity == 10))); + await _organizationRepository.DidNotReceive().GetOccupiedSeatCountByOrganizationIdAsync(orgId); + } + [Fact] public async Task ScheduleForSubscription_TrackAOrg_NoAssignment_ReturnsFalse() {