diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 65e989e758f7..8e770faf60a5 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -9,6 +9,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Extensions; +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; @@ -591,7 +592,14 @@ private async Task HandleScheduleTriggeredBusinessMigrationAsync( } var sourcePlan = await _pricingClient.GetPlanOrThrow(migrationPath.FromPlan); - var sourcePriceId = GetPasswordManagerPriceId(sourcePlan); + + // A Packaged source (Teams Starter via HasNonSeatBased, Teams 2019 via ActualUsage) is identified + // by its base price, which is present even when a sub-5 org has no seat-overage line; a Scalable + // source by its per-seat price. + var isPackagedSourcePlan = sourcePlan.IsPackagedMigrationSource(migrationPath.SeatCountPolicy); + var sourcePriceId = isPackagedSourcePlan + ? sourcePlan.PasswordManager.StripePlanId + : sourcePlan.PasswordManager.StripeSeatPlanId; if (string.IsNullOrEmpty(sourcePriceId)) { _logger.LogWarning( @@ -630,8 +638,9 @@ private async Task HandleScheduleTriggeredBusinessMigrationAsync( organization.ChangePlan(targetPlan); - // Packaged sources (e.g. Teams Starter) store a flat bundle cap in Seats; reconcile to the billed per-seat quantity. - if (sourcePlan.HasNonSeatBasedPasswordManagerPlan()) + // Packaged source plans (Teams Starter's flat bundle cap, Teams 2019's base seat allotment) store a + // seat count in Seats that doesn't match the billed per-seat quantity; reconcile to what was billed. + if (isPackagedSourcePlan) { var billedSeatQuantity = subscription.Items .First(item => item.Price?.Id == targetPriceId).Quantity; diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 34d33302c3e7..bad6971c5478 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -6,7 +6,9 @@ using Bit.Core.Billing.Constants; 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.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; using Bit.Core.Billing.Payment.Queries; @@ -417,7 +419,8 @@ private async Task ScheduleBusinessPlanPriceMigrationAsync( var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan); var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan); - await SendBusinessRenewalEmailAsync(organization, subscription, sourcePlan, targetPlan, cohort); + await SendBusinessRenewalEmailAsync( + organization, subscription, sourcePlan, targetPlan, cohort, migrationPath.SeatCountPolicy); } catch (Exception exception) { @@ -437,7 +440,8 @@ private async Task SendBusinessRenewalEmailAsync( Subscription subscription, Plan sourcePlan, Plan targetPlan, - OrganizationPlanMigrationCohort cohort) + OrganizationPlanMigrationCohort cohort, + SeatCountPolicy seatCountPolicy) { var renewalDate = subscription.GetCurrentPeriodEnd(); if (renewalDate is null) @@ -452,7 +456,7 @@ private async Task SendBusinessRenewalEmailAsync( } var culture = new CultureInfo("en-US"); - var seats = await ResolveSeatCountAsync(subscription, sourcePlan, organization); + var seats = await ResolveSeatCountAsync(subscription, sourcePlan, organization, seatCountPolicy); // 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 @@ -499,14 +503,16 @@ private static string FormatCurrency(decimal amount, CultureInfo culture) => ? amount.ToString("C0", culture) : amount.ToString("C2", culture); - private async Task ResolveSeatCountAsync(Subscription subscription, Plan sourcePlan, Organization organization) + private async Task ResolveSeatCountAsync( + Subscription subscription, Plan sourcePlan, Organization organization, SeatCountPolicy seatCountPolicy) { - // 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()) + // A Packaged source's line items don't reflect the true seat total, so resolve the quote from + // actual usage to match what the scheduler bills. + if (sourcePlan.IsPackagedMigrationSource(seatCountPolicy)) { - var occupied = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - return Math.Max(1, occupied.Total); + var occupied = (await organizationRepository + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)).Total; + return sourcePlan.ResolveMigratedSeatCount(occupied, organization.Seats); } var seatItem = subscription.Items.Data diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs index 324403ec1ade..d80aef5c1757 100644 --- a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs @@ -25,4 +25,6 @@ public enum MigrationPathId : byte Enterprise2019MonthlyToCurrent = 6, TeamsStarterToCurrent = 7, TeamsStarter2023ToCurrent = 8, + Teams2019AnnualToCurrent = 9, + Teams2019MonthlyToCurrent = 10, } diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs new file mode 100644 index 000000000000..2cce52e1984f --- /dev/null +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; + +/// +/// Determines how a resolves the Phase 2 seat quantity. +/// +public enum SeatCountPolicy : byte +{ + /// + /// Keep the source subscription's seat line-item quantity unchanged (Scalable-to-Scalable). + /// + Preserve = 0, + + /// + /// Resolve the seat quantity from the organization's actual usage rather than the source line + /// items, whose quantities don't reflect the true total on a Packaged source + /// + ActualUsage = 1, +} diff --git a/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs b/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs new file mode 100644 index 000000000000..21871aadcc88 --- /dev/null +++ b/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs @@ -0,0 +1,50 @@ +using Bit.Core.Billing.Organizations.PlanMigration.Enums; +using Plan = Bit.Core.Models.StaticStore.Plan; + +namespace Bit.Core.Billing.Organizations.PlanMigration; + +public static class PlanMigrationExtensions +{ + /// + /// Whether a migration source should be treated as Packaged — a flat bundle, or any source whose + /// line items don't reflect the true seat total (). + /// + /// The migration source plan. + /// The migration path's seat-count policy. + public static bool IsPackagedMigrationSource(this Plan sourcePlan, SeatCountPolicy policy) => + sourcePlan.HasNonSeatBasedPasswordManagerPlan() || policy == SeatCountPolicy.ActualUsage; + + /// + /// Resolves the billed seat count for a Packaged migration source: occupied seats for a flat + /// bundle (floored at 1), or occupied-below-base / purchased-at-or-above-base for seat overage. + /// + /// The Packaged source plan being migrated. + /// The organization's current occupied seat total. + /// The organization's purchased seat count, billed at or above the base. + /// The plan is a Scalable source, which must preserve its line-item quantity. + public static int ResolveMigratedSeatCount(this Plan sourcePlan, int occupiedSeats, int? purchasedSeats) + { + ArgumentNullException.ThrowIfNull(sourcePlan); + ArgumentOutOfRangeException.ThrowIfNegative(occupiedSeats); + + // Flat bundle (e.g. Teams Starter): no per-seat line — bill occupied seats, floored at 1. + if (sourcePlan.HasNonSeatBasedPasswordManagerPlan()) + { + return Math.Max(1, occupiedSeats); + } + + // Seat overage (e.g. Teams 2019) needs a packaged base. A Scalable source has none + // (BaseSeats == 0) and must preserve its line-item quantity, not resolve from usage here. + if (sourcePlan.PasswordManager.BaseSeats <= 0) + { + throw new ArgumentException( + $"{nameof(ResolveMigratedSeatCount)} supports only Packaged sources; " + + $"'{sourcePlan.Name}' has no packaged base and must preserve its line-item quantity.", + nameof(sourcePlan)); + } + + return occupiedSeats < sourcePlan.PasswordManager.BaseSeats + ? occupiedSeats + : purchasedSeats ?? occupiedSeats; + } +} diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs index c59491dfe87d..b86904d3cb64 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs @@ -16,5 +16,15 @@ namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; /// different path; see and its snapshot tests for the /// immortality guard. /// +/// +/// defaults to ; Packaged +/// sources whose line items don't reflect the true seat total set +/// . +/// /// -public sealed record MigrationPath(MigrationPathId Id, string Name, PlanType FromPlan, PlanType ToPlan); +public sealed record MigrationPath( + MigrationPathId Id, + string Name, + PlanType FromPlan, + PlanType ToPlan, + SeatCountPolicy SeatCountPolicy = SeatCountPolicy.Preserve); diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs index 1337cc749693..46e201eec541 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs @@ -60,6 +60,22 @@ public static class MigrationPaths FromPlan: PlanType.TeamsStarter2023, ToPlan: PlanType.TeamsMonthly); + // Teams 2019 is a Packaged base + seat-overage plan migrating to a Scalable plan, so its Phase 2 + // seat quantity is resolved from actual usage rather than preserved from the source line items. + public static readonly MigrationPath Teams2019AnnualToCurrent = new( + Id: MigrationPathId.Teams2019AnnualToCurrent, + Name: nameof(Teams2019AnnualToCurrent), + FromPlan: PlanType.TeamsAnnually2019, + ToPlan: PlanType.TeamsAnnually, + SeatCountPolicy: SeatCountPolicy.ActualUsage); + + public static readonly MigrationPath Teams2019MonthlyToCurrent = new( + Id: MigrationPathId.Teams2019MonthlyToCurrent, + Name: nameof(Teams2019MonthlyToCurrent), + FromPlan: PlanType.TeamsMonthly2019, + ToPlan: PlanType.TeamsMonthly, + SeatCountPolicy: SeatCountPolicy.ActualUsage); + public static IReadOnlyList All { get; } = [ Enterprise2020AnnualToCurrent, @@ -70,6 +86,8 @@ public static class MigrationPaths Enterprise2019MonthlyToCurrent, TeamsStarterToCurrent, TeamsStarter2023ToCurrent, + Teams2019AnnualToCurrent, + Teams2019MonthlyToCurrent, ]; /// diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 3f26e16ce163..2d3c9160321e 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using Stripe; using static Bit.Core.Billing.Constants.StripeConstants; +using OrganizationPlan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Billing.Pricing; @@ -549,16 +550,13 @@ 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; + // Teams Starter and Teams 2019 are Packaged sources whose base line (and, for Teams 2019, the + // seat-overage line) collapse onto the Scalable target's single seat price at a usage-resolved + // quantity. Scalable sources preserve their line-item quantities. + var isPackagedSourcePlan = sourcePlan.IsPackagedMigrationSource(migrationPath.SeatCountPolicy); + var items = new List(); foreach (var item in subscription.Items.Data) { @@ -571,15 +569,25 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, return null; } - var quantity = - overrideSeatQuantity is { } seats && targetPriceId == targetSeatPriceId - ? seats - : item.Quantity; + // Skip the packaged source's seat line(s) here; one collapsed seat line is added below. + if (isPackagedSourcePlan && targetPriceId == targetSeatPriceId) + { + continue; + } items.Add(new SubscriptionSchedulePhaseItemOptions { Price = targetPriceId, - Quantity = quantity + Quantity = item.Quantity + }); + } + + if (isPackagedSourcePlan) + { + items.Add(new SubscriptionSchedulePhaseItemOptions + { + Price = targetSeatPriceId, + Quantity = await CalculateTargetPlanSeatCountAsync(sourcePlan, organizationId) }); } @@ -608,6 +616,19 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, }; } + /// + /// Resolves the billed Phase 2 seat count for a Packaged source from the organization's actual usage. + /// + private async Task CalculateTargetPlanSeatCountAsync(OrganizationPlan sourcePlan, Guid organizationId) + { + // Packaged line items don't reflect the true total, so bill by actual usage. organization.Seats + // supplies the purchased count ResolveMigratedSeatCount uses for the seat-overage case. + var occupiedSeatCount = (await organizationRepository + .GetOccupiedSeatCountByOrganizationIdAsync(organizationId)).Total; + var organization = await organizationRepository.GetByIdAsync(organizationId); + return sourcePlan.ResolveMigratedSeatCount(occupiedSeatCount, organization?.Seats); + } + /// /// Coordinates the full organization scheduling flow. Resolves the organization, routes /// non-business plan types (personal, family, and 2019-era plans) to the personal scheduling diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index cbede597a4c5..e226df23fa41 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -4367,6 +4367,107 @@ await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => Assert.NotNull(assignment.MigratedDate); } + [Theory] + [InlineData(3)] + [InlineData(7)] + public async Task HandleAsync_BusinessMigration_Teams2019MonthlyToCurrent_ReconcilesSeatsToBilledQuantity(long billedSeats) + { + // Arrange + var organizationId = Guid.NewGuid(); + var cohortId = Guid.NewGuid(); + var teams2019 = new Teams2019Plan(false); // packaged source: base + seat-overage prices + var teamsMonthly = new TeamsPlan(false); // per-seat target + + // The pre-migration (Phase 1) subscription carries only the flat base line — a sub-5 org has no + // overage line at all. Detection must find the source by its base price. + var previousSubscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Price = new Price { Id = teams2019.PasswordManager.StripePlanId }, + Plan = new Plan { Id = teams2019.PasswordManager.StripePlanId } + } + ] + } + }; + + var subscription = new Subscription + { + Id = "sub_reconcile_t2019", + ScheduleId = "sub_sched_x", + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Price = new Price { Id = teamsMonthly.PasswordManager.StripeSeatPlanId }, + Plan = new Plan { Id = teamsMonthly.PasswordManager.StripeSeatPlanId }, + Quantity = billedSeats + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle } + }; + + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.TeamsMonthly2019, + Plan = teams2019.Name, + Seats = 5 // base allotment; reconciliation should overwrite this with the billed quantity + }; + + 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.TeamsMonthly2019).Returns(teams2019); + _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 = "Teams2019Monthly", + MigrationPathId = MigrationPathId.Teams2019MonthlyToCurrent, + IsActive = true + }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — handler did not bail (Gap A), plan-shape updated, and Seats reconciled to billed (Gap B). + Assert.Equal((int)billedSeats, organization.Seats); + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == organizationId && + o.PlanType == PlanType.TeamsMonthly && + o.Seats == (int)billedSeats)); + + Assert.NotNull(assignment.MigratedDate); + } + // PM-39562: gate is false for a per-seat source, so Seats stays put even when it differs from the billed quantity. [Fact] public async Task HandleAsync_BusinessMigration_PerSeatSource_LeavesSeatsUnchanged() diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index fb0b5c3cdd29..4d8a034201fe 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -5399,6 +5399,66 @@ await _mailer.Received(1).SendEmail(Arg.Is mail.View.TotalPrice == "$20,736")); } + // PM-37514: a Teams 2019 (ActualUsage) renewal email must quote the same seat count the scheduler + // bills — for a sub-5 org that is the occupied count, NOT organization.Seats (the base allotment) + // and NOT the seat-overage line. 3 occupied of a 5-base org -> the email quotes 3 seats. + [Fact] + public async Task HandleAsync_Teams2019Migration_SubFiveOrg_RenewalEmailQuotesOccupiedSeats() + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.TeamsMonthly2019); + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.TeamsMonthly2019, + Seats = 5 // base allotment; only 3 are occupied + }; + var source = new Teams2019Plan(isAnnual: false); + var target = new TeamsPlan(isAnnual: false); + var cohortId = Guid.NewGuid(); + var cohort = new OrganizationPlanMigrationCohort + { + Id = cohortId, + Name = "teams-2019-monthly", + MigrationPathId = MigrationPathId.Teams2019MonthlyToCurrent, + IsActive = true + }; + var assignment = new OrganizationPlanMigrationCohortAssignment + { + Id = Guid.NewGuid(), + OrganizationId = _organizationId, + CohortId = cohortId, + ScheduledDate = null + }; + + _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); + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(_organizationId) + .Returns(new OrganizationSeatCounts { Users = 3 }); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2019).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(target); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment); + _cohortRepository.GetByIdAsync(cohortId).Returns(cohort); + _priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any()) + .Returns(true); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — quotes occupied (3), not the 5-seat base allotment. + await _mailer.Received(1).SendEmail(Arg.Is(mail => + mail.View.Seats == 3)); + } + [Fact] public async Task HandleAsync_DoesNotRequestCustomerDiscountExpansionDeeperThanStripeAllows() { diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs index fc790ac3a4ed..3ac367fddd36 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapperTests.cs @@ -114,6 +114,33 @@ public void MapOrNull_Enterprise2019AnnualSmServiceAccount_ReturnsTargetSmServic Assert.Equal(target.SecretsManager.StripeServiceAccountPlanId, result); } + // PM-37514: Teams 2019 is a packaged plan — its flat base price lives in StripePlanId. Migrating + // to the pure per-seat current plan, that base price maps onto the target's per-seat price (the + // base + per-seat-overage lines are later collapsed to a single seat line by the scheduler). + [Fact] + public void MapOrNull_Teams2019MonthlyBasePrice_ReturnsTargetPmSeat() + { + var source = MockPlans.Get(PlanType.TeamsMonthly2019); + var target = MockPlans.Get(PlanType.TeamsMonthly); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.PasswordManager.StripePlanId, source, target); + + Assert.Equal(target.PasswordManager.StripeSeatPlanId, result); + } + + [Fact] + public void MapOrNull_Teams2019AnnualBasePrice_ReturnsTargetPmSeat() + { + var source = MockPlans.Get(PlanType.TeamsAnnually2019); + var target = MockPlans.Get(PlanType.TeamsAnnually); + + var result = OrganizationPlanMigrationPriceMapper.MapOrNull( + source.PasswordManager.StripePlanId, source, target); + + Assert.Equal(target.PasswordManager.StripeSeatPlanId, result); + } + [Fact] public void MapOrNull_SmSeatWhenSourceSmIsNull_ReturnsNull() { diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs new file mode 100644 index 000000000000..3f2493f9ea7d --- /dev/null +++ b/test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs @@ -0,0 +1,85 @@ +using Bit.Core.Billing.Organizations.PlanMigration; +using Bit.Core.Billing.Organizations.PlanMigration.Enums; +using Bit.Core.Test.Billing.Mocks.Plans; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.PlanMigration; + +public class PlanMigrationExtensionsTests +{ + [Fact] + public void IsPackagedMigrationSource_FlatBundle_TrueRegardlessOfPolicy() + { + var plan = new TeamsStarterPlan(); + + Assert.True(plan.IsPackagedMigrationSource(SeatCountPolicy.Preserve)); + Assert.True(plan.IsPackagedMigrationSource(SeatCountPolicy.ActualUsage)); + } + + [Fact] + public void IsPackagedMigrationSource_SeatOverage_FollowsPolicy() + { + var plan = new Teams2019Plan(isAnnual: true); + + Assert.True(plan.IsPackagedMigrationSource(SeatCountPolicy.ActualUsage)); + Assert.False(plan.IsPackagedMigrationSource(SeatCountPolicy.Preserve)); + } + + [Fact] + public void IsPackagedMigrationSource_Scalable_FollowsPolicy() + { + // Scalable + ActualUsage is a misconfiguration; the gate reports true here, and + // ResolveMigratedSeatCount throws downstream. Scalable + Preserve is the correct pairing. + var plan = new TeamsPlan(isAnnual: true); + + Assert.False(plan.IsPackagedMigrationSource(SeatCountPolicy.Preserve)); + Assert.True(plan.IsPackagedMigrationSource(SeatCountPolicy.ActualUsage)); + } + + [Theory] + [InlineData(0, 10, 1)] // floored at 1 + [InlineData(7, 10, 7)] // occupied below the bundle, purchased ignored + [InlineData(10, 10, 10)] // full bundle + public void ResolveMigratedSeatCount_FlatBundle_BillsOccupiedFlooredAtOne( + int occupiedSeats, int? purchasedSeats, int expected) + { + var plan = new TeamsStarterPlan(); + + var result = plan.ResolveMigratedSeatCount(occupiedSeats, purchasedSeats); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(3, 20, 3)] // below base -> occupied + [InlineData(5, 20, 20)] // at base -> purchased + [InlineData(8, 20, 20)] // above base -> purchased + [InlineData(8, null, 8)] // above base, no purchased -> occupied + public void ResolveMigratedSeatCount_SeatOverage_BillsOccupiedBelowBaseOtherwisePurchased( + int occupiedSeats, int? purchasedSeats, int expected) + { + var plan = new Teams2019Plan(isAnnual: true); + + var result = plan.ResolveMigratedSeatCount(occupiedSeats, purchasedSeats); + + Assert.Equal(expected, result); + } + + [Fact] + public void ResolveMigratedSeatCount_ScalableSource_Throws() + { + // Scalable: a per-seat line with no packaged base (BaseSeats == 0). It must preserve its + // line-item quantity, so resolving from usage is a misuse. + var plan = new TeamsPlan(isAnnual: true); + + Assert.Throws(() => plan.ResolveMigratedSeatCount(occupiedSeats: 5, purchasedSeats: 5)); + } + + [Fact] + public void ResolveMigratedSeatCount_NegativeOccupiedSeats_Throws() + { + var plan = new Teams2019Plan(isAnnual: true); + + Assert.Throws(() => plan.ResolveMigratedSeatCount(occupiedSeats: -1, purchasedSeats: 20)); + } +} diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs index 27f7307649a6..3c92b55e6b9b 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs @@ -41,6 +41,8 @@ public void MigrationPathId_ByteValues_AreImmutable() Assert.Equal((byte)6, (byte)MigrationPathId.Enterprise2019MonthlyToCurrent); Assert.Equal((byte)7, (byte)MigrationPathId.TeamsStarterToCurrent); Assert.Equal((byte)8, (byte)MigrationPathId.TeamsStarter2023ToCurrent); + Assert.Equal((byte)9, (byte)MigrationPathId.Teams2019AnnualToCurrent); + Assert.Equal((byte)10, (byte)MigrationPathId.Teams2019MonthlyToCurrent); } [Fact] @@ -65,6 +67,10 @@ public void MigrationPaths_RegistryEntries_PointAtMatchingIds() MigrationPaths.TeamsStarterToCurrent.Id); Assert.Equal(MigrationPathId.TeamsStarter2023ToCurrent, MigrationPaths.TeamsStarter2023ToCurrent.Id); + Assert.Equal(MigrationPathId.Teams2019AnnualToCurrent, + MigrationPaths.Teams2019AnnualToCurrent.Id); + Assert.Equal(MigrationPathId.Teams2019MonthlyToCurrent, + MigrationPaths.Teams2019MonthlyToCurrent.Id); } [Fact] @@ -106,6 +112,14 @@ public void MigrationPaths_RegistryEntries_HaveImmutableFromAndToPlans() MigrationPaths.TeamsStarter2023ToCurrent.FromPlan); Assert.Equal(PlanType.TeamsMonthly, MigrationPaths.TeamsStarter2023ToCurrent.ToPlan); + Assert.Equal(PlanType.TeamsAnnually2019, + MigrationPaths.Teams2019AnnualToCurrent.FromPlan); + Assert.Equal(PlanType.TeamsAnnually, + MigrationPaths.Teams2019AnnualToCurrent.ToPlan); + Assert.Equal(PlanType.TeamsMonthly2019, + MigrationPaths.Teams2019MonthlyToCurrent.FromPlan); + Assert.Equal(PlanType.TeamsMonthly, + MigrationPaths.Teams2019MonthlyToCurrent.ToPlan); } [Fact] @@ -113,7 +127,23 @@ public void MigrationPaths_All_CountIsExpected() { // Guards against accidental removal. Increment when intentionally adding a // new path. - Assert.Equal(8, MigrationPaths.All.Count); + Assert.Equal(10, MigrationPaths.All.Count); + } + + [Fact] + public void MigrationPaths_SeatCountPolicy_IsActualUsageForTeams2019AndPreserveOtherwise() + { + // Teams 2019 is a packaged base + per-seat-overage plan migrating to a pure per-seat + // plan; its Phase 2 seat quantity is resolved from actual usage, not preserved from the + // source line items. Every other path preserves the source seat quantity. + Assert.Equal(SeatCountPolicy.ActualUsage, + MigrationPaths.Teams2019AnnualToCurrent.SeatCountPolicy); + Assert.Equal(SeatCountPolicy.ActualUsage, + MigrationPaths.Teams2019MonthlyToCurrent.SeatCountPolicy); + Assert.Equal(SeatCountPolicy.Preserve, + MigrationPaths.Teams2020MonthlyToCurrent.SeatCountPolicy); + Assert.Equal(SeatCountPolicy.Preserve, + MigrationPaths.Enterprise2019AnnualToCurrent.SeatCountPolicy); } [Fact] diff --git a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs index 9bea59e70b7e..43e2e993d3b1 100644 --- a/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs +++ b/test/Core.Test/Billing/Pricing/PriceIncreaseSchedulerTests.cs @@ -2540,6 +2540,129 @@ await _stripeAdapter.Received(1) .CreateSubscriptionScheduleAsync(Arg.Any()); } + // PM-37514: Teams 2019 is a Packaged base + seat-overage plan migrating to a Scalable plan + // (SeatCountPolicy.ActualUsage). Phase 2 bills the org's actual seat count: occupied seats when + // below the Packaged base (unused base headroom disappears), otherwise the purchased seat count + // (organization.Seats). The flat base line and the overage line collapse to one seat line. + [Theory] + [InlineData(3, 5, 3)] // below base (5): bill occupied, unused headroom disappears + [InlineData(5, 5, 5)] // exactly at base: not below, bill purchased + [InlineData(7, 7, 7)] // above base: bill purchased (== occupied here) + [InlineData(6, 8, 8)] // above base, purchased exceeds occupied: bill purchased + public async Task ScheduleBusinessPriceIncrease_Teams2019Monthly_ResolvesSeatQuantityByPolicy( + int occupied, int purchased, long expectedSeats) + { + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.TeamsMonthly2019); + var target = MockPlans.Get(PlanType.TeamsMonthly); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2019).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(target); + + var orgId = Guid.NewGuid(); + var periodStart = DateTime.UtcNow; + var periodLength = TimeSpan.FromDays(30); + + // The flat base line is always present (quantity ~1); the per-seat overage line only exists + // when the org purchased seats beyond the base 5. + var items = new List + { + CreateSubscriptionItem(source.PasswordManager.StripePlanId, 1, periodStart, periodLength) + }; + if (purchased > source.PasswordManager.BaseSeats) + { + items.Add(CreateSubscriptionItem( + source.PasswordManager.StripeSeatPlanId, + purchased - source.PasswordManager.BaseSeats, + periodStart, periodLength)); + } + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, items.ToArray()); + var cohort = CreateCohort(MigrationPathId.Teams2019MonthlyToCurrent); + + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(orgId) + .Returns(new OrganizationSeatCounts { Users = occupied }); + _organizationRepository.GetByIdAsync(orgId) + .Returns(new Organization { Id = orgId, PlanType = PlanType.TeamsMonthly2019, Seats = purchased }); + + _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(); + + var result = await sut.ScheduleBusinessPriceIncrease(subscription, cohort); + + Assert.True(result); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + // exactly one seat line on the target per-seat price, at the resolved quantity + o.Phases[1].Items.Count(i => i.Price == target.PasswordManager.StripeSeatPlanId) == 1 && + o.Phases[1].Items.Single(i => i.Price == target.PasswordManager.StripeSeatPlanId).Quantity == expectedSeats && + // the flat base price never appears on the target + o.Phases[1].Items.All(i => i.Price != source.PasswordManager.StripePlanId))); + } + + [Fact] + public async Task ScheduleBusinessPriceIncrease_Teams2019MonthlyWithStorage_PreservesStorage() + { + const int occupiedSeats = 3; + // The base number of purchased seats is 5 for Teams 2019 + const int purchasedSeats = 5; + const int additionalStorage = 4; + + _featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true); + + var source = MockPlans.Get(PlanType.TeamsMonthly2019); + var target = MockPlans.Get(PlanType.TeamsMonthly); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2019).Returns(source); + _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(target); + + var orgId = Guid.NewGuid(); + var periodStart = DateTime.UtcNow; + var periodLength = TimeSpan.FromDays(30); + var subscription = CreateBusinessSubscription("sub_1", "cus_1", orgId, + CreateSubscriptionItem(source.PasswordManager.StripePlanId, 1, periodStart, periodLength), + CreateSubscriptionItem(source.PasswordManager.StripeStoragePlanId, additionalStorage, periodStart, periodLength)); + var cohort = CreateCohort(MigrationPathId.Teams2019MonthlyToCurrent); + + + _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(orgId) + .Returns(new OrganizationSeatCounts { Users = occupiedSeats }); + _organizationRepository.GetByIdAsync(orgId) + .Returns(new Organization { Id = orgId, PlanType = PlanType.TeamsMonthly2019, Seats = purchasedSeats }); + + _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(); + + var result = await sut.ScheduleBusinessPriceIncrease(subscription, cohort); + + Assert.True(result); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + "sched_1", + Arg.Is(o => + o.Phases[1].Items.Count == 2 && + o.Phases[1].Items.Single(i => i.Price == target.PasswordManager.StripeSeatPlanId).Quantity == occupiedSeats && + o.Phases[1].Items.Any(i => i.Price == target.PasswordManager.StripeStoragePlanId && i.Quantity == additionalStorage))); + } + private static Subscription CreateSubscription(string id, string customerId, params SubscriptionItem[] items) => CreateSubscription(id, customerId, new Dictionary { { "userId", Guid.NewGuid().ToString() } }, items);