Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -23,7 +23,8 @@ public class AccountBillingVNextController(
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
Expand Down Expand Up @@ -88,4 +89,16 @@ public async Task<IResult> GetLicenseAsync(
var response = await getUserLicenseQuery.Run(user);
return TypedResults.Ok(response);
}

[HttpPost("upgrade")]
[InjectUser]
public async Task<IResult> UpgradePremiumToOrganizationAsync(
[BindNever] User user,
[FromBody] UpgradePremiumToOrganizationRequest request)
{
var (planType, seats, premiumAccess, storage, trialEndDate) = request.ToDomain();
var result = await upgradePremiumToOrganizationCommand.Run(
user, planType, seats, premiumAccess, storage, trialEndDate);
return Handle(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
๏ปฟusing System.ComponentModel.DataAnnotations;
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 required PlanType PlanType { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

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

โŒ Our newer client-side code is not operating off of PlanType. Let's send Tier and Cadence like we do here: https://github.com/bitwarden/server/blob/main/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs


[Range(1, int.MaxValue)]
public int Seats { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

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

โŒ When upgrading from Premium, we're only going to have 1 seat. The designs do not support seat increases at the moment.


public bool PremiumAccess { get; set; } = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

โ“ What current plan still has premium access?


[Range(0, 99)]
public int Storage { get; set; } = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

โŒ This can be removed as the designs also don't support storage selection in the upgrade modal.


public DateTime? TrialEndDate { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

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

โ“ Why are we passing this in?


public (PlanType, int, bool, int?, DateTime?) ToDomain() =>
(PlanType, Seats, PremiumAccess, Storage > 0 ? Storage : null, TrialEndDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ private static void AddPremiumCommands(this IServiceCollection services)
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
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,173 @@
๏ปฟ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.Services;
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 modifying the existing Stripe subscription.
/// </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="targetPlanType">The target organization plan type to upgrade to.</param>
/// <param name="seats">The number of seats for the organization plan.</param>
/// <param name="premiumAccess">Whether to include premium access for the organization.</param>
/// <param name="storage">Additional storage in GB for the organization.</param>
/// <param name="trialEndDate">The trial end date to apply to the upgraded subscription.</param>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
PlanType targetPlanType,
int seats,
bool premiumAccess,
int? storage,
DateTime? trialEndDate);
}

public class UpgradePremiumToOrganizationCommand(
ILogger<UpgradePremiumToOrganizationCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserService userService)
: BaseBillingCommand<UpgradePremiumToOrganizationCommand>(logger), IUpgradePremiumToOrganizationCommand
{
public Task<BillingCommandResult<None>> Run(
User user,
PlanType targetPlanType,
int seats,
bool premiumAccess,
int? storage,
DateTime? trialEndDate) => HandleAsync<None>(async () =>
{
// Validate that the user has an active Premium subscription
if (!user.Premium)
{
return new BadRequest("User does not have an active Premium subscription.");
}

if (string.IsNullOrEmpty(user.GatewaySubscriptionId))
{
return new BadRequest("User does not have a Stripe subscription.");
}
Copy link
Contributor

Choose a reason for hiding this comment

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

โ›๏ธ if (user is { Premium: true, GatewaySubscriptionId: not null and not "" })


if (seats < 1)
{
return new BadRequest("Seats must be at least 1.");
}

if (trialEndDate.HasValue && trialEndDate.Value < DateTime.UtcNow)
{
return new BadRequest("Trial end date cannot be in the past.");
}

// Fetch the current Premium subscription from Stripe
var currentSubscription = await subscriberService.GetSubscriptionOrThrow(user, new SubscriptionGetOptions
Copy link
Contributor

Choose a reason for hiding this comment

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

โ›๏ธ If this is the only use of the SubscriberService you can use StripeAdapter since that will automatically throw an exception if the subscription does not exist.

{
Expand = ["items.data.price"]
Copy link
Contributor

Choose a reason for hiding this comment

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

});

// Get the target organization plan
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);

// Validate plan supports requested features
if (premiumAccess && string.IsNullOrEmpty(targetPlan.PasswordManager.StripePremiumAccessPlanId))
{
return new BadRequest("The selected plan does not support premium access.");
}

if (storage is > 0 && string.IsNullOrEmpty(targetPlan.PasswordManager.StripeStoragePlanId))
{
return new BadRequest("The selected plan does not support additional storage.");
}

// Build the list of subscription item updates
var subscriptionItemOptions = new List<SubscriptionItemOptions>();

// Mark existing Premium subscription items for deletion
foreach (var item in currentSubscription.Items.Data)
Copy link
Collaborator

Choose a reason for hiding this comment

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

โŒ We may need to be more precise in identifying subscription items by narrowing our search specifically to the items we want to delete related to premium (plan id, etc.) I'm not sure if there are some items we'd want to retain.

{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = item.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
});
}

if (premiumAccess)
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Price = targetPlan.PasswordManager.StripePremiumAccessPlanId,
Quantity = 1
});
}

if (storage is > 0)
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Price = targetPlan.PasswordManager.StripeStoragePlanId,
Quantity = storage
});
}

// Build the subscription update options
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = StripeConstants.ProrationBehavior.None,
Metadata = new Dictionary<string, string>(currentSubscription.Metadata ?? new Dictionary<string, string>())
{
["premium_upgrade_metadata"] = currentSubscription.Items.Data
.Select(item => item.Price.Id)
.FirstOrDefault() ?? "premium"
}
};

// Apply trial period if specified
if (trialEndDate.HasValue)
Copy link
Collaborator

Choose a reason for hiding this comment

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

โ“ Shouldn't this date be calculated using the TrialPeriodDays property of the target plan?

{
subscriptionUpdateOptions.TrialEnd = trialEndDate.Value;
}

// Update the subscription in Stripe
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);

// Update user record
user.PremiumExpirationDate = trialEndDate ?? currentSubscription.GetCurrentPeriodEnd();
user.RevisionDate = DateTime.UtcNow;
await userService.SaveUserAsync(user);
Copy link
Contributor

Choose a reason for hiding this comment

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

โŒ This results in a User record being attached to an Organization subscription, which would not be renderable or usable on the client. The idea is to create an Organization record and attach the modified subscription to that Organization while removing the subscription from the User.


return new None();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class AccountBillingVNextControllerTests
private readonly IGetPaymentMethodQuery _getPaymentMethodQuery;
private readonly IGetUserLicenseQuery _getUserLicenseQuery;
private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand;
private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand;
private readonly AccountBillingVNextController _sut;

public AccountBillingVNextControllerTests()
Expand All @@ -29,14 +30,16 @@ public AccountBillingVNextControllerTests()
_getPaymentMethodQuery = Substitute.For<IGetPaymentMethodQuery>();
_getUserLicenseQuery = Substitute.For<IGetUserLicenseQuery>();
_updatePaymentMethodCommand = Substitute.For<IUpdatePaymentMethodCommand>();
_upgradePremiumToOrganizationCommand = Substitute.For<IUpgradePremiumToOrganizationCommand>();

_sut = new AccountBillingVNextController(
_createBitPayInvoiceForCreditCommand,
_createPremiumCloudHostedSubscriptionCommand,
_getCreditQuery,
_getPaymentMethodQuery,
_getUserLicenseQuery,
_updatePaymentMethodCommand);
_updatePaymentMethodCommand,
_upgradePremiumToOrganizationCommand);
}

[Theory, BitAutoData]
Expand Down
Loading
Loading