Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
44b30a5
style(global): remove byte order mark from files
sbrown-livefront Jun 23, 2026
212b6e0
feat(billing): introduce SeatCountPolicy enum for plan migrations
sbrown-livefront Jun 23, 2026
ca4f540
feat(billing): add SeatCountPolicy to MigrationPath record
sbrown-livefront Jun 23, 2026
a774fb8
feat(billing): define Teams 2019 migration paths with ActualUsage policy
sbrown-livefront Jun 23, 2026
2aea473
feat(billing): implement ActualUsage seat calculation in PriceIncreas…
sbrown-livefront Jun 23, 2026
0392896
feat(billing): update UpcomingInvoiceHandler to support SeatCountPolicy
sbrown-livefront Jun 23, 2026
e67613a
test(billing): add unit tests for Teams 2019 migration seat policies
sbrown-livefront Jun 23, 2026
99c91f6
test(billing): update MigrationPathIds snapshot tests for new paths
sbrown-livefront Jun 23, 2026
d833d5b
feat(billing): consolidate packaged plan seat items to usage-based li…
sbrown-livefront Jun 24, 2026
d37227b
docs(billing): clarify SeatCountPolicy enum and packaged plan migrati…
sbrown-livefront Jun 24, 2026
8c9167a
Merge branch 'main' into billing/pm-37514-support-teams-2019-migration
sbrown-livefront Jun 25, 2026
dfcfaaa
Merge branch 'main' into billing/pm-37514-support-teams-2019-migration
sbrown-livefront Jun 25, 2026
04fe2a2
feat(billing): add update subscription handler logic for plan migrations
sbrown-livefront Jun 26, 2026
4d8a784
fix(billing): run dotnet format
sbrown-livefront Jun 26, 2026
76f0455
feat(billing): introduce plan migration extension methods
sbrown-livefront Jun 26, 2026
6918875
test(billing): add tests for plan migration extensions
sbrown-livefront Jun 26, 2026
c27ba68
refactor(billing): use plan migration extensions in SubscriptionUpdat…
sbrown-livefront Jun 26, 2026
be085ee
refactor(billing): use plan migration extensions in UpcomingInvoiceHa…
sbrown-livefront Jun 26, 2026
5cc6b44
refactor(billing): use plan migration extensions in PriceIncreaseSche…
sbrown-livefront Jun 26, 2026
e3cf03d
Merge branch 'main' into billing/pm-37514-support-teams-2019-migration
sbrown-livefront Jun 26, 2026
93f5e0d
refactor(billing): extract packaged migration source check
sbrown-livefront Jun 26, 2026
4868430
fix(billing): run dotnet format
sbrown-livefront Jun 26, 2026
a6b4375
Update src/Billing/Services/Implementations/SubscriptionUpdatedHandle…
sbrown-livefront Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 15 additions & 9 deletions src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -417,7 +419,8 @@ private async Task<bool> 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)
{
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -499,14 +503,16 @@ private static string FormatCurrency(decimal amount, CultureInfo culture) =>
? amount.ToString("C0", culture)
: amount.ToString("C2", culture);

private async Task<int> ResolveSeatCountAsync(Subscription subscription, Plan sourcePlan, Organization organization)
private async Task<int> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ public enum MigrationPathId : byte
Enterprise2019MonthlyToCurrent = 6,
TeamsStarterToCurrent = 7,
TeamsStarter2023ToCurrent = 8,
Teams2019AnnualToCurrent = 9,
Teams2019MonthlyToCurrent = 10,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ο»Ώnamespace Bit.Core.Billing.Organizations.PlanMigration.Enums;

/// <summary>
/// Determines how a <see cref="ValueObjects.MigrationPath"/> resolves the Phase 2 seat quantity.
/// </summary>
public enum SeatCountPolicy : byte
{
/// <summary>
/// Keep the source subscription's seat line-item quantity unchanged (Scalable-to-Scalable).
/// </summary>
Preserve = 0,

/// <summary>
/// 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
/// </summary>
ActualUsage = 1,
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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 (<see cref="SeatCountPolicy.ActualUsage"/>).
/// </summary>
/// <param name="sourcePlan">The migration source plan.</param>
/// <param name="policy">The migration path's seat-count policy.</param>
public static bool IsPackagedMigrationSource(this Plan sourcePlan, SeatCountPolicy policy) =>
sourcePlan.HasNonSeatBasedPasswordManagerPlan() || policy == SeatCountPolicy.ActualUsage;

/// <summary>
/// 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.
/// </summary>
/// <param name="sourcePlan">The Packaged source plan being migrated.</param>
/// <param name="occupiedSeats">The organization's current occupied seat total.</param>
/// <param name="purchasedSeats">The organization's purchased seat count, billed at or above the base.</param>
/// <exception cref="ArgumentException">The plan is a Scalable source, which must preserve its line-item quantity.</exception>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,15 @@ namespace Bit.Core.Billing.Organizations.PlanMigration.ValueObjects;
/// different path; see <see cref="MigrationPathId"/> and its snapshot tests for the
/// immortality guard.
/// </para>
/// <para>
/// <see cref="SeatCountPolicy"/> defaults to <see cref="Enums.SeatCountPolicy.Preserve"/>; Packaged
/// sources whose line items don't reflect the true seat total set
/// <see cref="Enums.SeatCountPolicy.ActualUsage"/>.
/// </para>
/// </remarks>
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);
Original file line number Diff line number Diff line change
Expand Up @@ -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<MigrationPath> All { get; } =
[
Enterprise2020AnnualToCurrent,
Expand All @@ -70,6 +86,8 @@ public static class MigrationPaths
Enterprise2019MonthlyToCurrent,
TeamsStarterToCurrent,
TeamsStarter2023ToCurrent,
Teams2019AnnualToCurrent,
Teams2019MonthlyToCurrent,
];

/// <summary>
Expand Down
47 changes: 34 additions & 13 deletions src/Core/Billing/Pricing/PriceIncreaseScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<SubscriptionSchedulePhaseItemOptions>();
foreach (var item in subscription.Items.Data)
{
Expand All @@ -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)
});
}

Expand Down Expand Up @@ -608,6 +616,19 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id,
};
}

/// <summary>
/// Resolves the billed Phase 2 seat count for a Packaged source from the organization's actual usage.
/// </summary>
private async Task<int> 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);
}

/// <summary>
/// Coordinates the full organization scheduling flow. Resolves the organization, routes
/// non-business plan types (personal, family, and 2019-era plans) to the personal scheduling
Expand Down
Loading
Loading