[PM 29610]Update Account Storage Endpoint#6750
Conversation
|
Claude finished @cyprain-okeke's task in 2m 59s —— View job Code Review Complete
Review complete. Inline comments and summary have been posted to the PR. |
|
New Issues (1)Checkmarx found the following issues in this Pull Request
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6750 +/- ##
==========================================
+ Coverage 54.74% 54.78% +0.04%
==========================================
Files 1923 1925 +2
Lines 85291 85422 +131
Branches 7635 7637 +2
==========================================
+ Hits 46693 46802 +109
- Misses 36825 36847 +22
Partials 1773 1773 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| using Bit.Core.Utilities; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Bit.Core.Billing.Storage.Commands; |
There was a problem hiding this comment.
This should live in the Premium folder and probably be named UpdatePremiumStorageCommand.
| { | ||
| public Task<BillingCommandResult<string?>> Run(User user, short storageGb) => HandleAsync<string?>(async () => | ||
| { | ||
| if (user == null) |
There was a problem hiding this comment.
User should never be null if it's not marked as nullable.
| return new BadRequest("No subscription found."); | ||
| } | ||
|
|
||
| var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); |
There was a problem hiding this comment.
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.
| user.MaxStorageGb = storageGb; | ||
| await userService.SaveUserAsync(user); | ||
|
|
||
| return paymentIntentClientSecret; |
There was a problem hiding this comment.
This is not used and not needed.
| var additionalStorage = storageGb - baseStorageGb; | ||
|
|
||
| // Call the payment service to adjust the subscription | ||
| var paymentIntentClientSecret = await paymentService.AdjustStorageAsync( |
There was a problem hiding this comment.
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.
| /// </summary> | ||
| [Required] | ||
| [Range(1, 100)] | ||
| public short StorageGb { get; set; } |
There was a problem hiding this comment.
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.
| // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription | ||
| [HttpPost("storage")] | ||
| [SelfHosted(NotSelfHostedOnly = true)] | ||
| public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Same issue still applies with applicable comments regarding FF on the command.
| return new BadRequest("User does not have a premium subscription."); | ||
| } | ||
|
|
||
| if (string.IsNullOrWhiteSpace(user.GatewayCustomerId)) |
There was a problem hiding this comment.
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.
|
|
||
| // Fetch all premium plans and find the one the user is on | ||
| var premiumPlans = await pricingClient.ListPremiumPlans(); | ||
| var premiumPlan = premiumPlans.FirstOrDefault(p => p.Available); |
There was a problem hiding this comment.
❌ This isn't the plan the user is on - it's the plan that's available. The Premium user could be on an older plan. You need to match the Price ID of their password manager subscription item with the price ID from one of the premium plans.
|
|
||
| var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb); | ||
|
|
||
| if (newTotalStorageGb > 100) |
There was a problem hiding this comment.
❓ Is this a business rule we have?
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| // Check if the requested storage would fit the user's current usage | ||
| if (!user.MaxStorageGb.HasValue) |
There was a problem hiding this comment.
⛏️ This could probably be moved up in the command to prevent any unnecessary calls.
| } | ||
|
|
||
| // Check feature flag to determine which code path to use | ||
| if (featureService.IsEnabled(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)) |
There was a problem hiding this comment.
❌ This feature flag controls which version of the Premium Subscription page is going to show on the Web Vault. The new version of the page is going to hit the new endpoint which hits the new command. The old version of the page will continue to hit the old endpoint with the old approach. As such, I don't think we should need to check the flag within the command.
| // NEW PATH: Directly update the premium subscription with prorations | ||
| // This is simpler than using FinalizeSubscriptionChangeAsync since storage is always billed annually | ||
| var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); | ||
| if (subscription == null) |
There was a problem hiding this comment.
⛏️ stripeAdapter.GetSubscriptionAsync doesn't ever return null; it throws an exception. If you want to check for null, you'd have to use the SubscriberService or catch a StripeException with a "resource_missing" error code.
| // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription | ||
| [HttpPost("storage")] | ||
| [SelfHosted(NotSelfHostedOnly = true)] | ||
| public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model) |
There was a problem hiding this comment.
Same issue still applies with applicable comments regarding FF on the command.
| await userService.SaveUserAsync(user); | ||
|
|
||
| // No payment intent needed - the subscription update will automatically create and finalize the invoice | ||
| return (string?)null; |
There was a problem hiding this comment.
❌ This return type of string serves no purpose. The command can return None.
e79386e to
d7d080d
Compare


🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-29610
📔 Objective
This PR implements PM-29610: Update Account Storage Endpoint - a refactor to modernize the individual premium user storage management endpoint by migrating it to the new VNext billing architecture.
What Changed
Endpoint Migration:
POST /accounts/storage(legacyAccountsController)PUT /account/billing/vnext/storage(newAccountBillingVNextController)PUTto properly reflect the idempotent nature of the operationKey Improvements:
1. Simplified Request Model:
- Old approach used delta/adjustment values (e.g., "add 5GB")
- New approach uses absolute storage values (e.g., "set to 10GB total")
- Makes the API more intuitive and truly idempotent
2. Modern Architecture:
- Follows the new command/handler pattern (
UpdateStorageCommand)- Uses
BillingCommandResult<T>with OneOf union types for type-safe error handling- Leverages the new
IPricingClientfor dynamic pricing configuration3. Robust Validation:
- Premium subscription verification
- Payment method existence checks
- Storage bounds validation (minimum base storage to 100 GB maximum)
- Current usage verification (prevents setting storage below what's currently used)
- Idempotency check (no-op when storage is unchanged)
4. Extensible Design:
- Single endpoint handles both storage increases and decreases
- Future-proof for additional storage-related operations
Files Added/Modified
New Files:
src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs- Request model with validationsrc/Core/Billing/Storage/Commands/UpdateStorageCommand.cs- Command implementationtest/Core.Test/Billing/Storage/Commands/UpdateStorageCommandTests.cs- 18 comprehensive teststest/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs- 10 controller integration testsModified Files:
src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs- Added PUT storage endpointsrc/Core/Billing/Extensions/ServiceCollectionExtensions.cs- Registered new command in DI containerBug Fix (Unrelated):
test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs- Fixed incorrect method names that didn't match IStripeAdapter interface📸 Screenshots
⏰ Reminders before review
🦮 Reviewer guidelines
:+1:) or similar for great changes:memo:) or ℹ️ (:information_source:) for notes or general info:question:) for questions:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion:art:) for suggestions / improvements:x:) or:warning:) for more significant problems or concerns needing attention:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt:pick:) for minor or nitpick changes