Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
๏ปฟ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;
Expand All @@ -21,7 +22,8 @@ public class AccountBillingVNextController(
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
Expand Down Expand Up @@ -77,4 +79,15 @@ public async Task<IResult> CreateSubscriptionAsync(
user, paymentMethod, billingAddress, additionalStorageGb);
return Handle(result);
}

[HttpPut("storage")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateStorageAsync(
[BindNever] User user,
[FromBody] StorageUpdateRequest request)
{
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
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 additional storage in GB beyond the base storage.
/// Must be between 0 and the maximum allowed (minus base storage).
/// </summary>
[Required]
[Range(0, 99)]
public short AdditionalStorageGb { get; set; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (AdditionalStorageGb < 0)
{
yield return new ValidationResult(
"Additional storage cannot be negative.",
new[] { nameof(AdditionalStorageGb) });
}

if (AdditionalStorageGb > 99)
{
yield return new ValidationResult(
"Maximum additional storage is 99 GB.",
new[] { nameof(AdditionalStorageGb) });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ private static void AddPremiumCommands(this IServiceCollection services)
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
}

private static void AddPremiumQueries(this IServiceCollection services)
Expand Down
144 changes: 144 additions & 0 deletions src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
๏ปฟ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;
using OneOf.Types;
using Stripe;

namespace Bit.Core.Billing.Premium.Commands;

/// <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 IUpdatePremiumStorageCommand
{
/// <summary>
/// Updates the user's storage by the specified additional amount.
/// </summary>
/// <param name="user">The premium user whose storage should be updated.</param>
/// <param name="additionalStorageGb">The additional storage amount in GB beyond base storage.</param>
/// <returns>A billing command result indicating success or failure.</returns>
Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb);
}

public class UpdatePremiumStorageCommand(
IStripeAdapter stripeAdapter,
IUserService userService,
IPricingClient pricingClient,
ILogger<UpdatePremiumStorageCommand> logger)
: BaseBillingCommand<UpdatePremiumStorageCommand>(logger), IUpdatePremiumStorageCommand
{
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
{
if (!user.Premium)
{
return new BadRequest("User does not have a premium subscription.");
}

if (!user.MaxStorageGb.HasValue)
{
return new BadRequest("No access to storage.");
}

// Fetch all premium plans and the user's subscription to find which plan they're on
var premiumPlans = await pricingClient.ListPremiumPlans();
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);

// Find the password manager subscription item (seat, not storage) and match it to a plan
var passwordManagerItem = subscription.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 premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);

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

if (additionalStorageGb < 0)
{
return new BadRequest("Additional storage cannot be negative.");
}

var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);

if (newTotalStorageGb > 100)
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.

โ“ Is this a business rule we have?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, this 100 GB limit exists in the old BillingHelpers.AdjustStorageAsync code at /server/src/Core/Utilities/BillingHelpers.cs:38-41. The new UpdatePremiumStorageCommand copied this
validation.

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

// Idempotency check: if user already has the requested storage, return success
if (user.MaxStorageGb == newTotalStorageGb)
{
return new None();
}

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

// Find the storage line item in the subscription
var storageItem = subscription.Items.Data.FirstOrDefault(i => i.Price.Id == premiumPlan.Storage.StripePriceId);

var subscriptionItemOptions = new List<SubscriptionItemOptions>();

if (additionalStorageGb > 0)
{
if (storageItem != null)
{
// Update existing storage item
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = storageItem.Id,
Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorageGb
});
}
else
{
// Add new storage item
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorageGb
});
}
}
else if (storageItem != null)
{
// Remove storage item if setting to 0
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = storageItem.Id,
Deleted = true
});
}

// Update subscription with prorations
// Storage is billed annually, so we create prorations and invoice immediately
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = Core.Constants.CreateProrations
};

await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);

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

// No payment intent needed - the subscription update will automatically create and finalize the invoice
return new None();
});
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ public static class FeatureFlagKeys
public const string PM26462_Milestone_3 = "pm-26462-milestone-3";
public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job";
public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode";
public const string PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page";

/* Key Management Team */
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
Expand Down
Loading
Loading