Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
17 changes: 1 addition & 16 deletions src/Api/Billing/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
๏ปฟusing Bit.Api.Models.Request;
using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
Expand Down Expand Up @@ -114,21 +114,6 @@ public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
}
}

// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
[HttpPost("storage")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
Copy link
Copy Markdown
Contributor

@amorask-bitwarden amorask-bitwarden Dec 18, 2025

Choose a reason for hiding this comment

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

This work needs to go behind a feature flag. Since it is part of the subscription page redesign, I suggest creating this flag which I'll have created for clients: pm-29594-update-individual-subscription-page

We can't remove this endpoint until that feature flag is removed or the old version of the subscription page would fail on adding storage.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue still applies with applicable comments regarding FF on the command.

{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

var result = await userService.AdjustStorageAsync(user, model.StorageGbAdjustment!.Value);
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
}

/*
* TODO: A new version of this exists in the AccountBillingVNextController.
* The individual-self-hosting-license-uploader.component needs to be updated to use it.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
๏ปฟusing Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Api.Billing.Models.Requests.Storage;
using Bit.Core;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Storage.Commands;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
Expand All @@ -21,7 +23,8 @@ public class AccountBillingVNextController(
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdateStorageCommand updateStorageCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
Expand Down Expand Up @@ -77,4 +80,14 @@ public async Task<IResult> CreateSubscriptionAsync(
user, paymentMethod, billingAddress, additionalStorageGb);
return Handle(result);
}

[HttpPut("storage")]
[InjectUser]
public async Task<IResult> UpdateStorageAsync(
[BindNever] User user,
[FromBody] StorageUpdateRequest request)
{
var result = await updateStorageCommand.Run(user, request.StorageGb);
return Handle(result);
}
}
35 changes: 35 additions & 0 deletions src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.Billing.Models.Requests.Storage;

/// <summary>
/// Request model for updating storage allocation on a user's premium subscription.
/// Allows for both increasing and decreasing storage in an idempotent manner.
/// </summary>
public class StorageUpdateRequest : IValidatableObject
{
/// <summary>
/// The desired total storage in GB (including base storage).
/// Must be between the base storage amount and the maximum allowed (100 GB).
/// </summary>
[Required]
[Range(1, 100)]
public short StorageGb { get; set; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think the user selects the total amount of storage they want in the web app. Rather, they submit how much additional storage they want to purchase. Please adjust this accordingly.


public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (StorageGb <= 0)
{
yield return new ValidationResult(
"Storage must be greater than 0 GB.",
new[] { nameof(StorageGb) });
}

if (StorageGb > 100)
{
yield return new ValidationResult(
"Maximum storage is 100 GB.",
new[] { nameof(StorageGb) });
}
}
}
7 changes: 7 additions & 0 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Storage.Commands;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
Expand All @@ -33,6 +34,7 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddOrganizationLicenseCommandsQueries();
services.AddPremiumCommands();
services.AddPremiumQueries();
services.AddStorageCommands();
services.AddTransient<IGetOrganizationMetadataQuery, GetOrganizationMetadataQuery>();
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
Expand All @@ -57,4 +59,9 @@ private static void AddPremiumQueries(this IServiceCollection services)
{
services.AddScoped<IHasPremiumAccessQuery, HasPremiumAccessQuery>();
}

private static void AddStorageCommands(this IServiceCollection services)
{
services.AddScoped<IUpdateStorageCommand, UpdateStorageCommand>();
}
}
103 changes: 103 additions & 0 deletions src/Core/Billing/Storage/Commands/UpdateStorageCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;

namespace Bit.Core.Billing.Storage.Commands;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This should live in the Premium folder and probably be named UpdatePremiumStorageCommand.


/// <summary>
/// Updates the storage allocation for a premium user's subscription.
/// Handles both increases and decreases in storage in an idempotent manner.
/// </summary>
public interface IUpdateStorageCommand
{
/// <summary>
/// Updates the user's storage to the specified amount.
/// </summary>
/// <param name="user">The premium user whose storage should be updated.</param>
/// <param name="storageGb">The desired total storage amount in GB (must be between base storage and 100 GB).</param>
/// <returns>A billing command result with payment intent client secret if payment is required.</returns>
Task<BillingCommandResult<string?>> Run(User user, short storageGb);
}

public class UpdateStorageCommand(
IStripePaymentService paymentService,
IUserService userService,
IPricingClient pricingClient,
ILogger<UpdateStorageCommand> logger)
: BaseBillingCommand<UpdateStorageCommand>(logger), IUpdateStorageCommand
{
public Task<BillingCommandResult<string?>> Run(User user, short storageGb) => HandleAsync<string?>(async () =>
{
if (user == null)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

User should never be null if it's not marked as nullable.

{
return new BadRequest("User not found.");
}

if (!user.Premium)
{
return new BadRequest("User does not have a premium subscription.");
}

if (string.IsNullOrWhiteSpace(user.GatewayCustomerId))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the GatewayCustomerId and GatewaySubscriptionId checks are redundant with checking if the user has premium. The user has to have a customer and subscription to have premium.

{
return new BadRequest("No payment method found.");
}

if (string.IsNullOrWhiteSpace(user.GatewaySubscriptionId))
{
return new BadRequest("No subscription found.");
}

var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We're going to be supporting users across multiple plans - you'll have to fetch all plans and find the one the user is on.

var baseStorageGb = (short)premiumPlan.Storage.Provided;

if (storageGb < baseStorageGb)
{
return new BadRequest($"Storage cannot be less than the base amount of {baseStorageGb} GB.");
}

if (storageGb > 100)
{
return new BadRequest("Maximum storage is 100 GB.");
}

// Check if the requested storage would fit the user's current usage
if (!user.MaxStorageGb.HasValue)
{
return new BadRequest("No access to storage.");
}

// Idempotency check: if user already has the requested storage, return success
if (user.MaxStorageGb == storageGb)
{
return (string?)null; // No payment intent needed for no-op
}

var remainingStorage = user.StorageBytesRemaining(storageGb);
if (remainingStorage < 0)
{
return new BadRequest(
$"You are currently using {CoreHelpers.ReadableBytesSize(user.Storage.GetValueOrDefault(0))} of storage. " +
"Delete some stored data first.");
}

// Calculate the additional storage beyond base
var additionalStorage = storageGb - baseStorageGb;

// Call the payment service to adjust the subscription
var paymentIntentClientSecret = await paymentService.AdjustStorageAsync(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is our chance to get away from the bloated and complicated FinalizeSubscriptionChangeAsync and the StripePaymentService; I suggest we take the opportunity.

All that would be required is that you update the premium subscription with correct storage amount and invoice a proration for it since storage is always billed annually.

user,
additionalStorage,
premiumPlan.Storage.StripePriceId);

// Update the user's max storage
user.MaxStorageGb = storageGb;
await userService.SaveUserAsync(user);

return paymentIntentClientSecret;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not used and not needed.

});
}
Loading
Loading