1
0
mirror of https://github.com/bitwarden/server synced 2026-01-15 23:13:56 +00:00

[PM-29598] Create Subscription Upgrade Endpoint (#6787)

* Add the ticket implementation

* Add the unit test

* Fix the lint and test issues

* resolve pr comments

* Fix the error on the test file

* Review suggestion and fixes

* resolve the api access comments

* Gte the key from the client

* Add the gateway type as stripe

* Address the legacy plans issues

* Resolve the misunderstanding

* Add additional storage that we will need if they revert

* Add the previous premium UserId
This commit is contained in:
cyprain-okeke
2026-01-09 16:34:06 +01:00
committed by GitHub
parent b1cf59b1bf
commit e705fe3f3f
7 changed files with 933 additions and 2 deletions

View File

@@ -25,7 +25,8 @@ public class AccountBillingVNextController(
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
@@ -100,4 +101,15 @@ public class AccountBillingVNextController(
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
return Handle(result);
}
[HttpPost("upgrade")]
[InjectUser]
public async Task<IResult> UpgradePremiumToOrganizationAsync(
[BindNever] User user,
[FromBody] UpgradePremiumToOrganizationRequest request)
{
var (organizationName, key, planType) = request.ToDomain();
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
return Handle(result);
}
}

View File

@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
namespace Bit.Api.Billing.Models.Requests.Premium;
public class UpgradePremiumToOrganizationRequest
{
[Required]
public string OrganizationName { get; set; } = null!;
[Required]
public string Key { get; set; } = null!;
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public ProductTierType Tier { get; set; }
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public PlanCadenceType Cadence { get; set; }
private PlanType PlanType =>
Tier switch
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
? PlanType.TeamsMonthly
: PlanType.TeamsAnnually,
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
? PlanType.EnterpriseMonthly
: PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
};
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
}

View File

@@ -67,6 +67,10 @@ public static class StripeConstants
public const string BraintreeCustomerId = "btCustomerId";
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string PreviousAdditionalStorage = "previous_additional_storage";
public const string PreviousPeriodEndDate = "previous_period_end_date";
public const string PreviousPremiumPriceId = "previous_premium_price_id";
public const string PreviousPremiumUserId = "previous_premium_user_id";
public const string ProviderId = "providerId";
public const string Region = "region";
public const string RetiredBraintreeCustomerId = "btCustomerId_old";

View File

@@ -54,6 +54,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
}
private static void AddPremiumQueries(this IServiceCollection services)

View File

@@ -0,0 +1,228 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
/// <summary>
/// Upgrades a user's Premium subscription to an Organization plan by creating a new Organization
/// and transferring the subscription from the User to the Organization.
/// </summary>
public interface IUpgradePremiumToOrganizationCommand
{
/// <summary>
/// Upgrades a Premium subscription to an Organization subscription.
/// </summary>
/// <param name="user">The user with an active Premium subscription to upgrade.</param>
/// <param name="organizationName">The name for the new organization.</param>
/// <param name="key">The encrypted organization key for the owner.</param>
/// <param name="targetPlanType">The target organization plan type to upgrade to.</param>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
string organizationName,
string key,
PlanType targetPlanType);
}
public class UpgradePremiumToOrganizationCommand(
ILogger<UpgradePremiumToOrganizationCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
IUserService userService,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationApiKeyRepository organizationApiKeyRepository,
IApplicationCacheService applicationCacheService)
: BaseBillingCommand<UpgradePremiumToOrganizationCommand>(logger), IUpgradePremiumToOrganizationCommand
{
public Task<BillingCommandResult<None>> Run(
User user,
string organizationName,
string key,
PlanType targetPlanType) => HandleAsync<None>(async () =>
{
// Validate that the user has an active Premium subscription
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
{
return new BadRequest("User does not have an active Premium subscription.");
}
// Hardcode seats to 1 for upgrade flow
const int seats = 1;
// Fetch the current Premium subscription from Stripe
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
// Fetch all premium plans to find which specific plan the user is on
var premiumPlans = await pricingClient.ListPremiumPlans();
// Find the password manager subscription item (seat, not storage) and match it to a plan
var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i =>
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription item not found.");
}
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
// Get the target organization plan
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
// Build the list of subscription item updates
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
// Delete the user's specific password manager item
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Deleted = true
});
// Delete the storage item if it exists for this user's plan
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
// Capture the previous additional storage quantity for potential revert
var previousAdditionalStorage = storageItem?.Quantity ?? 0;
if (storageItem != null)
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = storageItem.Id,
Deleted = true
});
}
// Add new organization subscription items
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
}
else
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Price = targetPlan.PasswordManager.StripeSeatPlanId,
Quantity = seats
});
}
// Generate organization ID early to include in metadata
var organizationId = CoreHelpers.GenerateComb();
// Build the subscription update options
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = StripeConstants.ProrationBehavior.None,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId,
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty,
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(),
[StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User
}
};
// Create the Organization entity
var organization = new Organization
{
Id = organizationId,
Name = organizationName,
BillingEmail = user.Email,
PlanType = targetPlan.Type,
Seats = (short)seats,
MaxCollections = targetPlan.PasswordManager.MaxCollections,
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
UsePolicies = targetPlan.HasPolicies,
UseSso = targetPlan.HasSso,
UseGroups = targetPlan.HasGroups,
UseEvents = targetPlan.HasEvents,
UseDirectory = targetPlan.HasDirectory,
UseTotp = targetPlan.HasTotp,
Use2fa = targetPlan.Has2fa,
UseApi = targetPlan.HasApi,
UseResetPassword = targetPlan.HasResetPassword,
SelfHost = targetPlan.HasSelfHost,
UsersGetPremium = targetPlan.UsersGetPremium,
UseCustomPermissions = targetPlan.HasCustomPermissions,
UseScim = targetPlan.HasScim,
Plan = targetPlan.Name,
Gateway = GatewayType.Stripe,
Enabled = true,
LicenseKey = CoreHelpers.SecureRandomString(20),
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created,
UsePasswordManager = true,
UseSecretsManager = false,
UseOrganizationDomains = targetPlan.HasOrganizationDomains,
GatewayCustomerId = user.GatewayCustomerId,
GatewaySubscriptionId = currentSubscription.Id
};
// Update the subscription in Stripe
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);
// Save the organization
await organizationRepository.CreateAsync(organization);
// Create organization API key
await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey
{
OrganizationId = organization.Id,
ApiKey = CoreHelpers.SecureRandomString(30),
Type = OrganizationApiKeyType.Default,
RevisionDate = DateTime.UtcNow,
});
// Update cache
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
// Create OrganizationUser for the upgrading user as owner
var organizationUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Key = key,
AccessSecretsManager = false,
Type = OrganizationUserType.Owner,
Status = OrganizationUserStatusType.Confirmed,
CreationDate = organization.CreationDate,
RevisionDate = organization.CreationDate
};
organizationUser.SetNewId();
await organizationUserRepository.CreateAsync(organizationUser);
// Remove subscription from user
user.Premium = false;
user.PremiumExpirationDate = null;
user.GatewaySubscriptionId = null;
user.GatewayCustomerId = null;
user.RevisionDate = DateTime.UtcNow;
await userService.SaveUserAsync(user);
return new None();
});
}