Files
server/test/Core.Test/Billing/Subscriptions/Commands/ReinstateSubscriptionCommandTests.cs
Stephon Brown da34581991 [PM-33897] Schedule Aware Cancellation and Reinstatement (#7374)
* feat(pricing): add ResolvePhase2Async to price increase scheduler interface

* feat(pricing): implement ResolvePhase2Async and add unit tests

* feat(subscriber): adjust subscription cancellation for price migration schedules

* feat(reinstate): update subscription reinstatement for price migration schedules

* fix(billing) simplify cancellation data logic

* fix(billing): Cast StartDate to DateTime in price increase tests

* fix(billing): PR feedback

* refactor(test): rename subscription cancellation test

* fix(billing): properly apply cancellation metadata to subscription schedules
2026-04-02 10:04:41 -04:00

566 lines
23 KiB
C#

using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Mocks;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
using static Bit.Core.Billing.Constants.StripeConstants;
namespace Bit.Core.Test.Billing.Subscriptions.Commands;
public class ReinstateSubscriptionCommandTests
{
private readonly IFeatureService _featureService = Substitute.For<IFeatureService>();
private readonly IPriceIncreaseScheduler _priceIncreaseScheduler = Substitute.For<IPriceIncreaseScheduler>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ILogger<ReinstateSubscriptionCommand> _logger = Substitute.For<ILogger<ReinstateSubscriptionCommand>>();
private readonly ReinstateSubscriptionCommand _command;
public ReinstateSubscriptionCommandTests()
{
_command = new ReinstateSubscriptionCommand(_logger, _stripeAdapter, _featureService, _priceIncreaseScheduler);
}
[Fact]
public async Task Run_SubscriptionNotPendingCancellation_ReturnsBadRequest()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
_stripeAdapter.GetSubscriptionAsync("sub_1")
.Returns(new Subscription { Status = SubscriptionStatus.Active, CancelAt = null });
var result = await _command.Run(user);
Assert.True(result.IsT1);
Assert.Equal("Subscription is not pending cancellation.", result.AsT1.Response);
}
[Fact]
public async Task Run_FlagOff_FallsThroughToStandardReinstate_NoScheduleCheck()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
_stripeAdapter.GetSubscriptionAsync("sub_1")
.Returns(new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30)
});
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(false);
var result = await _command.Run(user);
Assert.True(result.IsT0);
await _stripeAdapter.DidNotReceiveWithAnyArgs()
.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>());
await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1",
Arg.Is<SubscriptionUpdateOptions>(o => o.CancelAtPeriodEnd == false));
}
[Fact]
public async Task Run_FlagOn_NoSchedule_FallsThroughToStandardReinstate()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
_stripeAdapter.GetSubscriptionAsync("sub_1")
.Returns(new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["userId"] = user.Id.ToString() },
Items = new StripeList<SubscriptionItem> { Data = [] }
});
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
.Returns(new StripeList<SubscriptionSchedule> { Data = [] });
var result = await _command.Run(user);
Assert.True(result.IsT0);
await _stripeAdapter.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionScheduleAsync(Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1",
Arg.Is<SubscriptionUpdateOptions>(o => o.CancelAtPeriodEnd == false));
}
[Fact]
public async Task Run_FlagOn_ScheduleExistsWithZeroPhases_FallsThroughToStandardReinstate()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
_stripeAdapter.GetSubscriptionAsync("sub_1")
.Returns(new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["userId"] = user.Id.ToString() },
Items = new StripeList<SubscriptionItem> { Data = [] }
});
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
.Returns(new StripeList<SubscriptionSchedule>
{
Data =
[
new SubscriptionSchedule
{
Id = "sched_1",
SubscriptionId = "sub_1",
Status = SubscriptionScheduleStatus.Active,
Phases = []
}
]
});
var result = await _command.Run(user);
Assert.True(result.IsT0);
await _stripeAdapter.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionScheduleAsync(Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_1",
Arg.Is<SubscriptionUpdateOptions>(o => o.CancelAtPeriodEnd == false));
}
[Fact]
public async Task Run_FlagOn_OnePhaseCancelSchedule_NoMigratingPrice_ReturnsConflict()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
_stripeAdapter.GetSubscriptionAsync("sub_1")
.Returns(new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["userId"] = user.Id.ToString() },
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "non-migrating-price" }, Quantity = 1 }]
}
});
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
.Returns(new StripeList<SubscriptionSchedule>
{
Data =
[
new SubscriptionSchedule
{
Id = "sched_1",
SubscriptionId = "sub_1",
Status = SubscriptionScheduleStatus.Active,
Phases = [new SubscriptionSchedulePhase()]
}
]
});
// ResolvePhase2Async returns null (no price migration path found)
_priceIncreaseScheduler.ResolvePhase2Async(Arg.Any<Subscription>()).Returns((SubscriptionSchedulePhaseOptions)null);
var result = await _command.Run(user);
Assert.True(result.IsT2);
var conflict = result.AsT2;
Assert.Equal("We had a problem reinstating your subscription. Please contact support for assistance.", conflict.Response);
}
[Fact]
public async Task Run_FlagOn_OnePhaseCancelSchedule_PremiumMigratingPrice_ReAddsPhase2WithReleaseEndBehavior()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
var scheduleStartDate = DateTime.UtcNow;
var scheduleEndDate = scheduleStartDate.AddYears(1);
var currentPeriodEnd = scheduleEndDate;
var subscription = new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["userId"] = user.Id.ToString() },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = "premium-old-seat" },
Quantity = 1,
CurrentPeriodEnd = currentPeriodEnd
}
]
}
};
_stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
.Returns(new StripeList<SubscriptionSchedule>
{
Data =
[
new SubscriptionSchedule
{
Id = "sched_1",
SubscriptionId = "sub_1",
Status = SubscriptionScheduleStatus.Active,
Phases =
[
new SubscriptionSchedulePhase
{
StartDate = scheduleStartDate,
EndDate = scheduleEndDate,
Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-old-seat", Quantity = 1 }]
}
]
}
]
});
var phase2 = new SubscriptionSchedulePhaseOptions
{
StartDate = currentPeriodEnd,
Items =
[
new SubscriptionSchedulePhaseItemOptions
{
Price = "premium-new-seat",
Quantity = 1
}
],
Discounts = [new SubscriptionSchedulePhaseDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }],
ProrationBehavior = ProrationBehavior.None
};
_priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2);
var result = await _command.Run(user);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1",
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
o.EndBehavior == SubscriptionScheduleEndBehavior.Release &&
o.Phases.Count == 2 &&
o.Phases[0].Items.Any(i => i.Price == "premium-old-seat") &&
o.Phases[1].Items.Any(i => i.Price == "premium-new-seat" && i.Quantity == 1) &&
o.Phases[1].Discounts.Any(d => d.Coupon == CouponIDs.Milestone2SubscriptionDiscount)));
await _stripeAdapter.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task Run_FlagOn_OnePhaseCancelSchedule_Families2019MigratingPrice_ReAddsPhase2WithDiscount()
{
var organization = new Bit.Core.AdminConsole.Entities.Organization { GatewaySubscriptionId = "sub_1" };
var orgId = organization.Id;
var scheduleStartDate = DateTime.UtcNow;
var scheduleEndDate = scheduleStartDate.AddYears(1);
var currentPeriodEnd = scheduleEndDate;
var families2019 = MockPlans.Get(PlanType.FamiliesAnnually2019);
var familiesTarget = MockPlans.Get(PlanType.FamiliesAnnually);
var subscription = new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["organizationId"] = orgId.ToString() },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = families2019.PasswordManager.StripePlanId },
Quantity = 1,
CurrentPeriodEnd = currentPeriodEnd
}
]
}
};
_stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
.Returns(new StripeList<SubscriptionSchedule>
{
Data =
[
new SubscriptionSchedule
{
Id = "sched_1",
SubscriptionId = "sub_1",
Status = SubscriptionScheduleStatus.Active,
Phases =
[
new SubscriptionSchedulePhase
{
StartDate = scheduleStartDate,
EndDate = scheduleEndDate,
Items =
[
new SubscriptionSchedulePhaseItem
{
PriceId = families2019.PasswordManager.StripePlanId,
Quantity = 1
}
]
}
]
}
]
});
var phase2 = new SubscriptionSchedulePhaseOptions
{
StartDate = currentPeriodEnd,
Items =
[
new SubscriptionSchedulePhaseItemOptions
{
Price = familiesTarget.PasswordManager.StripePlanId,
Quantity = 1
}
],
Discounts = [new SubscriptionSchedulePhaseDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }],
ProrationBehavior = ProrationBehavior.None
};
_priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1",
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
o.EndBehavior == SubscriptionScheduleEndBehavior.Release &&
o.Phases.Count == 2 &&
o.Phases[1].Items.Any(i => i.Price == familiesTarget.PasswordManager.StripePlanId && i.Quantity == 1) &&
o.Phases[1].Discounts != null &&
o.Phases[1].Discounts.Any(d => d.Coupon == CouponIDs.Milestone3SubscriptionDiscount)));
}
[Fact]
public async Task Run_FlagOn_OnePhaseCancelSchedule_Families2025MigratingPrice_ReAddsPhase2WithoutDiscount()
{
var organization = new Bit.Core.AdminConsole.Entities.Organization { GatewaySubscriptionId = "sub_1" };
var orgId = organization.Id;
var scheduleStartDate = DateTime.UtcNow;
var scheduleEndDate = scheduleStartDate.AddYears(1);
var currentPeriodEnd = scheduleEndDate;
var families2025 = MockPlans.Get(PlanType.FamiliesAnnually2025);
var familiesTarget = MockPlans.Get(PlanType.FamiliesAnnually);
var subscription = new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["organizationId"] = orgId.ToString() },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = families2025.PasswordManager.StripePlanId },
Quantity = 1,
CurrentPeriodEnd = currentPeriodEnd
}
]
}
};
_stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
.Returns(new StripeList<SubscriptionSchedule>
{
Data =
[
new SubscriptionSchedule
{
Id = "sched_1",
SubscriptionId = "sub_1",
Status = SubscriptionScheduleStatus.Active,
Phases =
[
new SubscriptionSchedulePhase
{
StartDate = scheduleStartDate,
EndDate = scheduleEndDate,
Items =
[
new SubscriptionSchedulePhaseItem
{
PriceId = families2025.PasswordManager.StripePlanId,
Quantity = 1
}
]
}
]
}
]
});
var phase2 = new SubscriptionSchedulePhaseOptions
{
StartDate = currentPeriodEnd,
Items =
[
new SubscriptionSchedulePhaseItemOptions
{
Price = familiesTarget.PasswordManager.StripePlanId,
Quantity = 1
}
],
Discounts = null,
ProrationBehavior = ProrationBehavior.None
};
_priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1",
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
o.EndBehavior == SubscriptionScheduleEndBehavior.Release &&
o.Phases.Count == 2 &&
o.Phases[1].Items.Any(i => i.Price == familiesTarget.PasswordManager.StripePlanId && i.Quantity == 1) &&
o.Phases[1].Discounts == null));
}
[Fact]
public async Task Run_FlagOn_MultipleSchedules_SelectsActiveScheduleMatchingSubscription()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
var scheduleStartDate = DateTime.UtcNow;
var scheduleEndDate = scheduleStartDate.AddYears(1);
var currentPeriodEnd = scheduleEndDate;
var subscription = new Subscription
{
Id = "sub_1",
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["userId"] = user.Id.ToString() },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = "premium-old-seat" },
Quantity = 1,
CurrentPeriodEnd = currentPeriodEnd
}
]
}
};
_stripeAdapter.GetSubscriptionAsync("sub_1").Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
// Return multiple schedules, but only one matches the subscription ID and is active
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
.Returns(new StripeList<SubscriptionSchedule>
{
Data =
[
new SubscriptionSchedule
{
Id = "sched_other_1",
SubscriptionId = "sub_other",
Status = SubscriptionScheduleStatus.Active,
Phases = [new SubscriptionSchedulePhase()]
},
new SubscriptionSchedule
{
Id = "sched_1",
SubscriptionId = "sub_1",
Status = SubscriptionScheduleStatus.Active,
Phases =
[
new SubscriptionSchedulePhase
{
StartDate = scheduleStartDate,
EndDate = scheduleEndDate,
Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-old-seat", Quantity = 1 }]
}
]
},
new SubscriptionSchedule
{
Id = "sched_completed",
SubscriptionId = "sub_1",
Status = SubscriptionScheduleStatus.Completed,
Phases = [new SubscriptionSchedulePhase()]
}
]
});
var phase2 = new SubscriptionSchedulePhaseOptions
{
StartDate = currentPeriodEnd,
Items =
[
new SubscriptionSchedulePhaseItemOptions
{
Price = "premium-new-seat",
Quantity = 1
}
],
Discounts = [new SubscriptionSchedulePhaseDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }],
ProrationBehavior = ProrationBehavior.None
};
_priceIncreaseScheduler.ResolvePhase2Async(subscription).Returns(phase2);
var result = await _command.Run(user);
Assert.True(result.IsT0);
// Should only update the matching active schedule for sub_1
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync("sched_1",
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
o.EndBehavior == SubscriptionScheduleEndBehavior.Release &&
o.Phases.Count == 2 &&
o.Phases[0].Items.Any(i => i.Price == "premium-old-seat") &&
o.Phases[1].Items.Any(i => i.Price == "premium-new-seat" && i.Quantity == 1)));
// Should not update other schedules
await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync("sched_other_1", Arg.Any<SubscriptionScheduleUpdateOptions>());
await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync("sched_completed", Arg.Any<SubscriptionScheduleUpdateOptions>());
}
}