From 59fa6935b48c9947e09e9a5cb944ae2b1924533c Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 9 Feb 2024 11:58:37 -0500 Subject: [PATCH] [AC-1608] Send offboarding survey response to Stripe on subscription cancellation (#3734) * Added offboarding survey response to cancellation when FF is on. * Removed service methods to prevent unnecessary upstream registrations * Forgot to actually remove the injected command in the services * Rui's feedback * Add missing summary * Missed [FromBody] --- .../Controllers/OrganizationsController.cs | 58 ++++++- .../Auth/Controllers/AccountsController.cs | 50 +++++- .../SubscriptionCancellationRequestModel.cs | 7 + src/Api/Startup.cs | 1 + .../AdminConsole/Entities/Organization.cs | 6 +- .../Commands/ICancelSubscriptionCommand.cs | 25 +++ .../CancelSubscriptionCommand.cs | 118 +++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 8 + .../Models/OffboardingSurveyResponse.cs | 8 + .../Billing/Queries/IGetSubscriptionQuery.cs | 18 ++ .../Implementations/GetSubscriptionQuery.cs | 36 ++++ src/Core/Billing/Utilities.cs | 8 + src/Core/Constants.cs | 2 +- src/Core/Entities/ISubscriber.cs | 3 +- src/Core/Entities/User.cs | 6 +- .../OrganizationsControllerTests.cs | 14 +- .../Controllers/AccountsControllerTests.cs | 15 ++ .../CancelSubscriptionCommandTests.cs | 163 ++++++++++++++++++ .../Queries/GetSubscriptionQueryTests.cs | 104 +++++++++++ test/Core.Test/Billing/Utilities.cs | 18 ++ 20 files changed, 656 insertions(+), 12 deletions(-) create mode 100644 src/Api/Models/Request/SubscriptionCancellationRequestModel.cs create mode 100644 src/Core/Billing/Commands/ICancelSubscriptionCommand.cs create mode 100644 src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs create mode 100644 src/Core/Billing/Models/OffboardingSurveyResponse.cs create mode 100644 src/Core/Billing/Queries/IGetSubscriptionQuery.cs create mode 100644 src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs create mode 100644 src/Core/Billing/Utilities.cs create mode 100644 test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs create mode 100644 test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs create mode 100644 test/Core.Test/Billing/Utilities.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 7e16f75c4..07b005bfc 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -18,6 +18,9 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -27,6 +30,9 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -58,6 +64,9 @@ public class OrganizationsController : Controller private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -80,7 +89,10 @@ public class OrganizationsController : Controller IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, - IPushNotificationService pushNotificationService) + IPushNotificationService pushNotificationService, + ICancelSubscriptionCommand cancelSubscriptionCommand, + IGetSubscriptionQuery getSubscriptionQuery, + IReferenceEventService referenceEventService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -103,6 +115,9 @@ public class OrganizationsController : Controller _upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand; _addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand; _pushNotificationService = pushNotificationService; + _cancelSubscriptionCommand = cancelSubscriptionCommand; + _getSubscriptionQuery = getSubscriptionQuery; + _referenceEventService = referenceEventService; } [HttpGet("{id}")] @@ -447,15 +462,48 @@ public class OrganizationsController : Controller [HttpPost("{id}/cancel")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostCancel(string id) + public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) + if (!await _currentContext.EditSubscription(id)) { throw new NotFoundException(); } - await _organizationService.CancelSubscriptionAsync(orgIdGuid); + var presentUserWithOffboardingSurvey = + _featureService.IsEnabled(FeatureFlagKeys.AC1607_PresentUsersWithOffboardingSurvey); + + if (presentUserWithOffboardingSurvey) + { + var organization = await _organizationRepository.GetByIdAsync(id); + + if (organization == null) + { + throw new NotFoundException(); + } + + var subscription = await _getSubscriptionQuery.GetSubscription(organization); + + await _cancelSubscriptionCommand.CancelSubscription(subscription, + new OffboardingSurveyResponse + { + UserId = _currentContext.UserId!.Value, + Reason = request.Reason, + Feedback = request.Feedback + }, + organization.IsExpired()); + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent( + ReferenceEventType.CancelSubscription, + organization, + _currentContext) + { + EndOfPeriod = organization.IsExpired() + }); + } + else + { + await _organizationService.CancelSubscriptionAsync(id); + } } [HttpPost("{id}/reinstate")] diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index a4b41310e..8c4842848 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -21,6 +21,10 @@ using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.Utilities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -31,6 +35,8 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.Services; using Bit.Core.Utilities; @@ -62,6 +68,10 @@ public class AccountsController : Controller private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IFeatureService _featureService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); @@ -93,6 +103,10 @@ public class AccountsController : Controller ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, IRotateUserKeyCommand rotateUserKeyCommand, IFeatureService featureService, + ICancelSubscriptionCommand cancelSubscriptionCommand, + IGetSubscriptionQuery getSubscriptionQuery, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, IRotationValidator, IEnumerable> cipherValidator, IRotationValidator, IEnumerable> folderValidator, IRotationValidator, IReadOnlyList> sendValidator, @@ -118,6 +132,10 @@ public class AccountsController : Controller _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; _rotateUserKeyCommand = rotateUserKeyCommand; _featureService = featureService; + _cancelSubscriptionCommand = cancelSubscriptionCommand; + _getSubscriptionQuery = getSubscriptionQuery; + _referenceEventService = referenceEventService; + _currentContext = currentContext; _cipherValidator = cipherValidator; _folderValidator = folderValidator; _sendValidator = sendValidator; @@ -805,15 +823,43 @@ public class AccountsController : Controller [HttpPost("cancel-premium")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostCancel() + public async Task PostCancel([FromBody] SubscriptionCancellationRequestModel request) { var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) { throw new UnauthorizedAccessException(); } - await _userService.CancelPremiumAsync(user); + var presentUserWithOffboardingSurvey = + _featureService.IsEnabled(FeatureFlagKeys.AC1607_PresentUsersWithOffboardingSurvey); + + if (presentUserWithOffboardingSurvey) + { + var subscription = await _getSubscriptionQuery.GetSubscription(user); + + await _cancelSubscriptionCommand.CancelSubscription(subscription, + new OffboardingSurveyResponse + { + UserId = user.Id, + Reason = request.Reason, + Feedback = request.Feedback + }, + user.IsExpired()); + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent( + ReferenceEventType.CancelSubscription, + user, + _currentContext) + { + EndOfPeriod = user.IsExpired() + }); + } + else + { + await _userService.CancelPremiumAsync(user); + } } [HttpPost("reinstate-premium")] diff --git a/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs new file mode 100644 index 000000000..318c40aa2 --- /dev/null +++ b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Api.Models.Request; + +public class SubscriptionCancellationRequestModel +{ + public string Reason { get; set; } + public string Feedback { get; set; } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 7b5067f3f..9f9432551 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -171,6 +171,7 @@ public class Startup services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingCommands(); + services.AddBillingQueries(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 82041c509..cd6f317ba 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -10,7 +10,7 @@ using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.Entities; -public class Organization : ITableObject, ISubscriber, IStorable, IStorableSubscriber, IRevisable, IReferenceable +public class Organization : ITableObject, IStorableSubscriber, IRevisable, IReferenceable { private Dictionary _twoFactorProviders; @@ -139,6 +139,8 @@ public class Organization : ITableObject, ISubscriber, IStorable, IStorabl return "organizationId"; } + public bool IsOrganization() => true; + public bool IsUser() { return false; @@ -149,6 +151,8 @@ public class Organization : ITableObject, ISubscriber, IStorable, IStorabl return "Organization"; } + public bool IsExpired() => ExpirationDate.HasValue && ExpirationDate.Value <= DateTime.UtcNow; + public long StorageBytesRemaining() { if (!MaxStorageGb.HasValue) diff --git a/src/Core/Billing/Commands/ICancelSubscriptionCommand.cs b/src/Core/Billing/Commands/ICancelSubscriptionCommand.cs new file mode 100644 index 000000000..b23880e65 --- /dev/null +++ b/src/Core/Billing/Commands/ICancelSubscriptionCommand.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Stripe; + +namespace Bit.Core.Billing.Commands; + +public interface ICancelSubscriptionCommand +{ + /// + /// Cancels a user or organization's subscription while including user-provided feedback via the . + /// If the flag is , + /// this command sets the subscription's "cancel_at_end_of_period" property to . + /// Otherwise, this command cancels the subscription immediately. + /// + /// The or with the subscription to cancel. + /// An DTO containing user-provided feedback on why they are cancelling the subscription. + /// A flag indicating whether to cancel the subscription immediately or at the end of the subscription period. + /// Thrown when the provided subscription is already in an inactive state. + Task CancelSubscription( + Subscription subscription, + OffboardingSurveyResponse offboardingSurveyResponse, + bool cancelImmediately); +} diff --git a/src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs b/src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs new file mode 100644 index 000000000..09dc5dde9 --- /dev/null +++ b/src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs @@ -0,0 +1,118 @@ +using Bit.Core.Billing.Models; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Billing.Commands.Implementations; + +public class CancelSubscriptionCommand( + ILogger logger, + IStripeAdapter stripeAdapter) + : ICancelSubscriptionCommand +{ + private static readonly List _validReasons = + [ + "customer_service", + "low_quality", + "missing_features", + "other", + "switched_service", + "too_complex", + "too_expensive", + "unused" + ]; + + public async Task CancelSubscription( + Subscription subscription, + OffboardingSurveyResponse offboardingSurveyResponse, + bool cancelImmediately) + { + if (IsInactive(subscription)) + { + logger.LogWarning("Cannot cancel subscription ({ID}) that's already inactive.", subscription.Id); + throw ContactSupport(); + } + + var metadata = new Dictionary + { + { "cancellingUserId", offboardingSurveyResponse.UserId.ToString() } + }; + + if (cancelImmediately) + { + if (BelongsToOrganization(subscription)) + { + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions + { + Metadata = metadata + }); + } + + await CancelSubscriptionImmediatelyAsync(subscription.Id, offboardingSurveyResponse); + } + else + { + await CancelSubscriptionAtEndOfPeriodAsync(subscription.Id, offboardingSurveyResponse, metadata); + } + } + + private static bool BelongsToOrganization(IHasMetadata subscription) + => subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId"); + + private async Task CancelSubscriptionImmediatelyAsync( + string subscriptionId, + OffboardingSurveyResponse offboardingSurveyResponse) + { + var options = new SubscriptionCancelOptions + { + CancellationDetails = new SubscriptionCancellationDetailsOptions + { + Comment = offboardingSurveyResponse.Feedback + } + }; + + if (IsValidCancellationReason(offboardingSurveyResponse.Reason)) + { + options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; + } + + await stripeAdapter.SubscriptionCancelAsync(subscriptionId, options); + } + + private static bool IsInactive(Subscription subscription) => + subscription.CanceledAt.HasValue || + subscription.Status == "canceled" || + subscription.Status == "unpaid" || + subscription.Status == "incomplete_expired"; + + private static bool IsValidCancellationReason(string reason) => _validReasons.Contains(reason); + + private async Task CancelSubscriptionAtEndOfPeriodAsync( + string subscriptionId, + OffboardingSurveyResponse offboardingSurveyResponse, + Dictionary metadata = null) + { + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true, + CancellationDetails = new SubscriptionCancellationDetailsOptions + { + Comment = offboardingSurveyResponse.Feedback + } + }; + + if (IsValidCancellationReason(offboardingSurveyResponse.Reason)) + { + options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; + } + + if (metadata != null) + { + options.Metadata = metadata; + } + + await stripeAdapter.SubscriptionUpdateAsync(subscriptionId, options); + } +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 37857cf3c..113fa4d5b 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands.Implementations; +using Bit.Core.Billing.Queries; +using Bit.Core.Billing.Queries.Implementations; namespace Bit.Core.Billing.Extensions; @@ -9,6 +11,12 @@ public static class ServiceCollectionExtensions { public static void AddBillingCommands(this IServiceCollection services) { + services.AddSingleton(); services.AddSingleton(); } + + public static void AddBillingQueries(this IServiceCollection services) + { + services.AddSingleton(); + } } diff --git a/src/Core/Billing/Models/OffboardingSurveyResponse.cs b/src/Core/Billing/Models/OffboardingSurveyResponse.cs new file mode 100644 index 000000000..cd966f40c --- /dev/null +++ b/src/Core/Billing/Models/OffboardingSurveyResponse.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Billing.Models; + +public class OffboardingSurveyResponse +{ + public Guid UserId { get; set; } + public string Reason { get; set; } + public string Feedback { get; set; } +} diff --git a/src/Core/Billing/Queries/IGetSubscriptionQuery.cs b/src/Core/Billing/Queries/IGetSubscriptionQuery.cs new file mode 100644 index 000000000..9ba2a85ed --- /dev/null +++ b/src/Core/Billing/Queries/IGetSubscriptionQuery.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Stripe; + +namespace Bit.Core.Billing.Queries; + +public interface IGetSubscriptionQuery +{ + /// + /// Retrieves a Stripe using the 's property. + /// + /// The organization or user to retrieve the subscription for. + /// A Stripe . + /// Thrown when the is . + /// Thrown when the subscriber's is or empty. + /// Thrown when the returned from Stripe's API is null. + Task GetSubscription(ISubscriber subscriber); +} diff --git a/src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs b/src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs new file mode 100644 index 000000000..c3b0a2955 --- /dev/null +++ b/src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs @@ -0,0 +1,36 @@ +using Bit.Core.Entities; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Billing.Queries.Implementations; + +public class GetSubscriptionQuery( + ILogger logger, + IStripeAdapter stripeAdapter) : IGetSubscriptionQuery +{ + public async Task GetSubscription(ISubscriber subscriber) + { + ArgumentNullException.ThrowIfNull(subscriber); + + if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + { + logger.LogError("Cannot cancel subscription for subscriber ({ID}) with no GatewaySubscriptionId.", subscriber.Id); + + throw ContactSupport(); + } + + var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + + if (subscription != null) + { + return subscription; + } + + logger.LogError("Could not find Stripe subscription ({ID}) to cancel.", subscriber.GatewaySubscriptionId); + + throw ContactSupport(); + } +} diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs new file mode 100644 index 000000000..54ace07a7 --- /dev/null +++ b/src/Core/Billing/Utilities.cs @@ -0,0 +1,8 @@ +using Bit.Core.Exceptions; + +namespace Bit.Core.Billing; + +public static class Utilities +{ + public static GatewayException ContactSupport() => new("Something went wrong with your request. Please contact support."); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1d5073df6..8c013a2f2 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -115,7 +115,7 @@ public static class FeatureFlagKeys /// flexible collections /// public const string FlexibleCollectionsMigration = "flexible-collections-migration"; - + public const string AC1607_PresentUsersWithOffboardingSurvey = "AC-1607_present-user-offboarding-survey"; public const string PM5766AutomaticTax = "PM-5766-automatic-tax"; public static List GetAllKeys() diff --git a/src/Core/Entities/ISubscriber.cs b/src/Core/Entities/ISubscriber.cs index 58510459e..c4bfc622c 100644 --- a/src/Core/Entities/ISubscriber.cs +++ b/src/Core/Entities/ISubscriber.cs @@ -14,7 +14,8 @@ public interface ISubscriber string BraintreeCustomerIdPrefix(); string BraintreeIdField(); string BraintreeCloudRegionField(); - string GatewayIdField(); + bool IsOrganization(); bool IsUser(); string SubscriberType(); + bool IsExpired(); } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index b0db21eb1..bf13ff2a7 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Core.Entities; -public class User : ITableObject, ISubscriber, IStorable, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable +public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable { private Dictionary _twoFactorProviders; @@ -111,6 +111,8 @@ public class User : ITableObject, ISubscriber, IStorable, IStorableSubscri return "userId"; } + public bool IsOrganization() => false; + public bool IsUser() { return true; @@ -121,6 +123,8 @@ public class User : ITableObject, ISubscriber, IStorable, IStorableSubscri return "Subscriber"; } + public bool IsExpired() => PremiumExpirationDate.HasValue && PremiumExpirationDate.Value <= DateTime.UtcNow; + public Dictionary GetTwoFactorProviders() { if (string.IsNullOrWhiteSpace(TwoFactorProviders)) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 76e4432b5..45b3d9af3 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -11,6 +11,8 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -21,6 +23,7 @@ using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tools.Services; using NSubstitute; using NSubstitute.ReturnsExtensions; using Xunit; @@ -51,6 +54,9 @@ public class OrganizationsControllerTests : IDisposable private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; private readonly OrganizationsController _sut; @@ -77,6 +83,9 @@ public class OrganizationsControllerTests : IDisposable _upgradeOrganizationPlanCommand = Substitute.For(); _addSecretsManagerSubscriptionCommand = Substitute.For(); _pushNotificationService = Substitute.For(); + _cancelSubscriptionCommand = Substitute.For(); + _getSubscriptionQuery = Substitute.For(); + _referenceEventService = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -99,7 +108,10 @@ public class OrganizationsControllerTests : IDisposable _updateSecretsManagerSubscriptionCommand, _upgradeOrganizationPlanCommand, _addSecretsManagerSubscriptionCommand, - _pushNotificationService); + _pushNotificationService, + _cancelSubscriptionCommand, + _getSubscriptionQuery, + _referenceEventService); } public void Dispose() diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 0321b4f13..79aa2ca13 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -14,6 +14,9 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -53,6 +56,10 @@ public class AccountsControllerTests : IDisposable private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IFeatureService _featureService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; private readonly IRotationValidator, IEnumerable> _cipherValidator; private readonly IRotationValidator, IEnumerable> _folderValidator; @@ -82,6 +89,10 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand = Substitute.For(); _rotateUserKeyCommand = Substitute.For(); _featureService = Substitute.For(); + _cancelSubscriptionCommand = Substitute.For(); + _getSubscriptionQuery = Substitute.For(); + _referenceEventService = Substitute.For(); + _currentContext = Substitute.For(); _cipherValidator = Substitute.For, IEnumerable>>(); _folderValidator = @@ -110,6 +121,10 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand, _rotateUserKeyCommand, _featureService, + _cancelSubscriptionCommand, + _getSubscriptionQuery, + _referenceEventService, + _currentContext, _cipherValidator, _folderValidator, _sendValidator, diff --git a/test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs b/test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs new file mode 100644 index 000000000..ba98c26a5 --- /dev/null +++ b/test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs @@ -0,0 +1,163 @@ +using System.Linq.Expressions; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands.Implementations; +using Bit.Core.Billing.Models; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Stripe; +using Xunit; + +using static Bit.Core.Test.Billing.Utilities; + +namespace Bit.Core.Test.Billing.Commands; + +[SutProviderCustomize] +public class CancelSubscriptionCommandTests +{ + private const string _subscriptionId = "subscription_id"; + private const string _cancellingUserIdKey = "cancellingUserId"; + + [Theory, BitAutoData] + public async Task CancelSubscription_SubscriptionInactive_ThrowsGatewayException( + SutProvider sutProvider) + { + var subscription = new Subscription + { + Status = "canceled" + }; + + await ThrowsContactSupportAsync(() => + sutProvider.Sut.CancelSubscription(subscription, new OffboardingSurveyResponse(), false)); + + await DidNotUpdateSubscription(sutProvider); + + await DidNotCancelSubscription(sutProvider); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_UpdatesSubscription_CancelSubscriptionImmediately( + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + var subscription = new Subscription + { + Id = _subscriptionId, + Status = "active", + Metadata = new Dictionary + { + { "organizationId", "organization_id" } + } + }; + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = userId, + Reason = "missing_features", + Feedback = "Lorem ipsum" + }; + + await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true); + + await UpdatedSubscriptionWith(sutProvider, options => options.Metadata[_cancellingUserIdKey] == userId.ToString()); + + await CancelledSubscriptionWith(sutProvider, options => + options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && + options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_CancelImmediately_BelongsToUser_CancelSubscriptionImmediately( + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + var subscription = new Subscription + { + Id = _subscriptionId, + Status = "active", + Metadata = new Dictionary + { + { "userId", "user_id" } + } + }; + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = userId, + Reason = "missing_features", + Feedback = "Lorem ipsum" + }; + + await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true); + + await DidNotUpdateSubscription(sutProvider); + + await CancelledSubscriptionWith(sutProvider, options => + options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && + options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_DoNotCancelImmediately_UpdateSubscriptionToCancelAtEndOfPeriod( + Organization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + organization.ExpirationDate = DateTime.UtcNow.AddDays(5); + + var subscription = new Subscription + { + Id = _subscriptionId, + Status = "active" + }; + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = userId, + Reason = "missing_features", + Feedback = "Lorem ipsum" + }; + + await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, false); + + await UpdatedSubscriptionWith(sutProvider, options => + options.CancelAtPeriodEnd == true && + options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && + options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason && + options.Metadata[_cancellingUserIdKey] == userId.ToString()); + + await DidNotCancelSubscription(sutProvider); + } + + private static Task DidNotCancelSubscription(SutProvider sutProvider) + => sutProvider + .GetDependency() + .DidNotReceiveWithAnyArgs() + .SubscriptionCancelAsync(Arg.Any(), Arg.Any()); + + private static Task DidNotUpdateSubscription(SutProvider sutProvider) + => sutProvider + .GetDependency() + .DidNotReceiveWithAnyArgs() + .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + + private static Task CancelledSubscriptionWith( + SutProvider sutProvider, + Expression> predicate) + => sutProvider + .GetDependency() + .Received(1) + .SubscriptionCancelAsync(_subscriptionId, Arg.Is(predicate)); + + private static Task UpdatedSubscriptionWith( + SutProvider sutProvider, + Expression> predicate) + => sutProvider + .GetDependency() + .Received(1) + .SubscriptionUpdateAsync(_subscriptionId, Arg.Is(predicate)); +} diff --git a/test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs b/test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs new file mode 100644 index 000000000..adae46a79 --- /dev/null +++ b/test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs @@ -0,0 +1,104 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Queries.Implementations; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Queries; + +[SutProviderCustomize] +public class GetSubscriptionQueryTests +{ + [Theory, BitAutoData] + public async Task GetSubscription_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) + => await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetSubscription(null)); + + [Theory, BitAutoData] + public async Task GetSubscription_Organization_NoGatewaySubscriptionId_ThrowsGatewayException( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = null; + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_Organization_NoSubscription_ThrowsGatewayException( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId) + .ReturnsNull(); + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_Organization_Succeeds( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription(); + + sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId) + .Returns(subscription); + + var gotSubscription = await sutProvider.Sut.GetSubscription(organization); + + Assert.Equivalent(subscription, gotSubscription); + } + + [Theory, BitAutoData] + public async Task GetSubscription_User_NoGatewaySubscriptionId_ThrowsGatewayException( + User user, + SutProvider sutProvider) + { + user.GatewaySubscriptionId = null; + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_User_NoSubscription_ThrowsGatewayException( + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency().SubscriptionGetAsync(user.GatewaySubscriptionId) + .ReturnsNull(); + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_User_Succeeds( + User user, + SutProvider sutProvider) + { + var subscription = new Subscription(); + + sutProvider.GetDependency().SubscriptionGetAsync(user.GatewaySubscriptionId) + .Returns(subscription); + + var gotSubscription = await sutProvider.Sut.GetSubscription(user); + + Assert.Equivalent(subscription, gotSubscription); + } + + private static async Task ThrowsContactSupportAsync(Func function) + { + const string message = "Something went wrong with your request. Please contact support."; + + var exception = await Assert.ThrowsAsync(function); + + Assert.Equal(message, exception.Message); + } +} diff --git a/test/Core.Test/Billing/Utilities.cs b/test/Core.Test/Billing/Utilities.cs new file mode 100644 index 000000000..359c010a2 --- /dev/null +++ b/test/Core.Test/Billing/Utilities.cs @@ -0,0 +1,18 @@ +using Bit.Core.Exceptions; +using Xunit; + +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Test.Billing; + +public static class Utilities +{ + public static async Task ThrowsContactSupportAsync(Func function) + { + var contactSupport = ContactSupport(); + + var exception = await Assert.ThrowsAsync(function); + + Assert.Equal(contactSupport.Message, exception.Message); + } +}