From 44b30a5cc361a02b72ba448ed790f5bce12f45cb Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:40 -0400 Subject: [PATCH 01/20] style(global): remove byte order mark from files --- src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs | 2 +- .../Organizations/PlanMigration/Enums/MigrationPathId.cs | 2 +- .../PlanMigration/OrganizationPlanMigrationPriceMapper.cs | 2 +- .../Organizations/PlanMigration/ValueObjects/MigrationPaths.cs | 2 +- .../PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 31b508aad985..5502b16e9556 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs index 324403ec1ade..d8bdc2c029ba 100644 --- a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; +namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; /// /// Stable identifier for a supported . diff --git a/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs index db922c878a62..92fb7c5a375a 100644 --- a/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs +++ b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs @@ -1,4 +1,4 @@ -using Plan = Bit.Core.Models.StaticStore.Plan; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Billing.Organizations.PlanMigration; diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs index 1337cc749693..b7b037e750e9 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Enums; namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs index 27f7307649a6..08b6884f7e81 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; using Xunit; From 212b6e0ad645532f7c74f9f6cf4a6112551fae1f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:40 -0400 Subject: [PATCH 02/20] feat(billing): introduce SeatCountPolicy enum for plan migrations --- .../PlanMigration/Enums/SeatCountPolicy.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs 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..269bff1e5178 --- /dev/null +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs @@ -0,0 +1,21 @@ +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. The default for + /// Scalable-to-Scalable migrations where the seat quantity carries over directly. + /// + Preserve = 0, + + /// + /// Resolve the seat quantity from the organization's actual usage rather than the source + /// line items. Used for Packaged plans (e.g. Teams 2019) whose flat base covers a block of + /// seats and whose seat addon only counts seats beyond that base, so the source line items do + /// not reflect the true total seat count on the Scalable target plan. + /// + ActualUsage = 1, +} \ No newline at end of file From ca4f540dbace1d78533a372244139c5cbe6ff3a7 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:40 -0400 Subject: [PATCH 03/20] feat(billing): add SeatCountPolicy to MigrationPath record --- .../PlanMigration/ValueObjects/MigrationPath.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs index c59491dfe87d..79094a32a067 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs @@ -16,5 +16,16 @@ namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; /// different path; see and its snapshot tests for the /// immortality guard. /// +/// +/// defaults to so +/// existing scalable-to-scalable paths keep the source seat quantity. Packaged sources whose +/// seat total cannot be read from the source line items (e.g. Teams 2019) 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); From a774fb8d78e68489e08830b91700867a42794bf0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:40 -0400 Subject: [PATCH 04/20] feat(billing): define Teams 2019 migration paths with ActualUsage policy --- .../Implementations/UpcomingInvoiceHandler.cs | 1 + .../PlanMigration/Enums/MigrationPathId.cs | 2 ++ .../ValueObjects/MigrationPaths.cs | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 5502b16e9556..31afa5ba5975 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -7,6 +7,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; 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; diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs index d8bdc2c029ba..56986915226b 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/ValueObjects/MigrationPaths.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs index b7b037e750e9..8bdf1be9122e 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, ]; /// From 2aea4733b20b5e32c447f93f7b587f3c0476af18 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:40 -0400 Subject: [PATCH 05/20] feat(billing): implement ActualUsage seat calculation in PriceIncreaseScheduler --- .../Billing/Pricing/PriceIncreaseScheduler.cs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 3f26e16ce163..eb67ce6e972f 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,8 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.PlanMigration; 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.Services; @@ -12,6 +13,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; @@ -548,6 +550,11 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan); var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan); + // Calculate the target plan seat count based on the migration path policy. + var targetPlanSeatCount = migrationPath.SeatCountPolicy == SeatCountPolicy.ActualUsage + ? await CalculateTargetPlanSeatCountAsync(organizationId, sourcePlan) + : (int?)null; + // 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. @@ -583,6 +590,20 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, }); } + // Under ActualUsage, a Packaged source's flat base line and seat-overage line both map onto + // the Scalable target's seat price. Collapse them into a single seat line billed at the + // resolved seat count; non-seat items (e.g. storage) pass through untouched. + if (targetPlanSeatCount is { } seatCount) + { + var targetSeatPriceId = targetPlan.PasswordManager.StripeSeatPlanId; + items.RemoveAll(item => item.Price == targetSeatPriceId); + items.Add(new SubscriptionSchedulePhaseItemOptions + { + Price = targetSeatPriceId, + Quantity = seatCount + }); + } + // Merge de-duplicates, so a coupon on both the customer and the subscription isn't double-added. var discounts = (subscription.Customer?.Discount).MergeDiscountCouponIds( subscription.Discounts?.Select(d => d.Coupon.Id), @@ -608,6 +629,19 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, }; } + /// + /// Calculates the seat count for a given organization based on the current seat count and + /// the seat count policy of the migration path. + /// + private async Task CalculateTargetPlanSeatCountAsync(Guid organizationId, OrganizationPlan sourcePlan) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + var occupiedSeatCount = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organizationId)).Total; + var purchasedSeatCount = organization?.Seats; + var targetPlanSeatCount = occupiedSeatCount < sourcePlan.PasswordManager.BaseSeats ? occupiedSeatCount : purchasedSeatCount ?? occupiedSeatCount; + return targetPlanSeatCount; + } + /// /// Coordinates the full organization scheduling flow. Resolves the organization, routes /// non-business plan types (personal, family, and 2019-era plans) to the personal scheduling From 0392896f0ca5597eae32861af4ad7cc39acce1f5 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:40 -0400 Subject: [PATCH 06/20] feat(billing): update UpcomingInvoiceHandler to support SeatCountPolicy --- .../Implementations/UpcomingInvoiceHandler.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 31afa5ba5975..a4cb9175cd06 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -422,7 +422,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) { @@ -442,7 +443,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) @@ -457,7 +459,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 @@ -504,7 +506,8 @@ 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. @@ -514,6 +517,19 @@ private async Task ResolveSeatCountAsync(Subscription subscription, Plan so return Math.Max(1, occupied.Total); } + // ActualUsage (e.g. Teams 2019): a Packaged source's seat line only counts overage beyond the + // base, so the quote must match what the scheduler bills — occupied seats below the base + // (unused headroom disappears), otherwise the purchased seat count. Mirrors + // PriceIncreaseScheduler.CalculateTargetPlanSeatCountAsync. + if (seatCountPolicy == SeatCountPolicy.ActualUsage) + { + var occupied = (await organizationRepository + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)).Total; + return occupied < sourcePlan.PasswordManager.BaseSeats + ? occupied + : organization.Seats ?? occupied; + } + var seatItem = subscription.Items.Data .FirstOrDefault(item => item.Price?.Id == sourcePlan.PasswordManager.StripeSeatPlanId); From e67613aeb1346d3c3e02a22d8fa99a9292b76076 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:41 -0400 Subject: [PATCH 07/20] test(billing): add unit tests for Teams 2019 migration seat policies --- .../Services/UpcomingInvoiceHandlerTests.cs | 60 +++++++++ ...ganizationPlanMigrationPriceMapperTests.cs | 27 ++++ .../Pricing/PriceIncreaseSchedulerTests.cs | 123 ++++++++++++++++++ 3 files changed, 210 insertions(+) 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/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); From 99c91f6eedb2e27366f6d4e9d394c90bb54af63f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 23 Jun 2026 14:58:41 -0400 Subject: [PATCH 08/20] test(billing): update MigrationPathIds snapshot tests for new paths --- .../MigrationPathIdsSnapshotTests.cs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs index 08b6884f7e81..7594d08f11e9 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] From d833d5b5a3749cc323f338f3ecfd05ed79970ed0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 24 Jun 2026 12:49:57 -0400 Subject: [PATCH 09/20] feat(billing): consolidate packaged plan seat items to usage-based line in migrations --- .../Billing/Pricing/PriceIncreaseScheduler.cs | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index eb67ce6e972f..4e2ea7fe018e 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -550,22 +550,15 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan); var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan); - // Calculate the target plan seat count based on the migration path policy. - var targetPlanSeatCount = migrationPath.SeatCountPolicy == SeatCountPolicy.ActualUsage - ? await CalculateTargetPlanSeatCountAsync(organizationId, sourcePlan) - : (int?)null; - - - // 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 isPackagedSource = sourcePlan.HasNonSeatBasedPasswordManagerPlan() || + migrationPath.SeatCountPolicy == SeatCountPolicy.ActualUsage; + var items = new List(); foreach (var item in subscription.Items.Data) { @@ -578,29 +571,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 (isPackagedSource && targetPriceId == targetSeatPriceId) + { + continue; + } items.Add(new SubscriptionSchedulePhaseItemOptions { Price = targetPriceId, - Quantity = quantity + Quantity = item.Quantity }); } - // Under ActualUsage, a Packaged source's flat base line and seat-overage line both map onto - // the Scalable target's seat price. Collapse them into a single seat line billed at the - // resolved seat count; non-seat items (e.g. storage) pass through untouched. - if (targetPlanSeatCount is { } seatCount) + if (isPackagedSource) { - var targetSeatPriceId = targetPlan.PasswordManager.StripeSeatPlanId; - items.RemoveAll(item => item.Price == targetSeatPriceId); items.Add(new SubscriptionSchedulePhaseItemOptions { Price = targetSeatPriceId, - Quantity = seatCount + Quantity = await CalculateTargetPlanSeatCountAsync(sourcePlan, organizationId) }); } @@ -630,16 +619,28 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, } /// - /// Calculates the seat count for a given organization based on the current seat count and - /// the seat count policy of the migration path. + /// Resolves the billed Phase 2 seat count for a Packaged source migrating to a Scalable target. + /// Teams Starter is a flat bundle with no seat overage, so it bills the occupied seat count + /// (floored at 1). Teams 2019 carries overage beyond a packaged base, so it bills occupied seats + /// below the base (unused headroom disappears) and the purchased seat count at or above it. /// - private async Task CalculateTargetPlanSeatCountAsync(Guid organizationId, OrganizationPlan sourcePlan) + private async Task CalculateTargetPlanSeatCountAsync(OrganizationPlan sourcePlan, Guid organizationId) { + var occupiedSeatCount = (await organizationRepository + .GetOccupiedSeatCountByOrganizationIdAsync(organizationId)).Total; + + // Teams Starter: flat bundle, no overage — bill occupied seats (no base/purchased comparison), + // floored at 1. + if (sourcePlan.HasNonSeatBasedPasswordManagerPlan()) + { + return Math.Max(1, occupiedSeatCount); + } + + // Teams 2019: occupied below the packaged base, otherwise the purchased seat count. var organization = await organizationRepository.GetByIdAsync(organizationId); - var occupiedSeatCount = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organizationId)).Total; - var purchasedSeatCount = organization?.Seats; - var targetPlanSeatCount = occupiedSeatCount < sourcePlan.PasswordManager.BaseSeats ? occupiedSeatCount : purchasedSeatCount ?? occupiedSeatCount; - return targetPlanSeatCount; + return occupiedSeatCount < sourcePlan.PasswordManager.BaseSeats + ? occupiedSeatCount + : organization?.Seats ?? occupiedSeatCount; } /// From d37227bf70fcc800484b8983299d4603468e5248 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 24 Jun 2026 12:58:50 -0400 Subject: [PATCH 10/20] docs(billing): clarify SeatCountPolicy enum and packaged plan migration logic comments --- .../PlanMigration/Enums/SeatCountPolicy.cs | 9 +++------ .../PlanMigration/ValueObjects/MigrationPath.cs | 7 +++---- src/Core/Billing/Pricing/PriceIncreaseScheduler.cs | 13 ++++++------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs index 269bff1e5178..85e0b87f3d87 100644 --- a/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs @@ -6,16 +6,13 @@ namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; public enum SeatCountPolicy : byte { /// - /// Keep the source subscription's seat line-item quantity unchanged. The default for - /// Scalable-to-Scalable migrations where the seat quantity carries over directly. + /// 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. Used for Packaged plans (e.g. Teams 2019) whose flat base covers a block of - /// seats and whose seat addon only counts seats beyond that base, so the source line items do - /// not reflect the true total seat count on the Scalable target plan. + /// 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, } \ No newline at end of file diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs index 79094a32a067..785ceadbbf79 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Enums; namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; @@ -17,9 +17,8 @@ namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; /// immortality guard. /// /// -/// defaults to so -/// existing scalable-to-scalable paths keep the source seat quantity. Packaged sources whose -/// seat total cannot be read from the source line items (e.g. Teams 2019) set +/// defaults to ; Packaged +/// sources whose line items don't reflect the true seat total set /// . /// /// diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 4e2ea7fe018e..66a5ffe5b1e4 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -556,7 +556,7 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, // 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 isPackagedSource = sourcePlan.HasNonSeatBasedPasswordManagerPlan() || + var isPackagedSourcePlan = sourcePlan.HasNonSeatBasedPasswordManagerPlan() || migrationPath.SeatCountPolicy == SeatCountPolicy.ActualUsage; var items = new List(); @@ -572,7 +572,7 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, } // Skip the packaged source's seat line(s) here; one collapsed seat line is added below. - if (isPackagedSource && targetPriceId == targetSeatPriceId) + if (isPackagedSourcePlan && targetPriceId == targetSeatPriceId) { continue; } @@ -584,7 +584,7 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, }); } - if (isPackagedSource) + if (isPackagedSourcePlan) { items.Add(new SubscriptionSchedulePhaseItemOptions { @@ -619,10 +619,9 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, } /// - /// Resolves the billed Phase 2 seat count for a Packaged source migrating to a Scalable target. - /// Teams Starter is a flat bundle with no seat overage, so it bills the occupied seat count - /// (floored at 1). Teams 2019 carries overage beyond a packaged base, so it bills occupied seats - /// below the base (unused headroom disappears) and the purchased seat count at or above it. + /// Resolves the billed Phase 2 seat count for a Packaged source. Teams Starter (flat bundle) + /// bills occupied seats, floored at 1; Teams 2019 (seat overage) bills occupied seats below the + /// base and the purchased count at or above it. /// private async Task CalculateTargetPlanSeatCountAsync(OrganizationPlan sourcePlan, Guid organizationId) { From 04fe2a2249b6c3a1fbb6310cdd3ae67048226cab Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 13:01:47 -0400 Subject: [PATCH 11/20] feat(billing): add update subscription handler logic for plan migrations --- .../SubscriptionUpdatedHandler.cs | 18 +++- .../SubscriptionUpdatedHandlerTests.cs | 101 ++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 6a049eba9b81..8dd07b4e6382 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -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.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; using Bit.Core.Billing.Pricing; @@ -608,7 +609,15 @@ 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 isSourcePlanPackaged = sourcePlan.HasNonSeatBasedPasswordManagerPlan() + || migrationPath.SeatCountPolicy == SeatCountPolicy.ActualUsage; + var sourcePriceId = isSourcePlanPackaged + ? sourcePlan.PasswordManager.StripePlanId + : sourcePlan.PasswordManager.StripeSeatPlanId; if (string.IsNullOrEmpty(sourcePriceId)) { _logger.LogWarning( @@ -647,8 +656,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 (isSourcePlanPackaged) { var billedSeatQuantity = subscription.Items .First(item => item.Price?.Id == targetPriceId).Quantity; diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index cdc0fba659f0..0901b55d4ebc 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -4255,6 +4255,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() From 4d8a7844ca55b10014c95cd413cd8afaa05292a6 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 13:21:51 -0400 Subject: [PATCH 12/20] fix(billing): run dotnet format --- .../Services/Implementations/SubscriptionUpdatedHandler.cs | 2 +- .../Services/Implementations/UpcomingInvoiceHandler.cs | 2 +- .../Organizations/PlanMigration/Enums/MigrationPathId.cs | 2 +- .../Organizations/PlanMigration/Enums/SeatCountPolicy.cs | 4 ++-- .../PlanMigration/OrganizationPlanMigrationPriceMapper.cs | 2 +- .../Organizations/PlanMigration/ValueObjects/MigrationPath.cs | 2 +- .../PlanMigration/ValueObjects/MigrationPaths.cs | 2 +- src/Core/Billing/Pricing/PriceIncreaseScheduler.cs | 2 +- .../ValueObjects/MigrationPathIdsSnapshotTests.cs | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 8dd07b4e6382..581982621dd0 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index a4cb9175cd06..ca592351e05c 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs index 56986915226b..d80aef5c1757 100644 --- a/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/MigrationPathId.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; +namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; /// /// Stable identifier for a supported . diff --git a/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs b/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs index 85e0b87f3d87..2cce52e1984f 100644 --- a/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs +++ b/src/Core/Billing/Organizations/PlanMigration/Enums/SeatCountPolicy.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; +namespace Bit.Core.Billing.Organizations.PlanMigration.Enums; /// /// Determines how a resolves the Phase 2 seat quantity. @@ -15,4 +15,4 @@ public enum SeatCountPolicy : byte /// items, whose quantities don't reflect the true total on a Packaged source /// ActualUsage = 1, -} \ No newline at end of file +} diff --git a/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs index 92fb7c5a375a..db922c878a62 100644 --- a/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs +++ b/src/Core/Billing/Organizations/PlanMigration/OrganizationPlanMigrationPriceMapper.cs @@ -1,4 +1,4 @@ -using Plan = Bit.Core.Models.StaticStore.Plan; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Billing.Organizations.PlanMigration; diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs index 785ceadbbf79..b86904d3cb64 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPath.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Enums; namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; diff --git a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs index 8bdf1be9122e..46e201eec541 100644 --- a/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs +++ b/src/Core/Billing/Organizations/PlanMigration/ValueObjects/MigrationPaths.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Enums; namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 66a5ffe5b1e4..c5ddfcf97cbc 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.PlanMigration; diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs index 7594d08f11e9..3c92b55e6b9b 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/ValueObjects/MigrationPathIdsSnapshotTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; using Xunit; From 76f0455e81e146d3361133f6228962d598bd98b1 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 17:18:45 -0400 Subject: [PATCH 13/20] feat(billing): introduce plan migration extension methods --- .../PlanMigration/PlanMigrationExtensions.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs diff --git a/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs b/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs new file mode 100644 index 000000000000..c1b2d32a946d --- /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; + } +} From 69188754a669c6dd093c9fd5cf2f0c83218655bc Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 17:18:45 -0400 Subject: [PATCH 14/20] test(billing): add tests for plan migration extensions --- .../PlanMigrationExtensionsTests.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs 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..03096364c4fb --- /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)); + } +} From c27ba68b38645c35e8b21d831e15db5ec4e0692f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 17:18:45 -0400 Subject: [PATCH 15/20] refactor(billing): use plan migration extensions in SubscriptionUpdatedHandler --- .../Services/Implementations/SubscriptionUpdatedHandler.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 581982621dd0..fc4d10791d8e 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -9,7 +9,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Extensions; -using Bit.Core.Billing.Organizations.PlanMigration.Enums; +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; @@ -613,9 +613,7 @@ private async Task HandleScheduleTriggeredBusinessMigrationAsync( // 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 isSourcePlanPackaged = sourcePlan.HasNonSeatBasedPasswordManagerPlan() - || migrationPath.SeatCountPolicy == SeatCountPolicy.ActualUsage; - var sourcePriceId = isSourcePlanPackaged + var sourcePriceId = sourcePlan.IsPackagedMigrationSource(migrationPath.SeatCountPolicy) ? sourcePlan.PasswordManager.StripePlanId : sourcePlan.PasswordManager.StripeSeatPlanId; if (string.IsNullOrEmpty(sourcePriceId)) From be085ee781d2c0ecfca2da1cdf76f47030569fa2 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 17:18:45 -0400 Subject: [PATCH 16/20] refactor(billing): use plan migration extensions in UpcomingInvoiceHandler --- .../Implementations/UpcomingInvoiceHandler.cs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index ca592351e05c..acdd7e67835a 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -6,6 +6,7 @@ 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; @@ -509,25 +510,13 @@ private static string FormatCurrency(decimal amount, CultureInfo culture) => 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()) - { - var occupied = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - return Math.Max(1, occupied.Total); - } - - // ActualUsage (e.g. Teams 2019): a Packaged source's seat line only counts overage beyond the - // base, so the quote must match what the scheduler bills — occupied seats below the base - // (unused headroom disappears), otherwise the purchased seat count. Mirrors - // PriceIncreaseScheduler.CalculateTargetPlanSeatCountAsync. - if (seatCountPolicy == SeatCountPolicy.ActualUsage) + // 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)).Total; - return occupied < sourcePlan.PasswordManager.BaseSeats - ? occupied - : organization.Seats ?? occupied; + return sourcePlan.ResolveMigratedSeatCount(occupied, organization.Seats); } var seatItem = subscription.Items.Data From 5cc6b4428fefa0feff92456726dda80adca053f4 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 17:18:45 -0400 Subject: [PATCH 17/20] refactor(billing): use plan migration extensions in PriceIncreaseScheduler --- .../Billing/Pricing/PriceIncreaseScheduler.cs | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index c5ddfcf97cbc..1e864233dd97 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -556,8 +556,7 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, // 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.HasNonSeatBasedPasswordManagerPlan() || - migrationPath.SeatCountPolicy == SeatCountPolicy.ActualUsage; + var isPackagedSourcePlan = sourcePlan.IsPackagedMigrationSource(migrationPath.SeatCountPolicy); var items = new List(); foreach (var item in subscription.Items.Data) @@ -619,27 +618,16 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, } /// - /// Resolves the billed Phase 2 seat count for a Packaged source. Teams Starter (flat bundle) - /// bills occupied seats, floored at 1; Teams 2019 (seat overage) bills occupied seats below the - /// base and the purchased count at or above it. + /// 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; - - // Teams Starter: flat bundle, no overage — bill occupied seats (no base/purchased comparison), - // floored at 1. - if (sourcePlan.HasNonSeatBasedPasswordManagerPlan()) - { - return Math.Max(1, occupiedSeatCount); - } - - // Teams 2019: occupied below the packaged base, otherwise the purchased seat count. var organization = await organizationRepository.GetByIdAsync(organizationId); - return occupiedSeatCount < sourcePlan.PasswordManager.BaseSeats - ? occupiedSeatCount - : organization?.Seats ?? occupiedSeatCount; + return sourcePlan.ResolveMigratedSeatCount(occupiedSeatCount, organization?.Seats); } /// From 93f5e0d5a287727b0371bc782c253e9c4e7d90d9 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 17:56:04 -0400 Subject: [PATCH 18/20] refactor(billing): extract packaged migration source check --- .../Services/Implementations/SubscriptionUpdatedHandler.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 5b36c4e4b82a..ca111030c06f 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -595,8 +595,9 @@ private async Task HandleScheduleTriggeredBusinessMigrationAsync( // 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 sourcePriceId = sourcePlan.IsPackagedMigrationSource(migrationPath.SeatCountPolicy) + // source by its per- + var isPackagedSourcePlan = sourcePlan.IsPackagedMigrationSource(migrationPath.SeatCountPolicy); + var sourcePriceId = isPackagedSourcePlan ? sourcePlan.PasswordManager.StripePlanId : sourcePlan.PasswordManager.StripeSeatPlanId; if (string.IsNullOrEmpty(sourcePriceId)) @@ -639,7 +640,7 @@ private async Task HandleScheduleTriggeredBusinessMigrationAsync( // 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 (isSourcePlanPackaged) + if (isPackagedSourcePlan) { var billedSeatQuantity = subscription.Items .First(item => item.Price?.Id == targetPriceId).Quantity; From 4868430ec28d9bc4afc52524495a076936e6a95c Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 17:58:00 -0400 Subject: [PATCH 19/20] fix(billing): run dotnet format --- .../Organizations/PlanMigration/PlanMigrationExtensions.cs | 2 +- src/Core/Billing/Pricing/PriceIncreaseScheduler.cs | 1 - .../Organizations/PlanMigration/PlanMigrationExtensionsTests.cs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs b/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs index c1b2d32a946d..21871aadcc88 100644 --- a/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs +++ b/src/Core/Billing/Organizations/PlanMigration/PlanMigrationExtensions.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Organizations.PlanMigration.Enums; +using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Billing.Organizations.PlanMigration; diff --git a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs index 1e864233dd97..2d3c9160321e 100644 --- a/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs +++ b/src/Core/Billing/Pricing/PriceIncreaseScheduler.cs @@ -3,7 +3,6 @@ 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.Services; diff --git a/test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs b/test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs index 03096364c4fb..3f2493f9ea7d 100644 --- a/test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs +++ b/test/Core.Test/Billing/Organizations/PlanMigration/PlanMigrationExtensionsTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Organizations.PlanMigration; +using Bit.Core.Billing.Organizations.PlanMigration; using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Core.Test.Billing.Mocks.Plans; using Xunit; From a6b4375af266fa37ee778cdcd208685eaf23d27a Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 26 Jun 2026 18:02:25 -0400 Subject: [PATCH 20/20] Update src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../Services/Implementations/SubscriptionUpdatedHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index ca111030c06f..8e770faf60a5 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -595,7 +595,7 @@ private async Task HandleScheduleTriggeredBusinessMigrationAsync( // 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- + // source by its per-seat price. var isPackagedSourcePlan = sourcePlan.IsPackagedMigrationSource(migrationPath.SeatCountPolicy); var sourcePriceId = isPackagedSourcePlan ? sourcePlan.PasswordManager.StripePlanId