mirror of
https://github.com/bitwarden/server.git
synced 2026-06-01 01:55:55 -05:00
[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:
@@ -77,3 +77,4 @@ public interface ICouponDeletedHandler : IStripeWebhookHandler;
|
||||
/// Defines the contract for handling Stripe checkout session completed events.
|
||||
/// </summary>
|
||||
public interface ICheckoutSessionCompletedHandler : IStripeWebhookHandler;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user