1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 07:36:14 -05:00

[AC-1754] Provide upgrade flow for paid organizations (#3468)

* wip

* Add CompleteSubscriptionUpdate

* Add AdjustSubscription to PaymentService

* Use PaymentService.AdjustSubscription in UpgradeOrganizationPlanCommand

* Add CompleteSubscriptionUpdateTests

* Remove unused changes

* Update UpgradeOrganizationPlanCommandTests

* Fixing missing usings after master merge

* Defects: AC-1958, AC-1959

* Allow user to unsubscribe from Secrets Manager and Storage during upgrade

* Handled null exception when upgrading away from a plan that doesn't allow secrets manager

* Resolved issue where Teams Starter couldn't increase storage

---------

Co-authored-by: Conner Turnbull <cturnbull@bitwarden.com>
Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com>
This commit is contained in:
Alex Morask
2023-12-27 09:30:23 -05:00
committed by GitHub
parent cf4d8a4f92
commit c60f260c0f
15 changed files with 995 additions and 45 deletions

View File

@ -0,0 +1,329 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Stripe;
namespace Bit.Core.Models.Business;
/// <summary>
/// A model representing the data required to upgrade from one subscription to another using a <see cref="CompleteSubscriptionUpdate"/>.
/// </summary>
public class SubscriptionData
{
public StaticStore.Plan Plan { get; init; }
public int PurchasedPasswordManagerSeats { get; init; }
public bool SubscribedToSecretsManager { get; set; }
public int? PurchasedSecretsManagerSeats { get; init; }
public int? PurchasedAdditionalSecretsManagerServiceAccounts { get; init; }
public int PurchasedAdditionalStorage { get; init; }
}
public class CompleteSubscriptionUpdate : SubscriptionUpdate
{
private readonly SubscriptionData _currentSubscription;
private readonly SubscriptionData _updatedSubscription;
private readonly Dictionary<string, SubscriptionUpdateType> _subscriptionUpdateMap = new();
private enum SubscriptionUpdateType
{
PasswordManagerSeats,
SecretsManagerSeats,
SecretsManagerServiceAccounts,
Storage
}
/// <summary>
/// A model used to generate the Stripe <see cref="SubscriptionItemOptions"/>
/// necessary to both upgrade an organization's subscription and revert that upgrade
/// in the case of an error.
/// </summary>
/// <param name="organization">The <see cref="Organization"/> to upgrade.</param>
/// <param name="updatedSubscription">The updates you want to apply to the organization's subscription.</param>
public CompleteSubscriptionUpdate(
Organization organization,
SubscriptionData updatedSubscription)
{
_currentSubscription = GetSubscriptionDataFor(organization);
_updatedSubscription = updatedSubscription;
}
protected override List<string> PlanIds => new()
{
GetPasswordManagerPlanId(_updatedSubscription.Plan),
_updatedSubscription.Plan.SecretsManager.StripeSeatPlanId,
_updatedSubscription.Plan.SecretsManager.StripeServiceAccountPlanId,
_updatedSubscription.Plan.PasswordManager.StripeStoragePlanId
};
/// <summary>
/// Generates the <see cref="SubscriptionItemOptions"/> necessary to revert an <see cref="Organization"/>'s
/// <see cref="Subscription"/> upgrade in the case of an error.
/// </summary>
/// <param name="subscription">The organization's <see cref="Subscription"/>.</param>
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
{
var subscriptionItemOptions = new List<SubscriptionItemOptions>
{
GetPasswordManagerOptions(subscription, _updatedSubscription, _currentSubscription)
};
if (_updatedSubscription.SubscribedToSecretsManager || _currentSubscription.SubscribedToSecretsManager)
{
subscriptionItemOptions.Add(GetSecretsManagerOptions(subscription, _updatedSubscription, _currentSubscription));
if (_updatedSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0 ||
_currentSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0)
{
subscriptionItemOptions.Add(GetServiceAccountsOptions(subscription, _updatedSubscription, _currentSubscription));
}
}
if (_updatedSubscription.PurchasedAdditionalStorage != 0 || _currentSubscription.PurchasedAdditionalStorage != 0)
{
subscriptionItemOptions.Add(GetStorageOptions(subscription, _updatedSubscription, _currentSubscription));
}
return subscriptionItemOptions;
}
/*
* This is almost certainly overkill. If we trust the data in the Vault DB, we should just be able to
* compare the _currentSubscription against the _updatedSubscription to see if there are any differences.
* However, for the sake of ensuring we're checking against the Stripe subscription itself, I'll leave this
* included for now.
*/
/// <summary>
/// Checks whether the updates provided in the <see cref="CompleteSubscriptionUpdate"/>'s constructor
/// are actually different than the organization's current <see cref="Subscription"/>.
/// </summary>
/// <param name="subscription">The organization's <see cref="Subscription"/>.</param>
public override bool UpdateNeeded(Subscription subscription)
{
var upgradeItemsOptions = UpgradeItemsOptions(subscription);
foreach (var subscriptionItemOptions in upgradeItemsOptions)
{
var success = _subscriptionUpdateMap.TryGetValue(subscriptionItemOptions.Price, out var updateType);
if (!success)
{
return false;
}
var updateNeeded = updateType switch
{
SubscriptionUpdateType.PasswordManagerSeats => ContainsUpdatesBetween(
GetPasswordManagerPlanId(_currentSubscription.Plan),
subscriptionItemOptions),
SubscriptionUpdateType.SecretsManagerSeats => ContainsUpdatesBetween(
_currentSubscription.Plan.SecretsManager.StripeSeatPlanId,
subscriptionItemOptions),
SubscriptionUpdateType.SecretsManagerServiceAccounts => ContainsUpdatesBetween(
_currentSubscription.Plan.SecretsManager.StripeServiceAccountPlanId,
subscriptionItemOptions),
SubscriptionUpdateType.Storage => ContainsUpdatesBetween(
_currentSubscription.Plan.PasswordManager.StripeStoragePlanId,
subscriptionItemOptions),
_ => false
};
if (updateNeeded)
{
return true;
}
}
return false;
bool ContainsUpdatesBetween(string currentPlanId, SubscriptionItemOptions options)
{
var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId);
return (subscriptionItem.Plan.Id != options.Plan && subscriptionItem.Price.Id != options.Plan) ||
subscriptionItem.Quantity != options.Quantity ||
subscriptionItem.Deleted != options.Deleted;
}
}
/// <summary>
/// Generates the <see cref="SubscriptionItemOptions"/> necessary to upgrade an <see cref="Organization"/>'s
/// <see cref="Subscription"/>.
/// </summary>
/// <param name="subscription">The organization's <see cref="Subscription"/>.</param>
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{
var subscriptionItemOptions = new List<SubscriptionItemOptions>
{
GetPasswordManagerOptions(subscription, _currentSubscription, _updatedSubscription)
};
if (_currentSubscription.SubscribedToSecretsManager || _updatedSubscription.SubscribedToSecretsManager)
{
subscriptionItemOptions.Add(GetSecretsManagerOptions(subscription, _currentSubscription, _updatedSubscription));
if (_currentSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0 ||
_updatedSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0)
{
subscriptionItemOptions.Add(GetServiceAccountsOptions(subscription, _currentSubscription, _updatedSubscription));
}
}
if (_currentSubscription.PurchasedAdditionalStorage != 0 || _updatedSubscription.PurchasedAdditionalStorage != 0)
{
subscriptionItemOptions.Add(GetStorageOptions(subscription, _currentSubscription, _updatedSubscription));
}
return subscriptionItemOptions;
}
private SubscriptionItemOptions GetPasswordManagerOptions(
Subscription subscription,
SubscriptionData from,
SubscriptionData to)
{
var currentPlanId = GetPasswordManagerPlanId(from.Plan);
var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId);
if (subscriptionItem == null)
{
throw new GatewayException("Could not find Password Manager subscription");
}
var updatedPlanId = GetPasswordManagerPlanId(to.Plan);
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.PasswordManagerSeats;
return new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = updatedPlanId,
Quantity = IsNonSeatBasedPlan(to.Plan) ? 1 : to.PurchasedPasswordManagerSeats
};
}
private SubscriptionItemOptions GetSecretsManagerOptions(
Subscription subscription,
SubscriptionData from,
SubscriptionData to)
{
var currentPlanId = from.Plan?.SecretsManager?.StripeSeatPlanId;
var subscriptionItem = !string.IsNullOrEmpty(currentPlanId)
? FindSubscriptionItem(subscription, currentPlanId)
: null;
var updatedPlanId = to.Plan.SecretsManager.StripeSeatPlanId;
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.SecretsManagerSeats;
return new SubscriptionItemOptions
{
Id = subscriptionItem?.Id,
Price = updatedPlanId,
Quantity = to.PurchasedSecretsManagerSeats,
Deleted = subscriptionItem?.Id != null && to.PurchasedSecretsManagerSeats == 0
? true
: null
};
}
private SubscriptionItemOptions GetServiceAccountsOptions(
Subscription subscription,
SubscriptionData from,
SubscriptionData to)
{
var currentPlanId = from.Plan?.SecretsManager?.StripeServiceAccountPlanId;
var subscriptionItem = !string.IsNullOrEmpty(currentPlanId)
? FindSubscriptionItem(subscription, currentPlanId)
: null;
var updatedPlanId = to.Plan.SecretsManager.StripeServiceAccountPlanId;
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.SecretsManagerServiceAccounts;
return new SubscriptionItemOptions
{
Id = subscriptionItem?.Id,
Price = updatedPlanId,
Quantity = to.PurchasedAdditionalSecretsManagerServiceAccounts,
Deleted = subscriptionItem?.Id != null && to.PurchasedAdditionalSecretsManagerServiceAccounts == 0
? true
: null
};
}
private SubscriptionItemOptions GetStorageOptions(
Subscription subscription,
SubscriptionData from,
SubscriptionData to)
{
var currentPlanId = from.Plan.PasswordManager.StripeStoragePlanId;
var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId);
var updatedPlanId = to.Plan.PasswordManager.StripeStoragePlanId;
_subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.Storage;
return new SubscriptionItemOptions
{
Id = subscriptionItem?.Id,
Price = updatedPlanId,
Quantity = to.PurchasedAdditionalStorage,
Deleted = subscriptionItem?.Id != null && to.PurchasedAdditionalStorage == 0
? true
: null
};
}
private static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId)
{
if (string.IsNullOrEmpty(planId))
{
return null;
}
var data = subscription.Items.Data;
var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId);
return subscriptionItem;
}
private static string GetPasswordManagerPlanId(StaticStore.Plan plan)
=> IsNonSeatBasedPlan(plan)
? plan.PasswordManager.StripePlanId
: plan.PasswordManager.StripeSeatPlanId;
private static SubscriptionData GetSubscriptionDataFor(Organization organization)
{
var plan = Utilities.StaticStore.GetPlan(organization.PlanType);
return new SubscriptionData
{
Plan = plan,
PurchasedPasswordManagerSeats = organization.Seats.HasValue
? organization.Seats.Value - plan.PasswordManager.BaseSeats
: 0,
SubscribedToSecretsManager = organization.UseSecretsManager,
PurchasedSecretsManagerSeats = plan.SecretsManager is not null
? organization.SmSeats - plan.SecretsManager.BaseSeats
: 0,
PurchasedAdditionalSecretsManagerServiceAccounts = plan.SecretsManager is not null
? organization.SmServiceAccounts - plan.SecretsManager.BaseServiceAccount
: 0,
PurchasedAdditionalStorage = organization.MaxStorageGb.HasValue
? organization.MaxStorageGb.Value - (plan.PasswordManager.BaseStorageGb ?? 0) :
0
};
}
private static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
=> plan.Type is
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
or PlanType.FamiliesAnnually
or PlanType.TeamsStarter;
}

View File

@ -9,7 +9,7 @@ public abstract class SubscriptionUpdate
public abstract List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription);
public abstract List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription);
public bool UpdateNeeded(Subscription subscription)
public virtual bool UpdateNeeded(Subscription subscription)
{
var upgradeItemsOptions = UpgradeItemsOptions(subscription);
foreach (var upgradeItemOptions in upgradeItemsOptions)

View File

@ -31,8 +31,8 @@ public record Enterprise2019Plan : Models.StaticStore.Plan
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
LegacyYear = 2020;
SecretsManager = new Enterprise2019SecretsManagerFeatures(isAnnual);

View File

@ -31,8 +31,8 @@ public record Enterprise2020Plan : Models.StaticStore.Plan
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
LegacyYear = 2023;
PasswordManager = new Enterprise2020PasswordManagerFeatures(isAnnual);

View File

@ -31,8 +31,8 @@ public record EnterprisePlan : Models.StaticStore.Plan
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
PasswordManager = new EnterprisePasswordManagerFeatures(isAnnual);
SecretsManager = new EnterpriseSecretsManagerFeatures(isAnnual);

View File

@ -24,8 +24,8 @@ public record Teams2019Plan : Models.StaticStore.Plan
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 2;
DisplaySortOrder = 2;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
LegacyYear = 2020;
SecretsManager = new Teams2019SecretsManagerFeatures(isAnnual);

View File

@ -24,8 +24,8 @@ public record Teams2020Plan : Models.StaticStore.Plan
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 2;
DisplaySortOrder = 2;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
LegacyYear = 2023;
PasswordManager = new Teams2020PasswordManagerFeatures(isAnnual);

View File

@ -24,8 +24,8 @@ public record TeamsPlan : Models.StaticStore.Plan
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 2;
DisplaySortOrder = 2;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
PasswordManager = new TeamsPasswordManagerFeatures(isAnnual);
SecretsManager = new TeamsSecretsManagerFeatures(isAnnual);

View File

@ -64,6 +64,7 @@ public record TeamsStarterPlan : Plan
HasAdditionalStorageOption = true;
StripePlanId = "teams-org-starter";
StripeStoragePlanId = "storage-gb-monthly";
AdditionalStoragePricePerGb = 0.5M;
}
}

View File

@ -97,11 +97,6 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
throw new BadRequestException("You cannot upgrade to this plan.");
}
if (existingPlan.Type != PlanType.Free)
{
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
}
_organizationService.ValidatePasswordManagerPlan(newPlan, upgrade);
if (upgrade.UseSecretsManager)
@ -226,8 +221,16 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
}
else
{
// TODO: Update existing sub
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
paymentIntentClientSecret = await _paymentService.AdjustSubscription(
organization,
newPlan,
upgrade.AdditionalSeats,
upgrade.UseSecretsManager,
upgrade.AdditionalSmSeats,
upgrade.AdditionalServiceAccounts,
upgrade.AdditionalStorageGb);
success = string.IsNullOrEmpty(paymentIntentClientSecret);
}
organization.BusinessName = upgrade.BusinessName;

View File

@ -18,6 +18,15 @@ public interface IPaymentService
Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade);
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
short additionalStorageGb, TaxInfo taxInfo);
Task<string> AdjustSubscription(
Organization organization,
Plan updatedPlan,
int newlyPurchasedPasswordManagerSeats,
bool subscribedToSecretsManager,
int? newlyPurchasedSecretsManagerSeats,
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
int newlyPurchasedAdditionalStorage,
DateTime? prorationDate = null);
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);

View File

@ -741,7 +741,6 @@ public class StripePaymentService : IPaymentService
SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate)
{
// remember, when in doubt, throw
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId);
if (sub == null)
{
@ -860,6 +859,30 @@ public class StripePaymentService : IPaymentService
return paymentIntentClientSecret;
}
public Task<string> AdjustSubscription(
Organization organization,
StaticStore.Plan updatedPlan,
int newlyPurchasedPasswordManagerSeats,
bool subscribedToSecretsManager,
int? newlyPurchasedSecretsManagerSeats,
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
int newlyPurchasedAdditionalStorage,
DateTime? prorationDate = null)
=> FinalizeSubscriptionChangeAsync(
organization,
new CompleteSubscriptionUpdate(
organization,
new SubscriptionData
{
Plan = updatedPlan,
PurchasedPasswordManagerSeats = newlyPurchasedPasswordManagerSeats,
SubscribedToSecretsManager = subscribedToSecretsManager,
PurchasedSecretsManagerSeats = newlyPurchasedSecretsManagerSeats,
PurchasedAdditionalSecretsManagerServiceAccounts = newlyPurchasedAdditionalSecretsManagerServiceAccounts,
PurchasedAdditionalStorage = newlyPurchasedAdditionalStorage
}),
prorationDate);
public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null)
{
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);