mirror of
https://github.com/bitwarden/server.git
synced 2025-06-30 15:42:48 -05:00
[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]
This commit is contained in:
@ -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<IUpgradeOrganizationPlanCommand>();
|
||||
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
|
||||
_pushNotificationService = Substitute.For<IPushNotificationService>();
|
||||
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
|
||||
_getSubscriptionQuery = Substitute.For<IGetSubscriptionQuery>();
|
||||
_referenceEventService = Substitute.For<IReferenceEventService>();
|
||||
|
||||
_sut = new OrganizationsController(
|
||||
_organizationRepository,
|
||||
@ -99,7 +108,10 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_updateSecretsManagerSubscriptionCommand,
|
||||
_upgradeOrganizationPlanCommand,
|
||||
_addSecretsManagerSubscriptionCommand,
|
||||
_pushNotificationService);
|
||||
_pushNotificationService,
|
||||
_cancelSubscriptionCommand,
|
||||
_getSubscriptionQuery,
|
||||
_referenceEventService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
@ -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<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
|
||||
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
|
||||
@ -82,6 +89,10 @@ public class AccountsControllerTests : IDisposable
|
||||
_setInitialMasterPasswordCommand = Substitute.For<ISetInitialMasterPasswordCommand>();
|
||||
_rotateUserKeyCommand = Substitute.For<IRotateUserKeyCommand>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
|
||||
_getSubscriptionQuery = Substitute.For<IGetSubscriptionQuery>();
|
||||
_referenceEventService = Substitute.For<IReferenceEventService>();
|
||||
_currentContext = Substitute.For<ICurrentContext>();
|
||||
_cipherValidator =
|
||||
Substitute.For<IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>>>();
|
||||
_folderValidator =
|
||||
@ -110,6 +121,10 @@ public class AccountsControllerTests : IDisposable
|
||||
_setInitialMasterPasswordCommand,
|
||||
_rotateUserKeyCommand,
|
||||
_featureService,
|
||||
_cancelSubscriptionCommand,
|
||||
_getSubscriptionQuery,
|
||||
_referenceEventService,
|
||||
_currentContext,
|
||||
_cipherValidator,
|
||||
_folderValidator,
|
||||
_sendValidator,
|
||||
|
@ -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<CancelSubscriptionCommand> 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<CancelSubscriptionCommand> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = "active",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<CancelSubscriptionCommand> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = "active",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<CancelSubscriptionCommand> 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<Subscription> DidNotCancelSubscription(SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionCancelAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
|
||||
private static Task<Subscription> DidNotUpdateSubscription(SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
|
||||
private static Task<Subscription> CancelledSubscriptionWith(
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider,
|
||||
Expression<Predicate<SubscriptionCancelOptions>> predicate)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCancelAsync(_subscriptionId, Arg.Is(predicate));
|
||||
|
||||
private static Task<Subscription> UpdatedSubscriptionWith(
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider,
|
||||
Expression<Predicate<SubscriptionUpdateOptions>> predicate)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionUpdateAsync(_subscriptionId, Arg.Is(predicate));
|
||||
}
|
104
test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs
Normal file
104
test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs
Normal file
@ -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<GetSubscriptionQuery> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetSubscription(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_Organization_NoGatewaySubscriptionId_ThrowsGatewayException(
|
||||
Organization organization,
|
||||
SutProvider<GetSubscriptionQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_Organization_NoSubscription_ThrowsGatewayException(
|
||||
Organization organization,
|
||||
SutProvider<GetSubscriptionQuery> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_Organization_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<GetSubscriptionQuery> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().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<GetSubscriptionQuery> sutProvider)
|
||||
{
|
||||
user.GatewaySubscriptionId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_User_NoSubscription_ThrowsGatewayException(
|
||||
User user,
|
||||
SutProvider<GetSubscriptionQuery> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(user.GatewaySubscriptionId)
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_User_Succeeds(
|
||||
User user,
|
||||
SutProvider<GetSubscriptionQuery> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(user.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var gotSubscription = await sutProvider.Sut.GetSubscription(user);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
}
|
||||
|
||||
private static async Task ThrowsContactSupportAsync(Func<Task> function)
|
||||
{
|
||||
const string message = "Something went wrong with your request. Please contact support.";
|
||||
|
||||
var exception = await Assert.ThrowsAsync<GatewayException>(function);
|
||||
|
||||
Assert.Equal(message, exception.Message);
|
||||
}
|
||||
}
|
18
test/Core.Test/Billing/Utilities.cs
Normal file
18
test/Core.Test/Billing/Utilities.cs
Normal file
@ -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<Task> function)
|
||||
{
|
||||
var contactSupport = ContactSupport();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<GatewayException>(function);
|
||||
|
||||
Assert.Equal(contactSupport.Message, exception.Message);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user