Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ private Task<bool> AlignOrganizationSubscriptionConcernsAsync(
{
ProductTierType.Families =>
ScheduleFamiliesPriceMigrationAsync(organization, @event, subscription, plan),
ProductTierType.Teams or ProductTierType.Enterprise =>
ProductTierType.Teams or ProductTierType.Enterprise or ProductTierType.TeamsStarter =>
ScheduleBusinessPlanPriceMigrationAsync(organization, @event, subscription),
_ => Task.FromResult(false)
};
Expand Down Expand Up @@ -456,7 +456,7 @@ private async Task SendBusinessRenewalEmailAsync(
}

var culture = new CultureInfo("en-US");
var seats = ResolveSeatCount(subscription, sourcePlan, organization);
var seats = await ResolveSeatCountAsync(subscription, sourcePlan, organization);

// SeatPrice is a per-year figure on annual plans and a per-month figure on monthly plans. The per-user
// monthly line always shows a monthly rate (annual Γ· 12); the recurring total is quoted in the plan's own
Expand Down Expand Up @@ -503,8 +503,16 @@ private static string FormatCurrency(decimal amount, CultureInfo culture) =>
? amount.ToString("C0", culture)
: amount.ToString("C2", culture);

private int ResolveSeatCount(Subscription subscription, Plan sourcePlan, Organization organization)
private async Task<int> ResolveSeatCountAsync(Subscription subscription, Plan sourcePlan, Organization organization)
{
// A packaged source has no per-seat line, so the fallback below would quote organization.Seats (the
// bundle cap). Match the billed quantity instead: the org's actual occupied seats, floored at 1.
if (sourcePlan.HasNonSeatBasedPasswordManagerPlan())
{
var occupied = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
return Math.Max(1, occupied.Total);
}

var seatItem = subscription.Items.Data
.FirstOrDefault(item => item.Price?.Id == sourcePlan.PasswordManager.StripeSeatPlanId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ public enum MigrationPathId : byte
Teams2020MonthlyToCurrent = 4,
Enterprise2019AnnualToCurrent = 5,
Enterprise2019MonthlyToCurrent = 6,
TeamsStarterToCurrent = 7,
TeamsStarter2023ToCurrent = 8,
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ public static string MapOrPassThrough(string sourcePriceId, Plan source, Plan ta

private static string? Resolve(string sourcePriceId, Plan source, Plan target) => sourcePriceId switch
{
// Packaged -> Scalable PM base price (Teams Starter -> Teams Current): a packaged source holds its
// flat price in StripePlanId, mapped to the target's per-seat price. The IsNullOrEmpty guard keeps a
// null == null match from mis-mapping Scalable sources, whose StripePlanId is null.
_ when !string.IsNullOrEmpty(source.PasswordManager.StripePlanId) &&
sourcePriceId == source.PasswordManager.StripePlanId =>
target.PasswordManager.StripeSeatPlanId,
_ when sourcePriceId == source.PasswordManager.StripeSeatPlanId =>
target.PasswordManager.StripeSeatPlanId,
_ when sourcePriceId == source.PasswordManager.StripeStoragePlanId =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ public static class MigrationPaths
FromPlan: PlanType.EnterpriseMonthly2019,
ToPlan: PlanType.EnterpriseMonthly);

public static readonly MigrationPath TeamsStarterToCurrent = new(
Id: MigrationPathId.TeamsStarterToCurrent,
Name: nameof(TeamsStarterToCurrent),
FromPlan: PlanType.TeamsStarter,
ToPlan: PlanType.TeamsMonthly);

public static readonly MigrationPath TeamsStarter2023ToCurrent = new(
Id: MigrationPathId.TeamsStarter2023ToCurrent,
Name: nameof(TeamsStarter2023ToCurrent),
FromPlan: PlanType.TeamsStarter2023,
ToPlan: PlanType.TeamsMonthly);

public static IReadOnlyList<MigrationPath> All { get; } =
[
Enterprise2020AnnualToCurrent,
Expand All @@ -56,6 +68,8 @@ public static class MigrationPaths
Teams2020MonthlyToCurrent,
Enterprise2019AnnualToCurrent,
Enterprise2019MonthlyToCurrent,
TeamsStarterToCurrent,
TeamsStarter2023ToCurrent,
];

/// <summary>
Expand Down
26 changes: 22 additions & 4 deletions src/Core/Billing/Pricing/PriceIncreaseScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ public async Task<bool> ScheduleBusinessPriceIncrease(
return false;
}

var phase2 = await ResolvePhase2ForBusinessAsync(subscription, cohort);
var phase2 = await ResolvePhase2ForBusinessAsync(subscription, cohort, organizationId);
if (phase2 is null)
{
return false;
Expand Down Expand Up @@ -526,7 +526,8 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id,

private async Task<SubscriptionSchedulePhaseOptions?> ResolvePhase2ForBusinessAsync(
Subscription subscription,
OrganizationPlanMigrationCohort cohort)
OrganizationPlanMigrationCohort cohort,
Guid organizationId)
{
// Stripe.NET deserializes an unexpanded "discounts" array as a list of null entries;
// proceeding would silently drop pre-existing discounts from Phase 2.
Expand Down Expand Up @@ -558,6 +559,16 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id,
var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan);
var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan);

// A packaged source (e.g. Teams Starter) carries its flat bundle at quantity ~1, so bill the org's
// actual occupied seats on the per-seat target β€” applied unconditionally and floored at 1.
var overrideSeatQuantity =
sourcePlan.HasNonSeatBasedPasswordManagerPlan()
? Math.Max(1, (await organizationRepository
.GetOccupiedSeatCountByOrganizationIdAsync(organizationId)).Total)
: (long?)null;

var targetSeatPriceId = targetPlan.PasswordManager.StripeSeatPlanId;

var items = new List<SubscriptionSchedulePhaseItemOptions>();
foreach (var item in subscription.Items.Data)
{
Expand All @@ -569,10 +580,16 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id,
subscription.Id, item.Price.Id, migrationPath.Name);
return null;
}

var quantity =
overrideSeatQuantity is { } seats && targetPriceId == targetSeatPriceId
? seats
: item.Quantity;

items.Add(new SubscriptionSchedulePhaseItemOptions
{
Price = targetPriceId,
Quantity = item.Quantity
Quantity = quantity
});
}

Expand Down Expand Up @@ -636,7 +653,8 @@ private async Task<bool> DispatchOrganizationScheduleAsync(
return false;
}

if (organization.PlanType.GetProductTier() is not (ProductTierType.Teams or ProductTierType.Enterprise))
if (organization.PlanType.GetProductTier() is not
(ProductTierType.Teams or ProductTierType.Enterprise or ProductTierType.TeamsStarter))
{
return await SchedulePersonalPriceIncrease(subscription);
}
Expand Down
199 changes: 199 additions & 0 deletions test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3948,6 +3948,205 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
Assert.NotNull(assignment.MigratedDate);
}

// PM-37512: Teams Starter 2023's SM baseline (50) exceeds Teams Current's (20), so the generic grace
// machinery writes grace 30 automatically β€” no SM-specific production code.
[Fact]
public async Task HandleAsync_BusinessMigration_TeamsStarter2023ToCurrent_WritesGrace30Metadata()
{
// Arrange
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var teamsStarter2023 = new TeamsStarterPlan2023(); // source SM base 50
var teamsMonthly = new TeamsPlan(false); // target SM base 20

var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teamsStarter2023.PasswordManager.StripePlanId },
Plan = new Plan { Id = teamsStarter2023.PasswordManager.StripePlanId }
}
]
}
};

var subscription = new Subscription
{
Id = "sub_grace_ts2023",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teamsMonthly.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teamsMonthly.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};

var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsStarter2023,
Plan = teamsStarter2023.Name,
Seats = 10,
SmServiceAccounts = 50
};

var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
};

var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};

_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.TeamsStarter2023).Returns(teamsStarter2023);
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthly);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "TeamsStarter2023",
MigrationPathId = MigrationPathId.TeamsStarter2023ToCurrent,
IsActive = true
});

// Act
await _sut.HandleAsync(parsedEvent);

// Assert β€” grace of 30 (50 - 20) written, merged with existing metadata, no item change.
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscription.Id,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.Metadata[MetadataKeys.MigrationGraceServiceAccounts] == "30" &&
options.Metadata["organizationId"] == organizationId.ToString() &&
options.Items == null));

Assert.NotNull(assignment.MigratedDate);
}

// PM-37512: plain Teams Starter's SM baseline (20) already equals Teams Current's (20), so 20 - 20 = 0:
// no grace metadata is written, but the migration still completes.
[Fact]
public async Task HandleAsync_BusinessMigration_TeamsStarterToCurrent_DoesNotWriteGrace()
{
// Arrange
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var teamsStarter = new TeamsStarterPlan(); // source SM base 20
var teamsMonthly = new TeamsPlan(false); // target SM base 20

var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teamsStarter.PasswordManager.StripePlanId },
Plan = new Plan { Id = teamsStarter.PasswordManager.StripePlanId }
}
]
}
};

var subscription = new Subscription
{
Id = "sub_no_grace_ts",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teamsMonthly.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teamsMonthly.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};

var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsStarter,
Plan = teamsStarter.Name,
Seats = 10,
SmServiceAccounts = 20
};

var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
};

var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};

_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.TeamsStarter).Returns(teamsStarter);
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthly);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "TeamsStarter",
MigrationPathId = MigrationPathId.TeamsStarterToCurrent,
IsActive = true
});

// Act
await _sut.HandleAsync(parsedEvent);

// Assert β€” no grace metadata write; migration still completes.
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
subscription.Id,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.Metadata != null &&
options.Metadata.ContainsKey(MetadataKeys.MigrationGraceServiceAccounts)));

Assert.NotNull(assignment.MigratedDate);
}

// Grace is the per-path entitlement constant (50 -> 20 => 30), independent of the org's own account count.
[Fact]
public async Task HandleAsync_BusinessMigration_TeamsMonthly_OrgAboveBaseline_GraceIsEntitlementBased_NoQuantityForced()
Expand Down
Loading
Loading