[PM-34565] Save Cancellation Details for Scheduled Subscriptions (#7535)

* refactor(billing): add constant for deferred price increase cancellation

* feat(billing): update cancellation logic to release schedules and set metadata

* feat(billing): update reinstatement logic to recreate schedules

* style(billing): cleanup formatting

* fix(billing) run dotnet format

* docs(billing): clarify stripe subscription update behavior regarding schedules
This commit is contained in:
Stephon Brown
2026-04-28 18:03:41 -04:00
committed by GitHub
parent 329b144003
commit 995ccbbe0c
6 changed files with 58 additions and 567 deletions

View File

@@ -77,3 +77,4 @@ public interface ICouponDeletedHandler : IStripeWebhookHandler;
/// Defines the contract for handling Stripe checkout session completed events.
/// </summary>
public interface ICheckoutSessionCompletedHandler : IStripeWebhookHandler;

View File

@@ -93,6 +93,7 @@ public static class StripeConstants
public const string OriginatingPlatform = "originatingPlatform";
public const string OriginatingAppVersion = "originatingAppVersion";
public const string TrialInitiationPath = "trialInitiationPath";
public const string CancelledDuringDeferredPriceIncrease = "cancelled_during_deferred_price_increase";
}
public static class PaymentBehavior

View File

@@ -261,7 +261,12 @@ public class SubscriberService(
SubscriptionCancellationDetailsOptions? cancellationDetails,
Dictionary<string, string>? cancellingUserMetadata)
{
var updateOptions = new SubscriptionUpdateOptions();
var updateOptions = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true,
CancellationDetails = cancellationDetails,
Metadata = cancellingUserMetadata
};
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
@@ -269,59 +274,20 @@ public class SubscriberService(
if (activeSchedule is { Phases.Count: > 0 })
{
if (activeSchedule.Phases.Count > 2)
{
logger.LogWarning(
"{Service}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1-2), updating to only one phase for cancellation",
GetType().Name, activeSchedule.Id, activeSchedule.Phases.Count);
}
logger.LogInformation(
"{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases",
"{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), releasing schedule before cancellation",
GetType().Name, activeSchedule.Id, subscription.Id);
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var currentPhase = activeSchedule.Phases.FirstOrDefault(p => p.EndDate > now)
?? activeSchedule.Phases[^1];
await stripeAdapter.ReleaseSubscriptionScheduleAsync(activeSchedule.Id);
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
new SubscriptionScheduleUpdateOptions
{
EndBehavior = SubscriptionScheduleEndBehavior.Cancel,
Phases =
[
new SubscriptionSchedulePhaseOptions
{
StartDate = currentPhase.StartDate,
EndDate = currentPhase.EndDate,
Items = currentPhase.Items.Select(i => new SubscriptionSchedulePhaseItemOptions
{
Price = i.PriceId,
Quantity = i.Quantity
}).ToList(),
Discounts = currentPhase.StartDate <= now
? []
: currentPhase.Discounts?.Select(d =>
new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(),
ProrationBehavior = ProrationBehavior.None,
Metadata = cancellingUserMetadata
}
]
});
return;
updateOptions.Metadata = new Dictionary<string, string>(cancellingUserMetadata ?? [])
{
[MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true"
};
}
}
updateOptions.CancelAtPeriodEnd = true;
if (cancellationDetails != null)
{
updateOptions.CancellationDetails = cancellationDetails;
updateOptions.Metadata = cancellingUserMetadata;
}
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions);
}
private async Task<SubscriptionSchedule?> GetActiveScheduleAsync(Subscription subscription)

View File

@@ -41,55 +41,25 @@ public class ReinstateSubscriptionCommand(
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
var activeSchedule = await GetActiveScheduleAsync(subscription);
// if there is an active schedule, we need to update it to include Phase 2 because it was removed during cancellation
if (activeSchedule is { Phases.Count: > 0 })
if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true)
{
if (activeSchedule.Phases.Count > 1)
{
_logger.LogError(
"{Command}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1 after cancellation), updating to add Phase 2",
CommandName, activeSchedule.Id, activeSchedule.Phases.Count);
return DefaultConflict;
}
_logger.LogInformation(
"{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases",
CommandName, activeSchedule.Id, subscription.Id);
"{Command}: Subscription ({SubscriptionId}) has pending price increase, clearing flag and recreating schedule",
CommandName, subscription.Id);
var phase2 = await priceIncreaseScheduler.ResolvePhase2Async(subscription);
if (phase2 == null)
// Clear pending cancellation and flag BEFORE attaching a schedule.
// Stripe discourages direct subscription updates once a schedule is attached as it can create inconsistencies in phases.
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
{
_logger.LogError("Failed to resolve Phase 2 for Subscription {SubscriptionId}", subscription.Id);
return DefaultConflict;
}
var phase1 = activeSchedule.Phases[0];
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
new SubscriptionScheduleUpdateOptions
CancelAtPeriodEnd = false,
Metadata = new Dictionary<string, string>
{
EndBehavior = SubscriptionScheduleEndBehavior.Release,
Phases =
[
new SubscriptionSchedulePhaseOptions
{
StartDate = phase1.StartDate,
EndDate = phase1.EndDate,
Items = phase1.Items.Select(i => new SubscriptionSchedulePhaseItemOptions
{
Price = i.PriceId,
Quantity = i.Quantity
}).ToList(),
Discounts = phase1.Discounts?.Select(d => new SubscriptionSchedulePhaseDiscountOptions
{
Coupon = d.CouponId
}).ToList(),
ProrationBehavior = ProrationBehavior.None
},
phase2
]
});
[MetadataKeys.CancelledDuringDeferredPriceIncrease] = ""
}
});
await priceIncreaseScheduler.Schedule(subscription);
return new None();
}
}
@@ -103,14 +73,4 @@ public class ReinstateSubscriptionCommand(
return new None();
});
private async Task<SubscriptionSchedule?> GetActiveScheduleAsync(Subscription subscription)
{
var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync(
new SubscriptionScheduleListOptions { Customer = subscription.CustomerId });
return schedules.Data.FirstOrDefault(s =>
s.SubscriptionId == subscription.Id &&
s.Status == SubscriptionScheduleStatus.Active);
}
}

View File

@@ -362,7 +362,7 @@ public class SubscriberServiceTests
}
[Theory, BitAutoData]
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_UpdatesScheduleEndBehaviorToCancel(
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_ReleasesScheduleAndSetsCancelAtPeriodEnd(
Organization organization,
SutProvider<SubscriberService> sutProvider)
{
@@ -413,21 +413,20 @@ public class SubscriberServiceTests
await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false);
await stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(scheduleId,
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
o.EndBehavior == StripeConstants.SubscriptionScheduleEndBehavior.Cancel &&
o.Phases.Count == 1 &&
o.Phases[0].Items.Any(i => i.Price == "old-price") &&
o.Phases[0].Metadata == null));
await stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync(scheduleId);
await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(o =>
o.CancelAtPeriodEnd == true &&
o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease)));
await stripeAdapter.DidNotReceiveWithAnyArgs()
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
.UpdateSubscriptionScheduleAsync(Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
await stripeAdapter.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
}
[Theory, BitAutoData]
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_WithSurvey_AlsoUpdatesSubscriptionWithCancellationDetails(
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_WithSurvey_ReleasesScheduleAndUpdatesCancellationDetails(
Organization organization,
SutProvider<SubscriberService> sutProvider)
{
@@ -486,12 +485,17 @@ public class SubscriberServiceTests
await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false, offboardingSurveyResponse);
await stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(scheduleId,
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
o.EndBehavior == StripeConstants.SubscriptionScheduleEndBehavior.Cancel &&
o.Phases.Count == 1 &&
o.Phases[0].Metadata["cancellingUserId"] == userId.ToString()));
await stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync(scheduleId);
await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(o =>
o.CancelAtPeriodEnd == true &&
o.CancellationDetails.Comment == "Too pricey" &&
o.CancellationDetails.Feedback == "too_expensive" &&
o.Metadata["cancellingUserId"] == userId.ToString() &&
o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease)));
await stripeAdapter.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionScheduleAsync(Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
await stripeAdapter.DidNotReceiveWithAnyArgs()
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
}

View File

@@ -1,10 +1,8 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
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;
@@ -82,9 +80,6 @@ public class ReinstateSubscriptionCommandTests
_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);
@@ -95,7 +90,7 @@ public class ReinstateSubscriptionCommandTests
}
[Fact]
public async Task Run_FlagOn_ScheduleExistsWithZeroPhases_FallsThroughToStandardReinstate()
public async Task Run_FlagOn_NoSchedule_CancelledDuringDeferredPriceIncrease_RecreatesScheduleAndClearsFlag()
{
var user = new User { GatewaySubscriptionId = "sub_1" };
@@ -106,460 +101,24 @@ public class ReinstateSubscriptionCommandTests
Status = SubscriptionStatus.Active,
CancelAt = DateTime.UtcNow.AddDays(30),
CustomerId = "cus_1",
Metadata = new Dictionary<string, string> { ["userId"] = user.Id.ToString() },
Metadata = new Dictionary<string, string>
{
["userId"] = user.Id.ToString(),
[MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true"
},
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));
Arg.Is<SubscriptionUpdateOptions>(o =>
o.CancelAtPeriodEnd == false &&
o.Metadata[MetadataKeys.CancelledDuringDeferredPriceIncrease] == ""));
await _priceIncreaseScheduler.Received(1).Schedule(Arg.Any<Subscription>());
}
[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>());
}
}