2026-01-09 16:34:06 +01:00
|
|
|
|
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>
|
2026-01-21 17:17:01 -06:00
|
|
|
|
/// <param name="billingAddress">The billing address for tax calculation.</param>
|
2026-01-09 16:34:06 +01:00
|
|
|
|
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
|
|
|
|
|
Task<BillingCommandResult<None>> Run(
|
|
|
|
|
|
User user,
|
|
|
|
|
|
string organizationName,
|
|
|
|
|
|
string key,
|
2026-01-21 17:17:01 -06:00
|
|
|
|
PlanType targetPlanType,
|
|
|
|
|
|
Payment.Models.BillingAddress billingAddress);
|
2026-01-09 16:34:06 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-01-21 17:17:01 -06:00
|
|
|
|
PlanType targetPlanType,
|
|
|
|
|
|
Payment.Models.BillingAddress billingAddress) => HandleAsync<None>(async () =>
|
2026-01-09 16:34:06 +01:00
|
|
|
|
{
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
{
|
2026-01-21 16:43:06 -06:00
|
|
|
|
return new BadRequest("Premium subscription password manager item not found.");
|
2026-01-09 16:34:06 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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
|
|
|
|
|
|
{
|
2026-01-30 11:37:20 -06:00
|
|
|
|
Id = passwordManagerItem.Id,
|
2026-01-09 16:34:06 +01:00
|
|
|
|
Price = targetPlan.PasswordManager.StripePlanId,
|
|
|
|
|
|
Quantity = 1
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
|
|
|
|
|
{
|
2026-01-30 11:37:20 -06:00
|
|
|
|
Id = passwordManagerItem.Id,
|
2026-01-09 16:34:06 +01:00
|
|
|
|
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,
|
2026-01-30 11:37:20 -06:00
|
|
|
|
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice,
|
|
|
|
|
|
BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged,
|
2026-01-21 17:17:01 -06:00
|
|
|
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
2026-01-09 16:34:06 +01:00
|
|
|
|
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,
|
2026-01-30 11:37:20 -06:00
|
|
|
|
Seats = seats,
|
2026-01-09 16:34:06 +01:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-21 17:17:01 -06:00
|
|
|
|
// Update customer billing address for tax calculation
|
|
|
|
|
|
await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions
|
|
|
|
|
|
{
|
|
|
|
|
|
Address = new AddressOptions
|
|
|
|
|
|
{
|
|
|
|
|
|
Country = billingAddress.Country,
|
|
|
|
|
|
PostalCode = billingAddress.PostalCode
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-09 16:34:06 +01:00
|
|
|
|
// 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|