diff --git a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs index 176c77bf57..219f450f1d 100644 --- a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Core.Utilities; @@ -29,6 +30,7 @@ public interface IUpdatePremiumStorageCommand } public class UpdatePremiumStorageCommand( + IBraintreeService braintreeService, IStripeAdapter stripeAdapter, IUserService userService, IPricingClient pricingClient, @@ -49,7 +51,10 @@ public class UpdatePremiumStorageCommand( // 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); + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = ["customer"] + }); // Find the password manager subscription item (seat, not storage) and match it to a plan var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i => @@ -127,13 +132,41 @@ public class UpdatePremiumStorageCommand( }); } - var subscriptionUpdateOptions = new SubscriptionUpdateOptions - { - Items = subscriptionItemOptions, - ProrationBehavior = ProrationBehavior.AlwaysInvoice - }; + var usingPayPal = subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); - await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions); + if (usingPayPal) + { + var options = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = ProrationBehavior.CreateProrations + }; + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + + var draftInvoice = await stripeAdapter.CreateInvoiceAsync(new InvoiceCreateOptions + { + Customer = subscription.CustomerId, + Subscription = subscription.Id, + AutoAdvance = false, + CollectionMethod = CollectionMethod.ChargeAutomatically + }); + + var finalizedInvoice = await stripeAdapter.FinalizeInvoiceAsync(draftInvoice.Id, + new InvoiceFinalizeOptions { AutoAdvance = false, Expand = ["customer"] }); + + await braintreeService.PayInvoice(new UserId(user.Id), finalizedInvoice); + } + else + { + var options = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = ProrationBehavior.AlwaysInvoice + }; + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + } // Update the user's max storage user.MaxStorageGb = maxStorageGb; diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs index 5ec732920e..12ea3d5a7c 100644 --- a/src/Core/Billing/Services/IStripeAdapter.cs +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -24,6 +24,7 @@ public interface IStripeAdapter Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null); Task GetInvoiceAsync(string id, InvoiceGetOptions options); Task> ListInvoicesAsync(StripeInvoiceListOptions options); + Task CreateInvoiceAsync(InvoiceCreateOptions options); Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options); Task> SearchInvoiceAsync(InvoiceSearchOptions options); Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options); diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs index cdc7645042..5b90500021 100644 --- a/src/Core/Billing/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -116,6 +116,9 @@ public class StripeAdapter : IStripeAdapter return invoices; } + public Task CreateInvoiceAsync(InvoiceCreateOptions options) => + _invoiceService.CreateAsync(options); + public Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options) => _invoiceService.CreatePreviewAsync(options); diff --git a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs index 7b9b68c757..cd9b323f9d 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Test.Common.AutoFixture.Attributes; @@ -8,6 +9,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; @@ -15,6 +17,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands; public class UpdatePremiumStorageCommandTests { + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); @@ -33,13 +36,14 @@ public class UpdatePremiumStorageCommandTests _pricingClient.ListPremiumPlans().Returns([premiumPlan]); _command = new UpdatePremiumStorageCommand( + _braintreeService, _stripeAdapter, _userService, _pricingClient, Substitute.For>()); } - private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null) + private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null, bool isPayPal = false) { var items = new List { @@ -63,9 +67,17 @@ public class UpdatePremiumStorageCommandTests }); } + var customer = new Customer + { + Id = "cus_123", + Metadata = isPayPal ? new Dictionary { { MetadataKeys.BraintreeCustomerId, "braintree_123" } } : new Dictionary() + }; + return new Subscription { Id = subscriptionId, + CustomerId = "cus_123", + Customer = customer, Items = new StripeList { Data = items @@ -97,7 +109,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, -5); @@ -117,7 +129,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 100); @@ -154,7 +166,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 0); @@ -176,7 +188,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 4); @@ -185,7 +197,7 @@ public class UpdatePremiumStorageCommandTests Assert.True(result.IsT0); // Verify subscription was fetched but NOT updated - await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123"); + await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123", Arg.Any()); await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); } @@ -200,7 +212,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 9); @@ -233,7 +245,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123"); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 9); @@ -262,7 +274,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 2); @@ -291,7 +303,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 0); @@ -320,7 +332,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 99); @@ -335,4 +347,200 @@ public class UpdatePremiumStorageCommandTests await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 100)); } + + [Theory, BitAutoData] + public async Task Run_IncreaseStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 5; + user.Storage = 2L * 1024 * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 4, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 9); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated with CreateProrations + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Quantity == 9 && + opts.ProrationBehavior == "create_prorations")); + + // Verify draft invoice was created + await _stripeAdapter.Received(1).CreateInvoiceAsync( + Arg.Is(opts => + opts.Customer == "cus_123" && + opts.Subscription == "sub_123" && + opts.AutoAdvance == false && + opts.CollectionMethod == "charge_automatically")); + + // Verify invoice was finalized + await _stripeAdapter.Received(1).FinalizeInvoiceAsync( + "in_draft", + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand.Contains("customer"))); + + // Verify Braintree payment was processed + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + // Verify user was saved + await _userService.Received(1).SaveUserAsync(Arg.Is(u => + u.Id == user.Id && + u.MaxStorageGb == 10)); + } + + [Theory, BitAutoData] + public async Task Run_AddStorageFromZero_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 1; + user.Storage = 500L * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 9); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated with new storage item + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Price == "price_storage" && + opts.Items[0].Quantity == 9 && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 10)); + } + + [Theory, BitAutoData] + public async Task Run_DecreaseStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 10; + user.Storage = 2L * 1024 * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 2); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Quantity == 2 && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 3)); + } + + [Theory, BitAutoData] + public async Task Run_RemoveAllAdditionalStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 10; + user.Storage = 500L * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 0); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription item was deleted + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Deleted == true && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 1)); + } }