1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 13:23:27 +00:00

[PM-21827] Implement mechanism to suspend currently unpaid providers (#6119)

* Manually suspend provider and set cancel_at when we receive 'suspend_provider' metadata update

* Run dotnet format'
This commit is contained in:
Alex Morask
2025-07-24 11:50:09 -05:00
committed by GitHub
parent 05398ad8a4
commit c503ecbefc
2 changed files with 198 additions and 137 deletions

View File

@@ -1,4 +1,5 @@
using Bit.Billing.Constants; using System.Globalization;
using Bit.Billing.Constants;
using Bit.Billing.Jobs; using Bit.Billing.Jobs;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
@@ -316,7 +317,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private async Task HandleUnpaidProviderSubscriptionAsync( private async Task HandleUnpaidProviderSubscriptionAsync(
Guid providerId, Guid providerId,
Event parsedEvent, Event parsedEvent,
Subscription subscription) Subscription currentSubscription)
{ {
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
@@ -338,26 +339,43 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
if (parsedEvent.Data.PreviousAttributes != null) if (parsedEvent.Data.PreviousAttributes != null)
{ {
if (parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription is var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
{
Status: var updateIsSubscriptionGoingUnpaid = previousSubscription is
StripeSubscriptionStatus.Trialing or
StripeSubscriptionStatus.Active or
StripeSubscriptionStatus.PastDue
} && subscription is
{
Status: StripeSubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
})
{ {
if (subscription.TestClock != null) Status:
StripeSubscriptionStatus.Trialing or
StripeSubscriptionStatus.Active or
StripeSubscriptionStatus.PastDue
} && currentSubscription is
{
Status: StripeSubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
};
var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata(
previousSubscription, currentSubscription);
if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata)
{
if (currentSubscription.TestClock != null)
{ {
await WaitForTestClockToAdvanceAsync(subscription.TestClock); await WaitForTestClockToAdvanceAsync(currentSubscription.TestClock);
} }
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; var now = currentSubscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
await _stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) }); var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
if (updateIsManualSuspensionViaMetadata)
{
subscriptionUpdateOptions.Metadata = new Dictionary<string, string>
{
["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)
};
}
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
} }
} }
} }
@@ -379,4 +397,37 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
} }
} }
} }
private static bool CheckForManualSuspensionViaMetadata(
Subscription? previousSubscription,
Subscription currentSubscription)
{
/*
* When metadata on a subscription is updated, we'll receive an event that has:
* Previous Metadata: { newlyAddedKey: null }
* Current Metadata: { newlyAddedKey: newlyAddedValue }
*
* As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the
* 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null.
*
* If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue',
* we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update
* that does not update the metadata) the same as a manual suspension.
*/
const string key = "suspend_provider";
if (previousSubscription is not { Metadata: not null } ||
!previousSubscription.Metadata.TryGetValue(key, out var previousValue))
{
return false;
}
if (previousValue == null)
{
return !string.IsNullOrEmpty(
currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null);
}
return false;
}
} }

View File

@@ -20,7 +20,6 @@ using NSubstitute;
using NSubstitute.ReturnsExtensions; using NSubstitute.ReturnsExtensions;
using Quartz; using Quartz;
using Stripe; using Stripe;
using Stripe.TestHelpers;
using Xunit; using Xunit;
using Event = Stripe.Event; using Event = Stripe.Event;
@@ -36,14 +35,12 @@ public class SubscriptionUpdatedHandlerTests
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly ISchedulerFactory _schedulerFactory;
private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly ILogger<SubscriptionUpdatedHandler> _logger;
private readonly IScheduler _scheduler; private readonly IScheduler _scheduler;
private readonly SubscriptionUpdatedHandler _sut; private readonly SubscriptionUpdatedHandler _sut;
@@ -58,18 +55,17 @@ public class SubscriptionUpdatedHandlerTests
_providerService = Substitute.For<IProviderService>(); _providerService = Substitute.For<IProviderService>();
_pushNotificationService = Substitute.For<IPushNotificationService>(); _pushNotificationService = Substitute.For<IPushNotificationService>();
_organizationRepository = Substitute.For<IOrganizationRepository>(); _organizationRepository = Substitute.For<IOrganizationRepository>();
_providerRepository = Substitute.For<IProviderRepository>(); var schedulerFactory = Substitute.For<ISchedulerFactory>();
_schedulerFactory = Substitute.For<ISchedulerFactory>();
_organizationEnableCommand = Substitute.For<IOrganizationEnableCommand>(); _organizationEnableCommand = Substitute.For<IOrganizationEnableCommand>();
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>(); _organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
_pricingClient = Substitute.For<IPricingClient>(); _pricingClient = Substitute.For<IPricingClient>();
_featureService = Substitute.For<IFeatureService>(); _featureService = Substitute.For<IFeatureService>();
_providerRepository = Substitute.For<IProviderRepository>(); _providerRepository = Substitute.For<IProviderRepository>();
_providerService = Substitute.For<IProviderService>(); _providerService = Substitute.For<IProviderService>();
_logger = Substitute.For<ILogger<SubscriptionUpdatedHandler>>(); var logger = Substitute.For<ILogger<SubscriptionUpdatedHandler>>();
_scheduler = Substitute.For<IScheduler>(); _scheduler = Substitute.For<IScheduler>();
_schedulerFactory.GetScheduler().Returns(_scheduler); schedulerFactory.GetScheduler().Returns(_scheduler);
_sut = new SubscriptionUpdatedHandler( _sut = new SubscriptionUpdatedHandler(
_stripeEventService, _stripeEventService,
@@ -80,14 +76,14 @@ public class SubscriptionUpdatedHandlerTests
_userService, _userService,
_pushNotificationService, _pushNotificationService,
_organizationRepository, _organizationRepository,
_schedulerFactory, schedulerFactory,
_organizationEnableCommand, _organizationEnableCommand,
_organizationDisableCommand, _organizationDisableCommand,
_pricingClient, _pricingClient,
_featureService, _featureService,
_providerRepository, _providerRepository,
_providerService, _providerService,
_logger); logger);
} }
[Fact] [Fact]
@@ -126,61 +122,54 @@ public class SubscriptionUpdatedHandlerTests
} }
[Fact] [Fact]
public async Task HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSchedulesCancellation() public async Task
HandleAsync_UnpaidProviderSubscription_WithManualSuspensionViaMetadata_DisablesProviderAndSchedulesCancellation()
{ {
// Arrange // Arrange
var providerId = Guid.NewGuid(); var providerId = Guid.NewGuid();
const string subscriptionId = "sub_123"; var subscriptionId = "sub_test123";
var frozenTime = DateTime.UtcNow;
var testClock = new TestClock var previousSubscription = new Subscription
{ {
Id = "clock_123", Id = subscriptionId,
Status = "ready", Status = StripeSubscriptionStatus.Active,
FrozenTime = frozenTime Metadata = new Dictionary<string, string>
{
["suspend_provider"] = null // This is the key part - metadata exists, but value is null
}
}; };
var subscription = new Subscription var currentSubscription = new Subscription
{ {
Id = subscriptionId, Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid, Status = StripeSubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }, CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }, Metadata = new Dictionary<string, string>
TestClock = testClock {
}; ["providerId"] = providerId.ToString(),
["suspend_provider"] = "true" // Now has a value, indicating manual suspension
var provider = new Provider },
{ TestClock = null
Id = providerId,
Name = "Test Provider",
Enabled = true
}; };
var parsedEvent = new Event var parsedEvent = new Event
{ {
Id = "evt_test123",
Type = HandledStripeWebhook.SubscriptionUpdated,
Data = new EventData Data = new EventData
{ {
PreviousAttributes = JObject.FromObject(new Object = currentSubscription,
{ PreviousAttributes = JObject.FromObject(previousSubscription)
status = "active"
})
} }
}; };
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>()) var provider = new Provider { Id = providerId, Enabled = true };
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>()) _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId)); .Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns(provider);
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
_stripeFacade.GetTestClock(testClock.Id)
.Returns(testClock);
// Act // Act
await _sut.HandleAsync(parsedEvent); await _sut.HandleAsync(parsedEvent);
@@ -188,8 +177,75 @@ public class SubscriptionUpdatedHandlerTests
// Assert // Assert
Assert.False(provider.Enabled); Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider); await _providerService.Received(1).UpdateAsync(provider);
await _stripeFacade.Received(1).UpdateSubscription(subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(o => o.CancelAt == frozenTime.AddDays(7))); // Verify that UpdateSubscription was called with both CancelAt and the new metadata
await _stripeFacade.Received(1).UpdateSubscription(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.Metadata != null &&
options.Metadata.ContainsKey("suspended_provider_via_webhook_at")));
}
[Fact]
public async Task
HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSchedulesCancellation()
{
// Arrange
var providerId = Guid.NewGuid();
var subscriptionId = "sub_test123";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Active,
Metadata = new Dictionary<string, string> { ["providerId"] = providerId.ToString() }
};
var currentSubscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Metadata = new Dictionary<string, string> { ["providerId"] = providerId.ToString() },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" },
TestClock = null
};
var parsedEvent = new Event
{
Id = "evt_test123",
Type = HandledStripeWebhook.SubscriptionUpdated,
Data = new EventData
{
Object = currentSubscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
var provider = new Provider { Id = providerId, Enabled = true };
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
// Verify that UpdateSubscription was called with CancelAt but WITHOUT suspension metadata
await _stripeFacade.Received(1).UpdateSubscription(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
(options.Metadata == null || !options.Metadata.ContainsKey("suspended_provider_via_webhook_at"))));
} }
[Fact] [Fact]
@@ -207,12 +263,7 @@ public class SubscriptionUpdatedHandlerTests
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
}; };
var provider = new Provider var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true };
{
Id = providerId,
Name = "Test Provider",
Enabled = true
};
var parsedEvent = new Event var parsedEvent = new Event
{ {
@@ -220,7 +271,7 @@ public class SubscriptionUpdatedHandlerTests
{ {
PreviousAttributes = JObject.FromObject(new PreviousAttributes = JObject.FromObject(new
{ {
status = "unpaid" // No valid transition status = "unpaid" // No valid transition
}) })
} }
}; };
@@ -261,20 +312,9 @@ public class SubscriptionUpdatedHandlerTests
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
}; };
var provider = new Provider var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true };
{
Id = providerId,
Name = "Test Provider",
Enabled = true
};
var parsedEvent = new Event var parsedEvent = new Event { Data = new EventData { PreviousAttributes = null } };
{
Data = new EventData
{
PreviousAttributes = null
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>()) _stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription); .Returns(subscription);
@@ -314,12 +354,7 @@ public class SubscriptionUpdatedHandlerTests
LatestInvoice = new Invoice { BillingReason = "renewal" } LatestInvoice = new Invoice { BillingReason = "renewal" }
}; };
var provider = new Provider var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true };
{
Id = providerId,
Name = "Test Provider",
Enabled = true
};
var parsedEvent = new Event { Data = new EventData() }; var parsedEvent = new Event { Data = new EventData() };
@@ -434,10 +469,10 @@ public class SubscriptionUpdatedHandlerTests
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }, Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem> Items = new StripeList<SubscriptionItem>
{ {
Data = new List<SubscriptionItem> Data =
{ [
new() { Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } } new SubscriptionItem { Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } }
} ]
} }
}; };
@@ -478,11 +513,7 @@ public class SubscriptionUpdatedHandlerTests
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } } Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
}; };
var organization = new Organization var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually2023
};
var parsedEvent = new Event { Data = new EventData() }; var parsedEvent = new Event { Data = new EventData() };
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>()) _stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
@@ -495,7 +526,7 @@ public class SubscriptionUpdatedHandlerTests
.Returns(organization); .Returns(organization);
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()) _stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>())
.Returns(new StripeList<Invoice> { Data = new List<Invoice> { new Invoice { Id = "inv_123" } } }); .Returns(new StripeList<Invoice> { Data = [new Invoice { Id = "inv_123" }] });
var plan = new Enterprise2023Plan(true); var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType) _pricingClient.GetPlanOrThrow(organization.PlanType)
@@ -577,7 +608,8 @@ public class SubscriptionUpdatedHandlerTests
} }
[Fact] [Fact]
public async Task HandleAsync_WhenSubscriptionIsActive_AndOrganizationHasSecretsManagerTrial_AndRemovingSecretsManagerTrial_RemovesPasswordManagerCoupon() public async Task
HandleAsync_WhenSubscriptionIsActive_AndOrganizationHasSecretsManagerTrial_AndRemovingSecretsManagerTrial_RemovesPasswordManagerCoupon()
{ {
// Arrange // Arrange
var organizationId = Guid.NewGuid(); var organizationId = Guid.NewGuid();
@@ -589,34 +621,18 @@ public class SubscriptionUpdatedHandlerTests
CustomerId = "cus_123", CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem> Items = new StripeList<SubscriptionItem>
{ {
Data = new List<SubscriptionItem> Data = [new SubscriptionItem { Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }]
{
new() { Plan = new Stripe.Plan { Id = "2023-enterprise-org-seat-annually" } }
}
}, },
Customer = new Customer Customer = new Customer
{ {
Balance = 0, Balance = 0,
Discount = new Discount Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
{
Coupon = new Coupon { Id = "sm-standalone" }
}
}, },
Discount = new Discount Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } },
{ Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
Coupon = new Coupon { Id = "sm-standalone" }
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
}; };
var organization = new Organization var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually2023
};
var plan = new Enterprise2023Plan(true); var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType) _pricingClient.GetPlanOrThrow(organization.PlanType)
@@ -631,20 +647,14 @@ public class SubscriptionUpdatedHandlerTests
{ {
items = new items = new
{ {
data = new[] data = new[] { new { plan = new { id = "secrets-manager-enterprise-seat-annually" } } }
{
new { plan = new { id = "secrets-manager-enterprise-seat-annually" } }
}
}, },
Items = new StripeList<SubscriptionItem> Items = new StripeList<SubscriptionItem>
{ {
Data = new List<SubscriptionItem> Data =
{ [
new SubscriptionItem new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } }
{ ]
Plan = new Stripe.Plan { Id = "secrets-manager-enterprise-seat-annually" }
}
}
} }
}) })
} }
@@ -990,7 +1000,7 @@ public class SubscriptionUpdatedHandlerTests
{ {
Id = previousSubscription?.Id ?? "sub_123", Id = previousSubscription?.Id ?? "sub_123",
Status = StripeSubscriptionStatus.Active, Status = StripeSubscriptionStatus.Active,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }, Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
}; };
var provider = new Provider { Id = providerId, Enabled = false }; var provider = new Provider { Id = providerId, Enabled = false };
@@ -1010,10 +1020,10 @@ public class SubscriptionUpdatedHandlerTests
{ {
return new List<object[]> return new List<object[]>
{ {
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Unpaid }, }, new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Unpaid } },
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Incomplete }, }, new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Incomplete } },
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired }, }, new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired } },
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Paused }, }, new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Paused } }
}; };
} }
} }