Skip to content

Commit 76a8f0f

Browse files
[PM 29610]Update Account Storage Endpoint (#6750)
* update account storage endpoint * Fix the failing test * Added flag and refactor base on pr comments * fix the lint error * Resolve the pr comments * Fix the failing test * Fix the failing test * Return none * Resolve the lint error * Fix the failing test * Add the missing test * Formatting issues fixed
1 parent e9d53c0 commit 76a8f0f

File tree

8 files changed

+823
-21
lines changed

8 files changed

+823
-21
lines changed

src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Bit.Api.Billing.Attributes;
22
using Bit.Api.Billing.Models.Requests.Payment;
33
using Bit.Api.Billing.Models.Requests.Premium;
4+
using Bit.Api.Billing.Models.Requests.Storage;
45
using Bit.Core;
56
using Bit.Core.Billing.Licenses.Queries;
67
using Bit.Core.Billing.Payment.Commands;
@@ -23,7 +24,8 @@ public class AccountBillingVNextController(
2324
IGetCreditQuery getCreditQuery,
2425
IGetPaymentMethodQuery getPaymentMethodQuery,
2526
IGetUserLicenseQuery getUserLicenseQuery,
26-
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
27+
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
28+
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
2729
{
2830
[HttpGet("credit")]
2931
[InjectUser]
@@ -88,4 +90,15 @@ public async Task<IResult> GetLicenseAsync(
8890
var response = await getUserLicenseQuery.Run(user);
8991
return TypedResults.Ok(response);
9092
}
93+
94+
[HttpPut("storage")]
95+
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
96+
[InjectUser]
97+
public async Task<IResult> UpdateStorageAsync(
98+
[BindNever] User user,
99+
[FromBody] StorageUpdateRequest request)
100+
{
101+
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
102+
return Handle(result);
103+
}
91104
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Bit.Api.Billing.Models.Requests.Storage;
4+
5+
/// <summary>
6+
/// Request model for updating storage allocation on a user's premium subscription.
7+
/// Allows for both increasing and decreasing storage in an idempotent manner.
8+
/// </summary>
9+
public class StorageUpdateRequest : IValidatableObject
10+
{
11+
/// <summary>
12+
/// The additional storage in GB beyond the base storage.
13+
/// Must be between 0 and the maximum allowed (minus base storage).
14+
/// </summary>
15+
[Required]
16+
[Range(0, 99)]
17+
public short AdditionalStorageGb { get; set; }
18+
19+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
20+
{
21+
if (AdditionalStorageGb < 0)
22+
{
23+
yield return new ValidationResult(
24+
"Additional storage cannot be negative.",
25+
new[] { nameof(AdditionalStorageGb) });
26+
}
27+
28+
if (AdditionalStorageGb > 99)
29+
{
30+
yield return new ValidationResult(
31+
"Maximum additional storage is 99 GB.",
32+
new[] { nameof(AdditionalStorageGb) });
33+
}
34+
}
35+
}

src/Core/Billing/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ private static void AddPremiumCommands(this IServiceCollection services)
5353
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
5454
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
5555
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
56+
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
5657
}
5758

5859
private static void AddPremiumQueries(this IServiceCollection services)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
using Bit.Core.Billing.Commands;
2+
using Bit.Core.Billing.Pricing;
3+
using Bit.Core.Billing.Services;
4+
using Bit.Core.Entities;
5+
using Bit.Core.Services;
6+
using Bit.Core.Utilities;
7+
using Microsoft.Extensions.Logging;
8+
using OneOf.Types;
9+
using Stripe;
10+
11+
namespace Bit.Core.Billing.Premium.Commands;
12+
13+
/// <summary>
14+
/// Updates the storage allocation for a premium user's subscription.
15+
/// Handles both increases and decreases in storage in an idempotent manner.
16+
/// </summary>
17+
public interface IUpdatePremiumStorageCommand
18+
{
19+
/// <summary>
20+
/// Updates the user's storage by the specified additional amount.
21+
/// </summary>
22+
/// <param name="user">The premium user whose storage should be updated.</param>
23+
/// <param name="additionalStorageGb">The additional storage amount in GB beyond base storage.</param>
24+
/// <returns>A billing command result indicating success or failure.</returns>
25+
Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb);
26+
}
27+
28+
public class UpdatePremiumStorageCommand(
29+
IStripeAdapter stripeAdapter,
30+
IUserService userService,
31+
IPricingClient pricingClient,
32+
ILogger<UpdatePremiumStorageCommand> logger)
33+
: BaseBillingCommand<UpdatePremiumStorageCommand>(logger), IUpdatePremiumStorageCommand
34+
{
35+
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
36+
{
37+
if (!user.Premium)
38+
{
39+
return new BadRequest("User does not have a premium subscription.");
40+
}
41+
42+
if (!user.MaxStorageGb.HasValue)
43+
{
44+
return new BadRequest("No access to storage.");
45+
}
46+
47+
// Fetch all premium plans and the user's subscription to find which plan they're on
48+
var premiumPlans = await pricingClient.ListPremiumPlans();
49+
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
50+
51+
// Find the password manager subscription item (seat, not storage) and match it to a plan
52+
var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i =>
53+
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
54+
55+
if (passwordManagerItem == null)
56+
{
57+
return new BadRequest("Premium subscription item not found.");
58+
}
59+
60+
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
61+
62+
var baseStorageGb = (short)premiumPlan.Storage.Provided;
63+
64+
if (additionalStorageGb < 0)
65+
{
66+
return new BadRequest("Additional storage cannot be negative.");
67+
}
68+
69+
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
70+
71+
if (newTotalStorageGb > 100)
72+
{
73+
return new BadRequest("Maximum storage is 100 GB.");
74+
}
75+
76+
// Idempotency check: if user already has the requested storage, return success
77+
if (user.MaxStorageGb == newTotalStorageGb)
78+
{
79+
return new None();
80+
}
81+
82+
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
83+
if (remainingStorage < 0)
84+
{
85+
return new BadRequest(
86+
$"You are currently using {CoreHelpers.ReadableBytesSize(user.Storage.GetValueOrDefault(0))} of storage. " +
87+
"Delete some stored data first.");
88+
}
89+
90+
// Find the storage line item in the subscription
91+
var storageItem = subscription.Items.Data.FirstOrDefault(i => i.Price.Id == premiumPlan.Storage.StripePriceId);
92+
93+
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
94+
95+
if (additionalStorageGb > 0)
96+
{
97+
if (storageItem != null)
98+
{
99+
// Update existing storage item
100+
subscriptionItemOptions.Add(new SubscriptionItemOptions
101+
{
102+
Id = storageItem.Id,
103+
Price = premiumPlan.Storage.StripePriceId,
104+
Quantity = additionalStorageGb
105+
});
106+
}
107+
else
108+
{
109+
// Add new storage item
110+
subscriptionItemOptions.Add(new SubscriptionItemOptions
111+
{
112+
Price = premiumPlan.Storage.StripePriceId,
113+
Quantity = additionalStorageGb
114+
});
115+
}
116+
}
117+
else if (storageItem != null)
118+
{
119+
// Remove storage item if setting to 0
120+
subscriptionItemOptions.Add(new SubscriptionItemOptions
121+
{
122+
Id = storageItem.Id,
123+
Deleted = true
124+
});
125+
}
126+
127+
// Update subscription with prorations
128+
// Storage is billed annually, so we create prorations and invoice immediately
129+
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
130+
{
131+
Items = subscriptionItemOptions,
132+
ProrationBehavior = Core.Constants.CreateProrations
133+
};
134+
135+
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
136+
137+
// Update the user's max storage
138+
user.MaxStorageGb = newTotalStorageGb;
139+
await userService.SaveUserAsync(user);
140+
141+
// No payment intent needed - the subscription update will automatically create and finalize the invoice
142+
return new None();
143+
});
144+
}

src/Core/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ public static class FeatureFlagKeys
196196
public const string PM26462_Milestone_3 = "pm-26462-milestone-3";
197197
public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job";
198198
public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode";
199+
public const string PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page";
199200

200201
/* Key Management Team */
201202
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";

0 commit comments

Comments
 (0)