1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-07 10:55:43 -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:
Alex Morask
2024-02-09 11:58:37 -05:00
committed by GitHub
parent b81f9ca749
commit 59fa6935b4
20 changed files with 656 additions and 12 deletions

View File

@ -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)

View 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);
}

View File

@ -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);
}
}

View File

@ -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>();
}
}

View 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; }
}

View 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);
}

View File

@ -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();
}
}

View 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.");
}

View File

@ -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()

View File

@ -14,7 +14,8 @@ public interface ISubscriber
string BraintreeCustomerIdPrefix();
string BraintreeIdField();
string BraintreeCloudRegionField();
string GatewayIdField();
bool IsOrganization();
bool IsUser();
string SubscriberType();
bool IsExpired();
}

View File

@ -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))