Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
35d8812
Add the ticket implementation
cyprain-okeke Dec 31, 2025
8276423
Add the unit test
cyprain-okeke Dec 31, 2025
ffdef93
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Dec 31, 2025
dc93149
Fix the lint and test issues
cyprain-okeke Dec 31, 2025
bca4274
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Dec 31, 2025
64fa791
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 1, 2026
2c6c2fe
resolve pr comments
cyprain-okeke Jan 1, 2026
383530d
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 6, 2026
79a8be0
Fix the error on the test file
cyprain-okeke Jan 6, 2026
d8ebcc9
Review suggestion and fixes
cyprain-okeke Jan 6, 2026
00f4ad6
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 6, 2026
025e3e0
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 7, 2026
9ba9ef1
resolve the api access comments
cyprain-okeke Jan 7, 2026
0687f31
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 7, 2026
a64e0a0
Gte the key from the client
cyprain-okeke Jan 7, 2026
9b54c2e
Add the gateway type as stripe
cyprain-okeke Jan 7, 2026
244ca57
Address the legacy plans issues
cyprain-okeke Jan 7, 2026
cbdec08
Resolve the misunderstanding
cyprain-okeke Jan 7, 2026
823db8c
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 8, 2026
5299129
Add additional storage that we will need if they revert
cyprain-okeke Jan 9, 2026
f728816
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 9, 2026
9b3a68f
Add the previous premium UserId
cyprain-okeke Jan 9, 2026
d2f197a
Merge branch 'main' into billing/pm-29598/create-subscription-upgradeโ€ฆ
cyprain-okeke Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public class AccountBillingVNextController(
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
Expand Down Expand Up @@ -100,4 +101,15 @@ public async Task<IResult> UpdateStorageAsync(
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);
}
}
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โ“ Would we not include seats in the request(even though we assume it will be 1 for now)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sbrown-livefront I recommended not doing so - since the business requirements are for a 1-seat upgrade, I feel like that would primarily just give the client extra, unnecessary responsibility.

{
[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);
}
2 changes: 2 additions & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ public static class MetadataKeys
public const string BraintreeCustomerId = "btCustomerId";
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string PreviousPeriodEndDate = "previous_period_end_date";
public const string PreviousPremiumPriceId = "previous_premium_price_id";
public const string ProviderId = "providerId";
public const string Region = "region";
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ private static void AddPremiumCommands(this IServiceCollection services)
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
}

private static void AddPremiumQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
๏ปฟ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);

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.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();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ public class AccountBillingVNextControllerTests
{
private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand;
private readonly IGetUserLicenseQuery _getUserLicenseQuery;
private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand;
private readonly AccountBillingVNextController _sut;

public AccountBillingVNextControllerTests()
{
_updatePremiumStorageCommand = Substitute.For<IUpdatePremiumStorageCommand>();
_getUserLicenseQuery = Substitute.For<IGetUserLicenseQuery>();
_upgradePremiumToOrganizationCommand = Substitute.For<IUpgradePremiumToOrganizationCommand>();

_sut = new AccountBillingVNextController(
Substitute.For<Core.Billing.Payment.Commands.ICreateBitPayInvoiceForCreditCommand>(),
Expand All @@ -31,7 +33,8 @@ public AccountBillingVNextControllerTests()
Substitute.For<Core.Billing.Payment.Queries.IGetPaymentMethodQuery>(),
_getUserLicenseQuery,
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
_updatePremiumStorageCommand);
_updatePremiumStorageCommand,
_upgradePremiumToOrganizationCommand);
}

[Theory, BitAutoData]
Expand Down
Loading
Loading