Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
23b2951
feat(billing): introduce unified subscription price increase schedule…
sbrown-livefront May 20, 2026
af04330
feat(billing): implement unified subscription price increase schedule…
sbrown-livefront May 20, 2026
18f84c7
refactor(billing): update subscription handlers to use unified scheduler
sbrown-livefront May 20, 2026
67eede5
feat(billing): extend price migration feature flag checks
sbrown-livefront May 20, 2026
2fdfacb
test(billing): add and update tests for unified price increase scheduler
sbrown-livefront May 20, 2026
ff70af0
fix(billing): run dotnet format
sbrown-livefront May 20, 2026
33910be
Merge branch 'main' into billing/pm-37084/business-aware-schedule-rec…
sbrown-livefront May 20, 2026
7d424f9
Merge branch 'main' into billing/pm-37084/business-aware-schedule-rec…
sbrown-livefront May 20, 2026
12c7b8b
Merge branch 'main' into billing/pm-37084/business-aware-schedule-rec…
sbrown-livefront May 20, 2026
b1bb099
feat(billing): expand customer and customer.discount on subscription …
sbrown-livefront May 20, 2026
c4d5f30
refactor(ReinstateSubscriptionCommandTests): rename test method for b…
sbrown-livefront May 20, 2026
18ecb4d
feat(billing): expand customer.discount in update handler
sbrown-livefront May 20, 2026
365f579
test(billing): update test name
sbrown-livefront May 20, 2026
599a5d7
Merge branch 'main' into billing/pm-37084/business-aware-schedule-rec…
sbrown-livefront May 21, 2026
36bd4d2
feat(billing): add test clock waiting mechanism for upcoming invoices
sbrown-livefront May 21, 2026
2cbbac1
feat(billing): introduce cancelling user ID metadata key
sbrown-livefront May 21, 2026
1582b86
feat(billing): store cancelling user ID on subscription cancellation
sbrown-livefront May 21, 2026
157ead7
feat(billing): clear cancelling user ID on subscription reinstatement
sbrown-livefront May 21, 2026
36b3453
test(billing): update subscriber service tests for cancelling user ID
sbrown-livefront May 21, 2026
97a2fb4
Merge branch 'main' into billing/pm-37084/business-aware-schedule-rec…
sbrown-livefront May 21, 2026
68ffb1d
style(SubscriberService): use 'is not null' pattern matching
sbrown-livefront May 21, 2026
59ec480
feat(SubscriberService): add PM35215 migration cohort metadata handling
sbrown-livefront May 21, 2026
cb962e2
feat(SubscriberService): extend price migration deferral to PM35215
sbrown-livefront May 21, 2026
bcf9fe8
test(SubscriberService): add and update tests for PM35215 feature
sbrown-livefront May 21, 2026
3baa358
feat(billing): Introduce OrganizationPriceIncreaseOptions
sbrown-livefront May 22, 2026
66a3e8e
refactor(billing): Centralize price increase eligibility in scheduler
sbrown-livefront May 22, 2026
59d3c27
refactor(billing): Delegate price increase validation from UpcomingIn…
sbrown-livefront May 22, 2026
8cdee4c
feat(billing): Manage price increase schedules during subscription li…
sbrown-livefront May 22, 2026
c9eba37
test(billing): Update UpcomingInvoiceHandlerTests for centralized val…
sbrown-livefront May 22, 2026
53cacd7
test(billing): Add PriceIncreaseScheduler tests for SkipIfAlreadySche…
sbrown-livefront May 22, 2026
64bc9ee
test(billing): Add SubscriberService tests for price increase schedul…
sbrown-livefront May 22, 2026
4840b16
Merge branch 'main' into billing/pm-37084/business-aware-schedule-rec…
sbrown-livefront May 22, 2026
a0f1caa
fix(billing): run dotnet format
sbrown-livefront May 22, 2026
eb5f904
fix(billing): remove redundant customer expansion
sbrown-livefront May 22, 2026
3babcfe
fix(billing): expand discounts for customer and subscription
sbrown-livefront May 22, 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 @@ -83,7 +83,7 @@ public SubscriptionUpdatedHandler(

public async Task HandleAsync(Event parsedEvent)
{
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]);
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer.discount", "discounts", "latest_invoice", "test_clock"]);
SubscriberId subscriberId = subscription;

var subscriber = await GetSubscriberAsync(subscriberId);
Expand Down Expand Up @@ -338,8 +338,6 @@ private async Task SetSubscriptionToCancelAsync(Subscription subscription)

private async Task RemovePendingCancellationAsync(Subscription subscription)
{
await _priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription);

await _stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false,
Expand All @@ -352,6 +350,7 @@ private async Task RemovePendingCancellationAsync(Subscription subscription)
[MetadataKeys.CancellationOrigin] = string.Empty
}
});
await _priceIncreaseScheduler.ScheduleForSubscription(subscription);
Comment thread
sbrown-livefront marked this conversation as resolved.
}

/// <summary>
Expand Down
51 changes: 23 additions & 28 deletions src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using Bit.Core.Repositories;
using Bit.Core.Services;
using Stripe;
using Stripe.TestHelpers;
using Event = Stripe.Event;
using Plan = Bit.Core.Models.StaticStore.Plan;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
Expand Down Expand Up @@ -356,48 +357,29 @@ private async Task<bool> ScheduleBusinessPlanPriceMigrationAsync(
}

var assignment = await assignmentRepository.GetByOrganizationIdAsync(organization.Id);

if (assignment is null || assignment.ScheduledDate is not null)
{
return false;
}

var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId);

if (cohort is null || !cohort.IsActive)
{
return false;
}

if (cohort.MigrationPathId is null)
if (subscription.TestClock != null)
Comment thread
amorask-bitwarden marked this conversation as resolved.
{
// Churn-only cohort β€” no migration to schedule.
return false;
}

var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value);
if (migrationPath is null)
{
logger.LogError(
"Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}) for Organization ({OrganizationId})",
cohort.MigrationPathId, cohort.Id, organization.Id);
return false;
await WaitForTestClockToAdvanceAsync(subscription.TestClock);
}

if (organization.PlanType != migrationPath.FromPlan)
{
logger.LogWarning(
"Skipping business price migration for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}",
organization.Id, organization.PlanType, cohort.Name, migrationPath.FromPlan);
return false;
}
var scheduled = await priceIncreaseScheduler.ScheduleForSubscription(subscription);

var scheduled = await priceIncreaseScheduler.ScheduleBusinessPriceIncrease(subscription, cohort);
if (!scheduled)
{
return true;
}

var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId);
if (cohort?.MigrationPathId is null) return true;

var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value);
if (migrationPath is null) return true;

var sourcePlan = await pricingClient.GetPlanOrThrow(migrationPath.FromPlan);
var targetPlan = await pricingClient.GetPlanOrThrow(migrationPath.ToPlan);

Expand Down Expand Up @@ -428,6 +410,19 @@ private Task SendBusinessRenewalEmailAsync(
return Task.CompletedTask;
}

private async Task WaitForTestClockToAdvanceAsync(TestClock testClock)
{
while (testClock.Status != "ready")
{
await Task.Delay(TimeSpan.FromSeconds(2));
testClock = await stripeAdapter.GetTestClockAsync(testClock.Id);
if (testClock.Status == "internal_failure")
{
throw new Exception("Stripe Test Clock encountered an internal failure");
}
}
}

#endregion

#region Premium Users
Expand Down
1 change: 1 addition & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ public static class MetadataKeys
public const string CancelledDuringDeferredPriceIncrease = "cancelled_during_deferred_price_increase";
public const string MigrationCohortId = "migration_cohort_id";
public const string MigrationCohortName = "migration_cohort_name";
public const string CancellingUserId = "cancellingUserId";
}

public static class CancellationOrigins
Expand Down
15 changes: 15 additions & 0 deletions src/Core/Billing/Pricing/OrganizationPriceIncreaseOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
ο»Ώnamespace Bit.Core.Billing.Pricing;

/// <summary>
/// Controls optional guard behavior when scheduling an organization price increase via
/// <see cref="IPriceIncreaseScheduler.ScheduleForSubscription"/>. Guards are applied
/// during cohort validation before any Stripe calls are made.
/// </summary>
public record OrganizationPriceIncreaseOptions
{
/// <summary>
/// Skip scheduling if a price increase has already been scheduled for this
/// organization (i.e. <c>assignment.ScheduledDate</c> is set).
/// </summary>
public bool SkipIfAlreadyScheduled { get; init; }
}
143 changes: 142 additions & 1 deletion src/Core/Billing/Pricing/PriceIncreaseScheduler.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
ο»Ώusing Bit.Core.Billing.Enums;
ο»Ώ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.Repositories;
using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
Expand Down Expand Up @@ -45,6 +47,19 @@ public interface IPriceIncreaseScheduler
/// <returns>True if a new schedule was created; false if skipped.</returns>
Task<bool> ScheduleBusinessPriceIncrease(Subscription subscription, OrganizationPlanMigrationCohort cohort);

/// <summary>
/// Creates a deferred price-increase schedule for the given subscription,
/// dispatching to the correct path based on the subscription owner.
/// </summary>
/// <param name="subscription">The Stripe subscription to schedule a price increase for.</param>
/// <param name="options">
/// Optional guards applied before scheduling.
/// </param>
/// <returns>True if a new schedule was created; false if skipped.</returns>
Task<bool> ScheduleForSubscription(
Subscription subscription,
OrganizationPriceIncreaseOptions? options = null);

/// <summary>
/// Releases any active subscription schedule for the given subscription, cancelling a pending
/// deferred price increase. Use when the subscription operation makes the scheduled migration
Expand All @@ -61,7 +76,9 @@ public class PriceIncreaseScheduler(
IStripeAdapter stripeAdapter,
IFeatureService featureService,
IPricingClient pricingClient,
IOrganizationRepository organizationRepository,
IOrganizationPlanMigrationCohortAssignmentRepository assignmentRepository,
IOrganizationPlanMigrationCohortRepository cohortRepository,
ILogger<PriceIncreaseScheduler> logger) : IPriceIncreaseScheduler
{
public async Task<bool> SchedulePersonalPriceIncrease(Subscription subscription)
Expand Down Expand Up @@ -169,6 +186,33 @@ public async Task<bool> ScheduleBusinessPriceIncrease(
return true;
}

public async Task<bool> ScheduleForSubscription(
Subscription subscription,
OrganizationPriceIncreaseOptions? options = null)
{
try
{
SubscriberId subscriberId = subscription;
return await subscriberId.Match(
_ => SchedulePersonalPriceIncrease(subscription),
orgId => ScheduleForOrganizationAsync(subscription, orgId.Value, options),
_ =>
{
logger.LogWarning(
"Provider subscriptions do not support schedule recovery ({SubscriptionId})",
subscription.Id);
return Task.FromResult(false);
});
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to resolve subscriber type for subscription ({SubscriptionId}), cannot recover schedule",
subscription.Id);
return false;
}
}

public async Task Release(string customerId, string subscriptionId)
{
try
Expand Down Expand Up @@ -547,4 +591,101 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id,
};
}

/// <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
/// path, then validates cohort eligibility before scheduling.
/// </summary>
private async Task<bool> ScheduleForOrganizationAsync(
Subscription subscription,
Guid organizationId,
OrganizationPriceIncreaseOptions? options)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization is null)
{
logger.LogError(
"Organization ({OrganizationId}) not found; cannot recover schedule for subscription ({SubscriptionId})",
organizationId, subscription.Id);
return false;
}

if (!IsTrackABusinessPlanType(organization.PlanType))
{
return await SchedulePersonalPriceIncrease(subscription);
}

var assignment = await assignmentRepository.GetByOrganizationIdAsync(organization.Id);
if (assignment is null)
{
return false;
}

var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId);
if (cohort is null)
{
return false;
}

if (!IsEligibleForScheduling(organization, assignment, cohort, options))
{
return false;
}

return await ScheduleBusinessPriceIncrease(subscription, cohort);
}

/// <summary>
/// Returns true if the organization is eligible for a business plan price increase.
/// Guards from <paramref name="options"/> are evaluated first against all resolved data;
/// structural eligibility checks follow. Add new option guards here β€”
/// <see cref="ScheduleForOrganizationAsync"/> never needs to change.
/// Does not make any database or Stripe calls.
/// </summary>
private bool IsEligibleForScheduling(
Organization organization,
OrganizationPlanMigrationCohortAssignment assignment,
OrganizationPlanMigrationCohort cohort,
OrganizationPriceIncreaseOptions? options)
{
if (options?.SkipIfAlreadyScheduled == true && assignment.ScheduledDate is not null)
{
return false;
}

if (!cohort.IsActive || cohort.MigrationPathId is null)
{
return false;
}

var migrationPath = MigrationPaths.FromId(cohort.MigrationPathId.Value);
if (migrationPath is null)
{
logger.LogError(
"Unknown MigrationPathId ({MigrationPathId}) on cohort ({CohortId}); skipping schedule for organization ({OrganizationId})",
cohort.MigrationPathId, cohort.Id, organization.Id);
return false;
}

if (organization.PlanType != migrationPath.FromPlan)
{
logger.LogWarning(
"Skipping schedule for Organization ({OrganizationId}); PlanType {ActualPlan} does not match cohort {CohortName} source {ExpectedPlan}",
organization.Id, organization.PlanType, cohort.Name, migrationPath.FromPlan);
return false;
}

return true;
}

/// <summary>
/// Returns true if the plan type is a Track A business plan type.
/// </summary>
/// <remarks>Expand to include additional business plan types as new tracks are added.</remarks>
private static bool IsTrackABusinessPlanType(PlanType planType) => planType is
PlanType.TeamsMonthly2020 or
PlanType.TeamsAnnually2020 or
PlanType.EnterpriseMonthly2020 or
PlanType.EnterpriseAnnually2020;

}
Loading
Loading