mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01: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:
parent
b81f9ca749
commit
59fa6935b4
@ -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")]
|
||||
|
@ -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<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
|
||||
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator,
|
||||
IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> 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")]
|
||||
|
@ -0,0 +1,7 @@
|
||||
namespace Bit.Api.Models.Request;
|
||||
|
||||
public class SubscriptionCancellationRequestModel
|
||||
{
|
||||
public string Reason { get; set; }
|
||||
public string Feedback { get; set; }
|
||||
}
|
@ -171,6 +171,7 @@ public class Startup
|
||||
services.AddOrganizationSubscriptionServices();
|
||||
services.AddCoreLocalizationServices();
|
||||
services.AddBillingCommands();
|
||||
services.AddBillingQueries();
|
||||
|
||||
// Authorization Handlers
|
||||
services.AddAuthorizationHandlers();
|
||||
|
@ -10,7 +10,7 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorableSubscriber, IRevisable, IReferenceable
|
||||
public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable, IReferenceable
|
||||
{
|
||||
private Dictionary<TwoFactorProviderType, TwoFactorProvider> _twoFactorProviders;
|
||||
|
||||
@ -139,6 +139,8 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
|
||||
return "organizationId";
|
||||
}
|
||||
|
||||
public bool IsOrganization() => true;
|
||||
|
||||
public bool IsUser()
|
||||
{
|
||||
return false;
|
||||
@ -149,6 +151,8 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
|
||||
return "Organization";
|
||||
}
|
||||
|
||||
public bool IsExpired() => ExpirationDate.HasValue && ExpirationDate.Value <= DateTime.UtcNow;
|
||||
|
||||
public long StorageBytesRemaining()
|
||||
{
|
||||
if (!MaxStorageGb.HasValue)
|
||||
|
25
src/Core/Billing/Commands/ICancelSubscriptionCommand.cs
Normal file
25
src/Core/Billing/Commands/ICancelSubscriptionCommand.cs
Normal file
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Cancels a user or organization's subscription while including user-provided feedback via the <paramref name="offboardingSurveyResponse"/>.
|
||||
/// If the <paramref name="cancelImmediately"/> flag is <see langword="false"/>,
|
||||
/// this command sets the subscription's <b>"cancel_at_end_of_period"</b> property to <see langword="true"/>.
|
||||
/// Otherwise, this command cancels the subscription immediately.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The <see cref="User"/> or <see cref="Organization"/> with the subscription to cancel.</param>
|
||||
/// <param name="offboardingSurveyResponse">An <see cref="OffboardingSurveyResponse"/> DTO containing user-provided feedback on why they are cancelling the subscription.</param>
|
||||
/// <param name="cancelImmediately">A flag indicating whether to cancel the subscription immediately or at the end of the subscription period.</param>
|
||||
/// <exception cref="GatewayException">Thrown when the provided subscription is already in an inactive state.</exception>
|
||||
Task CancelSubscription(
|
||||
Subscription subscription,
|
||||
OffboardingSurveyResponse offboardingSurveyResponse,
|
||||
bool cancelImmediately);
|
||||
}
|
@ -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<CancelSubscriptionCommand> logger,
|
||||
IStripeAdapter stripeAdapter)
|
||||
: ICancelSubscriptionCommand
|
||||
{
|
||||
private static readonly List<string> _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<string, string>
|
||||
{
|
||||
{ "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<string, string> 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);
|
||||
}
|
||||
}
|
@ -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<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
|
||||
services.AddSingleton<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
|
||||
}
|
||||
|
||||
public static void AddBillingQueries(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IGetSubscriptionQuery, GetSubscriptionQuery>();
|
||||
}
|
||||
}
|
||||
|
8
src/Core/Billing/Models/OffboardingSurveyResponse.cs
Normal file
8
src/Core/Billing/Models/OffboardingSurveyResponse.cs
Normal file
@ -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; }
|
||||
}
|
18
src/Core/Billing/Queries/IGetSubscriptionQuery.cs
Normal file
18
src/Core/Billing/Queries/IGetSubscriptionQuery.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Queries;
|
||||
|
||||
public interface IGetSubscriptionQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The organization or user to retrieve the subscription for.</param>
|
||||
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="GatewayException">Thrown when the subscriber's <see cref="ISubscriber.GatewaySubscriptionId"/> is <see langword="null"/> or empty.</exception>
|
||||
/// <exception cref="GatewayException">Thrown when the <see cref="Subscription"/> returned from Stripe's API is null.</exception>
|
||||
Task<Subscription> GetSubscription(ISubscriber subscriber);
|
||||
}
|
@ -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<GetSubscriptionQuery> logger,
|
||||
IStripeAdapter stripeAdapter) : IGetSubscriptionQuery
|
||||
{
|
||||
public async Task<Subscription> 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();
|
||||
}
|
||||
}
|
8
src/Core/Billing/Utilities.cs
Normal file
8
src/Core/Billing/Utilities.cs
Normal file
@ -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.");
|
||||
}
|
@ -115,7 +115,7 @@ public static class FeatureFlagKeys
|
||||
/// flexible collections
|
||||
/// </summary>
|
||||
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<string> GetAllKeys()
|
||||
|
@ -14,7 +14,8 @@ public interface ISubscriber
|
||||
string BraintreeCustomerIdPrefix();
|
||||
string BraintreeIdField();
|
||||
string BraintreeCloudRegionField();
|
||||
string GatewayIdField();
|
||||
bool IsOrganization();
|
||||
bool IsUser();
|
||||
string SubscriberType();
|
||||
bool IsExpired();
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class User : ITableObject<Guid>, ISubscriber, IStorable, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable
|
||||
public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable
|
||||
{
|
||||
private Dictionary<TwoFactorProviderType, TwoFactorProvider> _twoFactorProviders;
|
||||
|
||||
@ -111,6 +111,8 @@ public class User : ITableObject<Guid>, ISubscriber, IStorable, IStorableSubscri
|
||||
return "userId";
|
||||
}
|
||||
|
||||
public bool IsOrganization() => false;
|
||||
|
||||
public bool IsUser()
|
||||
{
|
||||
return true;
|
||||
@ -121,6 +123,8 @@ public class User : ITableObject<Guid>, ISubscriber, IStorable, IStorableSubscri
|
||||
return "Subscriber";
|
||||
}
|
||||
|
||||
public bool IsExpired() => PremiumExpirationDate.HasValue && PremiumExpirationDate.Value <= DateTime.UtcNow;
|
||||
|
||||
public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(TwoFactorProviders))
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user