From 37ec6a56a5d4ee1bf104ef0d4c2007ad9e753762 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 9 Mar 2026 12:54:01 -0400 Subject: [PATCH 01/24] feat(billing): implement paypal payments for premium organization upgrade --- .../UpgradePremiumToOrganizationCommand.cs | 278 +++++++++++------- 1 file changed, 179 insertions(+), 99 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index a7bf3e20cea5..c6fc7c28d61d 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -1,10 +1,10 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -14,9 +14,12 @@ using Microsoft.Extensions.Logging; using Stripe; using CountryAbbreviations = Bit.Core.Constants.CountryAbbreviations; -using TaxExempt = Bit.Core.Billing.Constants.StripeConstants.TaxExempt; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using static Bit.Core.Billing.Utilities; +using static Bit.Core.Billing.Constants.StripeConstants; namespace Bit.Core.Billing.Premium.Commands; + /// /// Upgrades a user's Premium subscription to an Organization plan by creating a new Organization /// and transferring the subscription from the User to the Organization. @@ -55,7 +58,8 @@ public class UpgradePremiumToOrganizationCommand( IOrganizationUserRepository organizationUserRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, ICollectionRepository collectionRepository, - IApplicationCacheService applicationCacheService) + IApplicationCacheService applicationCacheService, + IBraintreeService braintreeService) : BaseBillingCommand(logger), IUpgradePremiumToOrganizationCommand { private readonly ILogger _logger = logger; @@ -76,15 +80,7 @@ public Task> Run( return new BadRequest("User does not have an active Premium subscription."); } - // Fetch the current Premium subscription from Stripe - var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); - - // Fetch all premium plans to find which specific plan the user is on - var premiumPlans = await pricingClient.ListPremiumPlans(); - - // Find the password manager subscription item (seat, not storage) and match it to a plan - var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => - premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); + var (currentSubscription, premiumPlans, passwordManagerItem) = await GetPremiumPlanAndSubscriptionDetailsAsync(user); if (passwordManagerItem == null) { @@ -96,13 +92,83 @@ public Task> Run( // Get the target organization plan var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); + var subscriptionItemOptions = BuildSubscriptionItemOptions( + currentSubscription, usersPremiumPlan, targetPlan, passwordManagerItem); + + // Generate organization ID early to include in metadata + var organizationId = CoreHelpers.GenerateComb(); + + // Create the Organization entity + var organization = BuildOrganization( + organizationId, user, organizationName, publicKey, encryptedPrivateKey, targetPlan, currentSubscription.Id); + + // Update customer billing address for tax calculation + var customer = await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + }, + TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None + }); + + + await UpdateSubscriptionAsync(currentSubscription.Id, organizationId, customer, subscriptionItemOptions); + + // Add tax ID to the customer for accurate tax calculation if provided + if (billingAddress.TaxId != null) + { + await AddTaxIdToCustomerAsync(user.GatewayCustomerId!, billingAddress.TaxId); + } + + var organizationUser = await SaveOrganizationAsync(organization, user, key); + + // Create a default collection if a collection name is provided + if (!string.IsNullOrWhiteSpace(collectionName)) + { + await CreateDefaultCollectionAsync(organization, organizationUser, collectionName); + } + + // Remove subscription from user + user.Premium = false; + user.PremiumExpirationDate = null; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = null; + user.RevisionDate = DateTime.UtcNow; + await userService.SaveUserAsync(user); + + return organization.Id; + }); + + private async Task<(Subscription currentSubscription, List premiumPlans, SubscriptionItem? passwordManagerItem)> GetPremiumPlanAndSubscriptionDetailsAsync(User user) + { + // Fetch the current Premium subscription from Stripe + var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); + + // Fetch all premium plans to find which specific plan the user is on + var premiumPlans = await pricingClient.ListPremiumPlans(); + + // Find the password manager subscription item (seat, not storage) and match it to a plan + var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => + premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); + + return (currentSubscription, premiumPlans, passwordManagerItem); + } + + private List BuildSubscriptionItemOptions( + Subscription currentSubscription, + PremiumPlan usersPremiumPlan, + Core.Models.StaticStore.Plan targetPlan, + SubscriptionItem passwordManagerItem) + { var isNonSeatBasedPmPlan = targetPlan.HasNonSeatBasedPasswordManagerPlan(); // if the target plan is non-seat-based, set seats to the base seats of the target plan, otherwise set to 1 var initialSeats = isNonSeatBasedPmPlan ? targetPlan.PasswordManager.BaseSeats : 1; // Build the list of subscription item updates - var subscriptionItemOptions = new List(); + var options = new List(); // Delete the storage item if it exists for this user's plan var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => @@ -110,52 +176,42 @@ public Task> Run( if (storageItem != null) { - subscriptionItemOptions.Add(new SubscriptionItemOptions - { - Id = storageItem.Id, - Deleted = true - }); + options.Add(new SubscriptionItemOptions { Id = storageItem.Id, Deleted = true }); } // Add new organization subscription items - if (isNonSeatBasedPmPlan) - { - subscriptionItemOptions.Add(new SubscriptionItemOptions + options.Add(isNonSeatBasedPmPlan + ? new SubscriptionItemOptions { Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripePlanId, Quantity = 1 - }); - } - else - { - subscriptionItemOptions.Add(new SubscriptionItemOptions + } + : new SubscriptionItemOptions { Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripeSeatPlanId, Quantity = initialSeats }); - } - // Generate organization ID early to include in metadata - var organizationId = CoreHelpers.GenerateComb(); + return options; + } - // Build the subscription update options - var subscriptionUpdateOptions = new SubscriptionUpdateOptions - { - Items = subscriptionItemOptions, - ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice, - BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged, - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, - Metadata = new Dictionary - { - [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(), - [StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User - } - }; + private Organization BuildOrganization( + Guid organizationId, + User user, + string organizationName, + string publicKey, + string encryptedPrivateKey, + Core.Models.StaticStore.Plan targetPlan, + string subscriptionId) + { + var isNonSeatBasedPmPlan = targetPlan.HasNonSeatBasedPasswordManagerPlan(); - // Create the Organization entity - var organization = new Organization + // if the target plan is non-seat-based, set seats to the base seats of the target plan, otherwise set to 1 + var initialSeats = isNonSeatBasedPmPlan ? targetPlan.PasswordManager.BaseSeats : 1; + + return new Organization { Id = organizationId, Name = organizationName, @@ -165,7 +221,7 @@ public Task> Run( MaxCollections = targetPlan.PasswordManager.MaxCollections, MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, UsePolicies = targetPlan.HasPolicies, - UseMyItems = targetPlan.HasPolicies, // TODO: use the plan property when added (PM-32366) + UseMyItems = targetPlan.HasMyItems, UseSso = targetPlan.HasSso, UseGroups = targetPlan.HasGroups, UseEvents = targetPlan.HasEvents, @@ -191,33 +247,52 @@ public Task> Run( UseSecretsManager = false, UseOrganizationDomains = targetPlan.HasOrganizationDomains, GatewayCustomerId = user.GatewayCustomerId, - GatewaySubscriptionId = currentSubscription.Id + GatewaySubscriptionId = subscriptionId }; + } - // Update customer billing address for tax calculation - await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions + private async Task UpdateSubscriptionAsync( + string subscriptionId, + Guid organizationId, + Customer customer, + List subscriptionItemOptions) + { + var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false; + + // Build the subscription update options + var subscriptionUpdateOptions = new SubscriptionUpdateOptions { - Address = new AddressOptions + Items = subscriptionItemOptions, + ProrationBehavior = ProrationBehavior.AlwaysInvoice, + BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, + Metadata = new Dictionary { - Country = billingAddress.Country, - PostalCode = billingAddress.PostalCode + [MetadataKeys.OrganizationId] = organizationId.ToString(), + [MetadataKeys.UserId] = string.Empty // Remove userId to unlink the subscription from User }, - TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None - }); + PaymentBehavior = usingPayPal ? PaymentBehavior.DefaultIncomplete : null + }; - // Add tax ID to customer for accurate tax calculation if provided - if (billingAddress.TaxId != null) + // Update the subscription in Stripe + var subscription = await stripeAdapter.UpdateSubscriptionAsync(subscriptionId, subscriptionUpdateOptions); + + // If using PayPal, update the subscription in Braintree + if (usingPayPal) { - await AddTaxIdToCustomerAsync(user, billingAddress.TaxId); + await PayInvoiceUsingPayPalAsync(subscription, organizationId); } + } - // Update the subscription in Stripe - await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); - + private async Task SaveOrganizationAsync( + Organization organization, + User user, + string key) + { // Save the organization await organizationRepository.CreateAsync(organization); - // Create organization API key + // Create the organization API key await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey { OrganizationId = organization.Id, @@ -244,61 +319,66 @@ await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey organizationUser.SetNewId(); await organizationUserRepository.CreateAsync(organizationUser); - // Create default collection if collection name is provided - if (!string.IsNullOrWhiteSpace(collectionName)) + return organizationUser; + } + + private async Task CreateDefaultCollectionAsync( + Organization organization, + OrganizationUser organizationUser, + string collectionName) + { + try { - try - { - // Give the owner Can Manage access over the default collection - List defaultOwnerAccess = - [new CollectionAccessSelection { Id = organizationUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; + // Give the owner Can Manage access over the default collection + List defaultOwnerAccess = + [new() { Id = organizationUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; - var defaultCollection = new Collection - { - Name = collectionName, - OrganizationId = organization.Id, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - await collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); - } - catch (Exception ex) + var defaultCollection = new Collection { - _logger.LogWarning(ex, - "{Command}: Failed to create default collection for organization {OrganizationId}. Organization upgrade will continue.", - CommandName, organization.Id); - // Continue - organization is fully functional without default collection - } + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + await collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "{Command}: Failed to create default collection for organization {OrganizationId}. Organization upgrade will continue.", + CommandName, organization.Id); + // Continue - organization is fully functional without default collection } + } - // Remove subscription from user - user.Premium = false; - user.PremiumExpirationDate = null; - user.GatewaySubscriptionId = null; - user.GatewayCustomerId = null; - user.RevisionDate = DateTime.UtcNow; - await userService.SaveUserAsync(user); + private async Task PayInvoiceUsingPayPalAsync(Subscription subscription, Guid organizationId) + { + var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + { + AutoAdvance = false, + Expand = ["customer"] + }); - return organization.Id; - }); + await braintreeService.PayInvoice(new UserId(organizationId), invoice); + } /// /// Adds a tax ID to the Stripe customer for accurate tax calculation. /// If the tax ID is a Spanish NIF, also adds the corresponding EU VAT ID. /// - /// The user whose Stripe customer will be updated with the tax ID. - /// The tax ID to add, including the type and value. - private async Task AddTaxIdToCustomerAsync(User user, TaxID taxId) + /// The Stripe customer ID to add the tax ID to. + /// The tax ID to add, including the type and value. + private async Task AddTaxIdToCustomerAsync(string customerId, TaxID taxId) { - await stripeAdapter.CreateTaxIdAsync(user.GatewayCustomerId, + await stripeAdapter.CreateTaxIdAsync(customerId, new TaxIdCreateOptions { Type = taxId.Code, Value = taxId.Value }); - if (taxId.Code == StripeConstants.TaxIdType.SpanishNIF) + if (taxId.Code == TaxIdType.SpanishNIF) { - await stripeAdapter.CreateTaxIdAsync(user.GatewayCustomerId, + await stripeAdapter.CreateTaxIdAsync(customerId, new TaxIdCreateOptions { - Type = StripeConstants.TaxIdType.EUVAT, + Type = TaxIdType.EUVAT, Value = $"ES{taxId.Value}" }); } From 075b6583dd8169b8f61e6fd33f5422240ab6be51 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 9 Mar 2026 12:54:01 -0400 Subject: [PATCH 02/24] test(billing): add paypal payment scenarios for organization upgrade --- ...pgradePremiumToOrganizationCommandTests.cs | 251 +++++++++++++++++- 1 file changed, 250 insertions(+), 1 deletion(-) diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 181a5e6d33e5..4dd4e7bbe361 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -4,6 +4,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.Enums; using Bit.Core.Models.Data; @@ -136,11 +137,16 @@ private static List CreateTestPremiumPlansList() private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository = Substitute.For(); private readonly ICollectionRepository _collectionRepository = Substitute.For(); private readonly IApplicationCacheService _applicationCacheService = Substitute.For(); + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly UpgradePremiumToOrganizationCommand _command; public UpgradePremiumToOrganizationCommandTests() { + // Default: non-PayPal customer (no Braintree metadata) + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new Customer { Metadata = new Dictionary() })); + _command = new UpgradePremiumToOrganizationCommand( _logger, _pricingClient, @@ -150,7 +156,8 @@ public UpgradePremiumToOrganizationCommandTests() _organizationUserRepository, _organizationApiKeyRepository, _collectionRepository, - _applicationCacheService); + _applicationCacheService, + _braintreeService); } private static Core.Billing.Payment.Models.BillingAddress CreateTestBillingAddress() => @@ -241,6 +248,7 @@ public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user) .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); @@ -318,6 +326,7 @@ public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User use .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); @@ -387,6 +396,7 @@ public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user) .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _userService.SaveUserAsync(user).Returns(Task.CompletedTask); @@ -450,6 +460,7 @@ public async Task Run_UserOnLegacyPremiumPlan_SuccessfullyDeletesLegacyItems(Use .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); @@ -518,6 +529,7 @@ public async Task Run_UserHasPremiumPlusOtherProducts_OnlyDeletesPremiumItems(Us .Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockSubscription)); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); @@ -1324,6 +1336,243 @@ await _stripeAdapter.Received(1).CreateTaxIdAsync( options.Value == "DE123456789")); } + [Theory, BitAutoData] + public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehavior(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + LatestInvoiceId = "in_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var payPalCustomer = new Customer + { + Metadata = new Dictionary + { + [Bit.Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_cus_123" + } + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(payPalCustomer); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(new Invoice { Id = "in_123" }); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.PaymentBehavior == "default_incomplete")); + } + + [Theory, BitAutoData] + public async Task Run_WithNonPayPalCustomer_DoesNotSetDefaultIncompletePaymentBehavior(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + // Customer with no Braintree metadata + var stripeCustomer = new Customer { Metadata = new Dictionary() }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(stripeCustomer); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => opts.PaymentBehavior == null)); + await _braintreeService.DidNotReceive().PayInvoice(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_WithPayPalCustomer_PaysInvoiceViaBraintree(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + LatestInvoiceId = "in_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var payPalCustomer = new Customer + { + Metadata = new Dictionary + { + [Bit.Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_cus_123" + } + }; + + var mockInvoice = new Invoice { Id = "in_123" }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(payPalCustomer); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync("in_123", Arg.Any()).Returns(mockInvoice); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + var organizationId = result.AsT0; + + await _braintreeService.Received(1).PayInvoice( + Arg.Is(id => id.IsT0 && id.AsT0.Value == organizationId), + mockInvoice); + } + + [Theory, BitAutoData] + public async Task Run_WithPayPalCustomer_UpdatesInvoiceWithAutoAdvanceFalseBeforePaying(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + LatestInvoiceId = "in_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var payPalCustomer = new Customer + { + Metadata = new Dictionary + { + [Bit.Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_cus_123" + } + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(payPalCustomer); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(new Invoice { Id = "in_123" }); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).UpdateInvoiceAsync( + "in_123", + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand.Contains("customer"))); + } + [Theory, BitAutoData] public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNIFAndEUVAT(User user) { From 3604bfd767c6ff289d72e68940467dca0ab9841f Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 9 Mar 2026 13:48:54 -0400 Subject: [PATCH 03/24] tests(billing): simplify tests --- ...pgradePremiumToOrganizationCommandTests.cs | 159 +++--------------- 1 file changed, 21 insertions(+), 138 deletions(-) diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 4dd4e7bbe361..450c56119158 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1337,7 +1337,7 @@ await _stripeAdapter.Received(1).CreateTaxIdAsync( } [Theory, BitAutoData] - public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehavior(User user) + public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehaviorAndPaysInvoiceViaBraintree(User user) { // Arrange user.Premium = true; @@ -1370,15 +1370,14 @@ public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehavior(Us } }; - var mockPremiumPlans = CreateTestPremiumPlansList(); - var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + var mockInvoice = new Invoice { Id = "in_123" }; _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); - _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); - _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(payPalCustomer); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); - _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(new Invoice { Id = "in_123" }); + _stripeAdapter.UpdateInvoiceAsync("in_123", Arg.Any()).Returns(mockInvoice); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); @@ -1390,14 +1389,25 @@ public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehavior(Us // Assert Assert.True(result.IsT0); + var organizationId = result.AsT0; + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", - Arg.Is(opts => - opts.PaymentBehavior == "default_incomplete")); + Arg.Is(opts => opts.PaymentBehavior == "default_incomplete")); + + await _stripeAdapter.Received(1).UpdateInvoiceAsync( + "in_123", + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand.Contains("customer"))); + + await _braintreeService.Received(1).PayInvoice( + Arg.Is(id => id.IsT0 && id.AsT0.Value == organizationId), + mockInvoice); } [Theory, BitAutoData] - public async Task Run_WithNonPayPalCustomer_DoesNotSetDefaultIncompletePaymentBehavior(User user) + public async Task Run_WithNonPayPalCustomer_DoesNotPayViaPayPal(User user) { // Arrange user.Premium = true; @@ -1424,12 +1434,9 @@ public async Task Run_WithNonPayPalCustomer_DoesNotSetDefaultIncompletePaymentBe // Customer with no Braintree metadata var stripeCustomer = new Customer { Metadata = new Dictionary() }; - var mockPremiumPlans = CreateTestPremiumPlansList(); - var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); - _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); - _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(stripeCustomer); _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); @@ -1449,130 +1456,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync( await _braintreeService.DidNotReceive().PayInvoice(Arg.Any(), Arg.Any()); } - [Theory, BitAutoData] - public async Task Run_WithPayPalCustomer_PaysInvoiceViaBraintree(User user) - { - // Arrange - user.Premium = true; - user.GatewaySubscriptionId = "sub_123"; - user.GatewayCustomerId = "cus_123"; - - var mockSubscription = new Subscription - { - Id = "sub_123", - LatestInvoiceId = "in_123", - Items = new StripeList - { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } - }, - Metadata = new Dictionary() - }; - - var payPalCustomer = new Customer - { - Metadata = new Dictionary - { - [Bit.Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_cus_123" - } - }; - - var mockInvoice = new Invoice { Id = "in_123" }; - - var mockPremiumPlans = CreateTestPremiumPlansList(); - var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); - - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); - _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); - _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); - _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(payPalCustomer); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); - _stripeAdapter.UpdateInvoiceAsync("in_123", Arg.Any()).Returns(mockInvoice); - _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); - _userService.SaveUserAsync(user).Returns(Task.CompletedTask); - - // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); - - // Assert - Assert.True(result.IsT0); - var organizationId = result.AsT0; - - await _braintreeService.Received(1).PayInvoice( - Arg.Is(id => id.IsT0 && id.AsT0.Value == organizationId), - mockInvoice); - } - - [Theory, BitAutoData] - public async Task Run_WithPayPalCustomer_UpdatesInvoiceWithAutoAdvanceFalseBeforePaying(User user) - { - // Arrange - user.Premium = true; - user.GatewaySubscriptionId = "sub_123"; - user.GatewayCustomerId = "cus_123"; - - var mockSubscription = new Subscription - { - Id = "sub_123", - LatestInvoiceId = "in_123", - Items = new StripeList - { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } - }, - Metadata = new Dictionary() - }; - - var payPalCustomer = new Customer - { - Metadata = new Dictionary - { - [Bit.Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_cus_123" - } - }; - - var mockPremiumPlans = CreateTestPremiumPlansList(); - var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); - - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); - _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); - _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); - _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(payPalCustomer); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); - _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(new Invoice { Id = "in_123" }); - _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); - _userService.SaveUserAsync(user).Returns(Task.CompletedTask); - - // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); - - // Assert - Assert.True(result.IsT0); - await _stripeAdapter.Received(1).UpdateInvoiceAsync( - "in_123", - Arg.Is(opts => - opts.AutoAdvance == false && - opts.Expand.Contains("customer"))); - } - [Theory, BitAutoData] public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNIFAndEUVAT(User user) { From 1baae1185b189b1991d2f4fe5c82290f70d5e4e5 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 9 Mar 2026 13:57:15 -0400 Subject: [PATCH 04/24] fix(billing): run dotnet format --- .../Premium/Commands/UpgradePremiumToOrganizationCommand.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index c6fc7c28d61d..c2eee69cc5d3 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; @@ -13,10 +13,10 @@ using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; +using static Bit.Core.Billing.Constants.StripeConstants; +using static Bit.Core.Billing.Utilities; using CountryAbbreviations = Bit.Core.Constants.CountryAbbreviations; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; -using static Bit.Core.Billing.Utilities; -using static Bit.Core.Billing.Constants.StripeConstants; namespace Bit.Core.Billing.Premium.Commands; From 7b76fa348b6045341203ec7a575dd6c7e3ff78c0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 11 Mar 2026 11:51:45 -0400 Subject: [PATCH 05/24] style(billing): formatting --- .../UpgradePremiumToOrganizationCommand.cs | 26 +- ...pgradePremiumToOrganizationCommandTests.cs | 251 +++++++----------- 2 files changed, 106 insertions(+), 171 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index c2eee69cc5d3..e103a7cf0430 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -103,16 +103,18 @@ public Task> Run( organizationId, user, organizationName, publicKey, encryptedPrivateKey, targetPlan, currentSubscription.Id); // Update customer billing address for tax calculation - var customer = await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions - { - Address = new AddressOptions + var customer = await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, + new CustomerUpdateOptions { - Country = billingAddress.Country, - PostalCode = billingAddress.PostalCode - }, - TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None - }); - + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + }, + TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates + ? TaxExempt.Reverse + : TaxExempt.None + }); await UpdateSubscriptionAsync(currentSubscription.Id, organizationId, customer, subscriptionItemOptions); @@ -376,11 +378,7 @@ await stripeAdapter.CreateTaxIdAsync(customerId, if (taxId.Code == TaxIdType.SpanishNIF) { await stripeAdapter.CreateTaxIdAsync(customerId, - new TaxIdCreateOptions - { - Type = TaxIdType.EUVAT, - Value = $"ES{taxId.Value}" - }); + new TaxIdCreateOptions { Type = TaxIdType.EUVAT, Value = $"ES{taxId.Value}" }); } } } diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 450c56119158..1085ab87eafa 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -119,13 +119,12 @@ private static PremiumPlan CreateTestPremiumPlan( private static List CreateTestPremiumPlansList() { - return new List - { - // Current available plan + return + [ CreateTestPremiumPlan(available: true), // Legacy plan from 2020 CreateTestPremiumPlan("premium-annually-2020", "personal-storage-gb-annually-2020", available: false) - }; + ]; } @@ -225,15 +224,15 @@ public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem + Data = + [ + new SubscriptionItem() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } - } + ] }, Metadata = new Dictionary() }; @@ -301,15 +300,15 @@ public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User use Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem + Data = + [ + new SubscriptionItem() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } - } + ] }, Metadata = new Dictionary() }; @@ -370,15 +369,15 @@ public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem + Data = + [ + new SubscriptionItem() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } - } + ] }, Metadata = new Dictionary { @@ -431,21 +430,22 @@ public async Task Run_UserOnLegacyPremiumPlan_SuccessfullyDeletesLegacyItems(Use Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem + Data = + [ + new SubscriptionItem() { Id = "si_premium_legacy", Price = new Price { Id = "premium-annually-2020" }, // Legacy price ID CurrentPeriodEnd = currentPeriodEnd }, - new SubscriptionItem + + new SubscriptionItem() { Id = "si_storage_legacy", Price = new Price { Id = "personal-storage-gb-annually-2020" }, // Legacy storage price ID CurrentPeriodEnd = currentPeriodEnd } - } + ] }, Metadata = new Dictionary() }; @@ -500,21 +500,22 @@ public async Task Run_UserHasPremiumPlusOtherProducts_OnlyDeletesPremiumItems(Us Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem + Data = + [ + new SubscriptionItem() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }, + new SubscriptionItem { Id = "si_other_product", Price = new Price { Id = "some-other-product-id" }, // Non-premium item CurrentPeriodEnd = currentPeriodEnd } - } + ] }, Metadata = new Dictionary() }; @@ -568,15 +569,15 @@ public async Task Run_NoPremiumSubscriptionItemFound_ReturnsBadRequest(User user Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem + Data = + [ + new SubscriptionItem() { Id = "si_other", Price = new Price { Id = "some-other-product" }, // Not a premium plan CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } - } + ] }, Metadata = new Dictionary() }; @@ -609,14 +610,10 @@ public async Task Run_UpdatesCustomerBillingAddress(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -665,14 +662,10 @@ public async Task Run_EnablesAutomaticTaxOnSubscription(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -719,14 +712,10 @@ public async Task Run_UsesAlwaysInvoiceProrationBehavior(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -772,14 +761,10 @@ public async Task Run_ModifiesExistingSubscriptionItem_NotDeleteAndRecreate(User Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -831,14 +816,10 @@ public async Task Run_CreatesOrganizationWithCorrectSettings(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -890,14 +871,10 @@ public async Task Run_CreatesOrganizationApiKeyWithCorrectType(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -943,14 +920,10 @@ public async Task Run_CreatesOrganizationUserAsOwnerWithAllPermissions(User user Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -997,14 +970,10 @@ public async Task Run_SetsOrganizationPublicAndPrivateKeys(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1048,14 +1017,10 @@ public async Task Run_WithCollectionName_CreatesDefaultCollection(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1106,14 +1071,10 @@ public async Task Run_WithoutCollectionName_DoesNotCreateCollection(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1159,14 +1120,10 @@ public async Task Run_CollectionCreationFails_UpgradeStillSucceeds(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1228,14 +1185,10 @@ public async Task Run_WithNoTaxId_SetsTaxExemptToNone_DoesNotCreateTaxId(User us Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1286,14 +1239,10 @@ public async Task Run_WithTaxId_SetsTaxExemptToReverse_CreatesOneTaxId(User user Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1350,14 +1299,10 @@ public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehaviorAnd LatestInvoiceId = "in_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1419,14 +1364,10 @@ public async Task Run_WithNonPayPalCustomer_DoesNotPayViaPayPal(User user) Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; @@ -1469,14 +1410,10 @@ public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNI Id = "sub_123", Items = new StripeList { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" } - } - } + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] }, Metadata = new Dictionary() }; From a901bace30b2c62cfc15e4cc0724d89580a3c969 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 11 Mar 2026 11:51:45 -0400 Subject: [PATCH 06/24] refactor(billing): inline subscription detail fetching logic --- .../UpgradePremiumToOrganizationCommand.cs | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index e103a7cf0430..f1c0484f23cc 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -80,7 +80,15 @@ public Task> Run( return new BadRequest("User does not have an active Premium subscription."); } - var (currentSubscription, premiumPlans, passwordManagerItem) = await GetPremiumPlanAndSubscriptionDetailsAsync(user); + // Fetch the current Premium subscription from Stripe + var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); + + // Fetch all premium plans to find which specific plan the user is on + var premiumPlans = await pricingClient.ListPremiumPlans(); + + // Find the password manager subscription item (seat, not storage) and match it to a plan + var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => + premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); if (passwordManagerItem == null) { @@ -143,21 +151,6 @@ public Task> Run( return organization.Id; }); - private async Task<(Subscription currentSubscription, List premiumPlans, SubscriptionItem? passwordManagerItem)> GetPremiumPlanAndSubscriptionDetailsAsync(User user) - { - // Fetch the current Premium subscription from Stripe - var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); - - // Fetch all premium plans to find which specific plan the user is on - var premiumPlans = await pricingClient.ListPremiumPlans(); - - // Find the password manager subscription item (seat, not storage) and match it to a plan - var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => - premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); - - return (currentSubscription, premiumPlans, passwordManagerItem); - } - private List BuildSubscriptionItemOptions( Subscription currentSubscription, PremiumPlan usersPremiumPlan, From 306c7284f0b16f7e0c32d54e6015667a98d08013 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 11 Mar 2026 11:51:45 -0400 Subject: [PATCH 07/24] fix(billing): use OrganizationId for PayPal invoice payment --- .../Commands/UpgradePremiumToOrganizationCommand.cs | 9 +++------ .../Commands/UpgradePremiumToOrganizationCommandTests.cs | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index f1c0484f23cc..8888b7d31569 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -348,13 +348,10 @@ private async Task CreateDefaultCollectionAsync( private async Task PayInvoiceUsingPayPalAsync(Subscription subscription, Guid organizationId) { - var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions - { - AutoAdvance = false, - Expand = ["customer"] - }); + var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, + new InvoiceUpdateOptions { AutoAdvance = false, Expand = ["customer"] }); - await braintreeService.PayInvoice(new UserId(organizationId), invoice); + await braintreeService.PayInvoice(new OrganizationId(organizationId), invoice); } /// diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 1085ab87eafa..c77c34e757cb 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1347,7 +1347,7 @@ await _stripeAdapter.Received(1).UpdateInvoiceAsync( opts.Expand.Contains("customer"))); await _braintreeService.Received(1).PayInvoice( - Arg.Is(id => id.IsT0 && id.AsT0.Value == organizationId), + Arg.Is(id => id.IsOrganizationId && id.AsT1.Value == organizationId), mockInvoice); } From 8de8f4a95a88fa48a5a7a8b6e3e738b0ef1d66fe Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 11 Mar 2026 11:51:46 -0400 Subject: [PATCH 08/24] refactor(billing): simplify subscription item quantity to 1 for upgrades --- .../UpgradePremiumToOrganizationCommand.cs | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 8888b7d31569..d83645d03e45 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -159,9 +159,6 @@ private List BuildSubscriptionItemOptions( { var isNonSeatBasedPmPlan = targetPlan.HasNonSeatBasedPasswordManagerPlan(); - // if the target plan is non-seat-based, set seats to the base seats of the target plan, otherwise set to 1 - var initialSeats = isNonSeatBasedPmPlan ? targetPlan.PasswordManager.BaseSeats : 1; - // Build the list of subscription item updates var options = new List(); @@ -175,19 +172,14 @@ private List BuildSubscriptionItemOptions( } // Add new organization subscription items - options.Add(isNonSeatBasedPmPlan - ? new SubscriptionItemOptions - { - Id = passwordManagerItem.Id, - Price = targetPlan.PasswordManager.StripePlanId, - Quantity = 1 - } - : new SubscriptionItemOptions - { - Id = passwordManagerItem.Id, - Price = targetPlan.PasswordManager.StripeSeatPlanId, - Quantity = initialSeats - }); + options.Add(new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, + Price = isNonSeatBasedPmPlan + ? targetPlan.PasswordManager.StripePlanId + : targetPlan.PasswordManager.StripeSeatPlanId, + Quantity = 1 + }); return options; } From 73332883e98bcbc15629f9d270439f0138acbf55 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 11 Mar 2026 11:51:46 -0400 Subject: [PATCH 09/24] feat(subscriber): add type-checking properties to SubscriberId --- src/Core/Billing/Subscriptions/Models/SubscriberId.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Core/Billing/Subscriptions/Models/SubscriberId.cs b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs index 1ea842b0e659..9c044e242dd4 100644 --- a/src/Core/Billing/Subscriptions/Models/SubscriberId.cs +++ b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs @@ -17,6 +17,10 @@ public class SubscriberId : OneOfBase { private SubscriberId(OneOf input) : base(input) { } + public bool IsUserId => IsT0; + public bool IsOrganizationId => IsT1; + public bool IsProviderId => IsT2; + public static implicit operator SubscriberId(UserId value) => new(value); public static implicit operator SubscriberId(OrganizationId value) => new(value); public static implicit operator SubscriberId(ProviderId value) => new(value); From c4e7cb83bb268c7b59bdeb35573ae54ddc07a136 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 11 Mar 2026 11:51:46 -0400 Subject: [PATCH 10/24] refactor(tests): update result assertion to use 'Success' property --- ...pgradePremiumToOrganizationCommandTests.cs | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index c77c34e757cb..3343a1955fe1 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -260,7 +260,7 @@ public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -338,7 +338,7 @@ public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User use var result = await _command.Run(user, "My Families Org", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.FamiliesAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -404,7 +404,7 @@ public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -473,7 +473,7 @@ public async Task Run_UserOnLegacyPremiumPlan_SuccessfullyDeletesLegacyItems(Use var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -543,7 +543,7 @@ public async Task Run_UserHasPremiumPlusOtherProducts_OnlyDeletesPremiumItems(Us var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -638,7 +638,7 @@ public async Task Run_UpdatesCustomerBillingAddress(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, billingAddress); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -688,7 +688,7 @@ public async Task Run_EnablesAutomaticTaxOnSubscription(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -738,7 +738,7 @@ public async Task Run_UsesAlwaysInvoiceProrationBehavior(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -787,7 +787,7 @@ public async Task Run_ModifiesExistingSubscriptionItem_NotDeleteAndRecreate(User var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -842,7 +842,7 @@ public async Task Run_CreatesOrganizationWithCorrectSettings(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -897,7 +897,7 @@ public async Task Run_CreatesOrganizationApiKeyWithCorrectType(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -946,7 +946,7 @@ public async Task Run_CreatesOrganizationUserAsOwnerWithAllPermissions(User user var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -996,7 +996,7 @@ public async Task Run_SetsOrganizationPublicAndPrivateKeys(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "test-public-key", "test-encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); await _organizationRepository.Received(1).CreateAsync( Arg.Is(org => @@ -1044,7 +1044,7 @@ public async Task Run_WithCollectionName_CreatesDefaultCollection(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -1097,7 +1097,7 @@ public async Task Run_WithoutCollectionName_DoesNotCreateCollection(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -1154,7 +1154,7 @@ public async Task Run_CollectionCreationFails_UpgradeStillSucceeds(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; Assert.NotEqual(Guid.Empty, organizationId); @@ -1218,7 +1218,7 @@ public async Task Run_WithNoTaxId_SetsTaxExemptToNone_DoesNotCreateTaxId(User us var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.Received(1).UpdateCustomerAsync( "cus_123", Arg.Is(options => @@ -1273,7 +1273,7 @@ public async Task Run_WithTaxId_SetsTaxExemptToReverse_CreatesOneTaxId(User user var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.Received(1).UpdateCustomerAsync( "cus_123", Arg.Is(options => @@ -1333,7 +1333,7 @@ public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehaviorAnd var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); var organizationId = result.AsT0; await _stripeAdapter.Received(1).UpdateSubscriptionAsync( @@ -1390,7 +1390,7 @@ public async Task Run_WithNonPayPalCustomer_DoesNotPayViaPayPal(User user) var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => opts.PaymentBehavior == null)); @@ -1444,7 +1444,7 @@ public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNI var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress); // Assert - Assert.True(result.IsT0); + Assert.True(result.Success); await _stripeAdapter.Received(1).UpdateCustomerAsync( "cus_123", From 32f250ae0bfd51531efe0fa5e2748c9ae119dd74 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 19 Mar 2026 11:42:55 -0400 Subject: [PATCH 11/24] fix(billing) run dotnet format --- .../Premium/Commands/UpgradePremiumToOrganizationCommand.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index ec8b3a929443..0510ccdd5aa5 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -5,8 +5,8 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Utilities; using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Billing.Tax.Utilities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -18,7 +18,6 @@ using Stripe; using static Bit.Core.Billing.Constants.StripeConstants; using static Bit.Core.Billing.Utilities; -using CountryAbbreviations = Bit.Core.Constants.CountryAbbreviations; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; namespace Bit.Core.Billing.Premium.Commands; From 8d360eda0e0799132da52c658cbf2f0c684482e2 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 19 Mar 2026 16:16:14 -0400 Subject: [PATCH 12/24] feat(billing): implement payment method pre-check for organization upgrade --- .../Commands/UpgradePremiumToOrganizationCommand.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 0510ccdd5aa5..30effdcbb766 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; @@ -61,6 +62,7 @@ public class UpgradePremiumToOrganizationCommand( IOrganizationApiKeyRepository organizationApiKeyRepository, ICollectionRepository collectionRepository, IBraintreeService braintreeService, + IHasPaymentMethodQuery hasPaymentMethodQuery, IApplicationCacheService applicationCacheService, IPushNotificationService pushNotificationService) : BaseBillingCommand(logger), IUpgradePremiumToOrganizationCommand @@ -113,6 +115,12 @@ public Task> Run( var organization = BuildOrganization( organizationId, user, organizationName, publicKey, encryptedPrivateKey, targetPlan, currentSubscription.Id); + var hasPaymentMethod = await hasPaymentMethodQuery.Run(user); + if (!hasPaymentMethod) + { + return new BadRequest("No payment method found for the user. Please add a payment method to upgrade to Organization plan."); + } + // Update customer billing address for tax calculation var customer = await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions From 67c4927c43159d6d26dd5f61251b3aa43c07584d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 19 Mar 2026 16:16:14 -0400 Subject: [PATCH 13/24] feat(billing): refine Stripe subscription payment behavior for default incomplete --- .../Commands/UpgradePremiumToOrganizationCommand.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 30effdcbb766..c628f95d6666 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -271,6 +271,9 @@ private async Task UpdateSubscriptionAsync( List subscriptionItemOptions) { var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false; + var hasDefaultPaymentMethod = !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) + || !string.IsNullOrEmpty(customer.DefaultSourceId); + var requiresIncomplete = usingPayPal || !hasDefaultPaymentMethod; // Build the subscription update options var subscriptionUpdateOptions = new SubscriptionUpdateOptions @@ -284,13 +287,13 @@ private async Task UpdateSubscriptionAsync( [MetadataKeys.OrganizationId] = organizationId.ToString(), [MetadataKeys.UserId] = string.Empty // Remove userId to unlink the subscription from User }, - PaymentBehavior = usingPayPal ? PaymentBehavior.DefaultIncomplete : null + PaymentBehavior = requiresIncomplete ? PaymentBehavior.DefaultIncomplete : null }; // Update the subscription in Stripe var subscription = await stripeAdapter.UpdateSubscriptionAsync(subscriptionId, subscriptionUpdateOptions); - // If using PayPal, update the subscription in Braintree + // If using PayPal, pay the invoice via Braintree if (usingPayPal) { await PayInvoiceUsingPayPalAsync(subscription, organizationId); From 5cd2429046e819945aadd86cc41e0f208c3e06e3 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 19 Mar 2026 16:16:14 -0400 Subject: [PATCH 14/24] test(billing): update and add tests for organization upgrade command --- ...pgradePremiumToOrganizationCommandTests.cs | 144 ++++++++++++------ 1 file changed, 96 insertions(+), 48 deletions(-) diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index a0c0257d93fa..454593c50a70 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; @@ -138,15 +139,21 @@ private static List CreateTestPremiumPlansList() private readonly ICollectionRepository _collectionRepository = Substitute.For(); private readonly IApplicationCacheService _applicationCacheService = Substitute.For(); private readonly IBraintreeService _braintreeService = Substitute.For(); + private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For(); private readonly IPushNotificationService _pushNotificationService = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly UpgradePremiumToOrganizationCommand _command; public UpgradePremiumToOrganizationCommandTests() { - // Default: non-PayPal customer (no Braintree metadata) + // Default: card customer with a default payment method and a payment method present _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new Customer { Metadata = new Dictionary() })); + .Returns(Task.FromResult(new Customer + { + Metadata = new Dictionary(), + InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = "pm_card_123" } + })); + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); _command = new UpgradePremiumToOrganizationCommand( _logger, @@ -158,6 +165,7 @@ public UpgradePremiumToOrganizationCommandTests() _organizationApiKeyRepository, _collectionRepository, _braintreeService, + _hasPaymentMethodQuery, _applicationCacheService, _pushNotificationService); } @@ -1355,52 +1363,6 @@ await _braintreeService.Received(1).PayInvoice( mockInvoice); } - [Theory, BitAutoData] - public async Task Run_WithNonPayPalCustomer_DoesNotPayViaPayPal(User user) - { - // Arrange - user.Premium = true; - user.GatewaySubscriptionId = "sub_123"; - user.GatewayCustomerId = "cus_123"; - - var mockSubscription = new Subscription - { - Id = "sub_123", - Items = new StripeList - { - Data = - [ - new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } - ] - }, - Metadata = new Dictionary() - }; - - // Customer with no Braintree metadata - var stripeCustomer = new Customer { Metadata = new Dictionary() }; - - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); - _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); - _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); - _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(stripeCustomer); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); - _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); - _userService.SaveUserAsync(user).Returns(Task.CompletedTask); - - // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); - - // Assert - Assert.True(result.Success); - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - "sub_123", - Arg.Is(opts => opts.PaymentBehavior == null)); - await _braintreeService.DidNotReceive().PayInvoice(Arg.Any(), Arg.Any()); - } - [Theory, BitAutoData] public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNIFAndEUVAT(User user) { @@ -1524,4 +1486,90 @@ await _stripeAdapter.Received(1).UpdateCustomerAsync( options.TaxExempt == StripeConstants.TaxExempt.None)); await _stripeAdapter.DidNotReceive().CreateTaxIdAsync(Arg.Any(), Arg.Any()); } + + [Theory, BitAutoData] + public async Task Run_WithNoPaymentMethod_ReturnsBadRequest(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] + }, + Metadata = new Dictionary() + }; + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); + _hasPaymentMethodQuery.Run(user).Returns(false); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT1); + Assert.Equal("No payment method found for the user. Please add a payment method to upgrade to Organization plan.", result.AsT1.Response); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_WithUnverifiedBankAccount_SetsDefaultIncompleteAndDoesNotPayViaBraintree(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] + }, + Metadata = new Dictionary() + }; + + // Customer with no default payment method and no PayPal — unverified bank account + var bankAccountCustomer = new Customer + { + Metadata = new Dictionary(), + InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = null } + }; + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(bankAccountCustomer); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.Success); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => opts.PaymentBehavior == "default_incomplete")); + await _braintreeService.DidNotReceive().PayInvoice(Arg.Any(), Arg.Any()); + } + } From 03a4366263169133c72411e8d75b5fe61912f307 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 19 Mar 2026 16:16:14 -0400 Subject: [PATCH 15/24] chore(billing): clarify comment on user subscription removal --- .../Premium/Commands/UpgradePremiumToOrganizationCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index c628f95d6666..63d596df3953 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -149,7 +149,7 @@ public Task> Run( await CreateDefaultCollectionAsync(organization, organizationUser, collectionName); } - // Remove subscription from user + // Remove subscription from a user user.Premium = false; user.PremiumExpirationDate = null; user.GatewaySubscriptionId = null; From 41049b10d909399d471ca9b308624f0ca9c5c221 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 24 Mar 2026 13:27:13 -0400 Subject: [PATCH 16/24] feat(payment): add type helper properties to masked payment method --- src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs index d30c27ee4165..61ce292ef4d4 100644 --- a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs +++ b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs @@ -32,6 +32,10 @@ public record MaskedPayPalAccount public class MaskedPaymentMethod(OneOf input) : OneOfBase(input) { + public bool IsBankAccount => IsT0; + public bool IsCard => IsT1; + public bool IsPayPal => IsT2; + public static implicit operator MaskedPaymentMethod(MaskedBankAccount bankAccount) => new(bankAccount); public static implicit operator MaskedPaymentMethod(MaskedCard card) => new(card); public static implicit operator MaskedPaymentMethod(MaskedPayPalAccount payPal) => new(payPal); From 40b815ca32ae568e8a453928c1e032226465bc4a Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 24 Mar 2026 13:27:13 -0400 Subject: [PATCH 17/24] refactor(billing): replace IHasPaymentMethodQuery with IGetPaymentMethodQuery --- .../Commands/UpgradePremiumToOrganizationCommand.cs | 2 +- .../UpgradePremiumToOrganizationCommandTests.cs | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 63d596df3953..56d1ae0e7989 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -62,7 +62,7 @@ public class UpgradePremiumToOrganizationCommand( IOrganizationApiKeyRepository organizationApiKeyRepository, ICollectionRepository collectionRepository, IBraintreeService braintreeService, - IHasPaymentMethodQuery hasPaymentMethodQuery, + IGetPaymentMethodQuery getPaymentMethodQuery, IApplicationCacheService applicationCacheService, IPushNotificationService pushNotificationService) : BaseBillingCommand(logger), IUpgradePremiumToOrganizationCommand diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 454593c50a70..402d46405252 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -139,7 +139,7 @@ private static List CreateTestPremiumPlansList() private readonly ICollectionRepository _collectionRepository = Substitute.For(); private readonly IApplicationCacheService _applicationCacheService = Substitute.For(); private readonly IBraintreeService _braintreeService = Substitute.For(); - private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For(); + private readonly IGetPaymentMethodQuery _getPaymentMethodQuery = Substitute.For(); private readonly IPushNotificationService _pushNotificationService = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly UpgradePremiumToOrganizationCommand _command; @@ -153,7 +153,12 @@ public UpgradePremiumToOrganizationCommandTests() Metadata = new Dictionary(), InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = "pm_card_123" } })); - _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); + _getPaymentMethodQuery.Run(Arg.Any()).Returns(new MaskedPaymentMethod(new MaskedCard + { + Brand = "visa", + Last4 = "4242", + Expiration = "12/25" + })); _command = new UpgradePremiumToOrganizationCommand( _logger, @@ -165,7 +170,7 @@ public UpgradePremiumToOrganizationCommandTests() _organizationApiKeyRepository, _collectionRepository, _braintreeService, - _hasPaymentMethodQuery, + _getPaymentMethodQuery, _applicationCacheService, _pushNotificationService); } @@ -1511,7 +1516,7 @@ public async Task Run_WithNoPaymentMethod_ReturnsBadRequest(User user) _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); - _hasPaymentMethodQuery.Run(user).Returns(false); + _getPaymentMethodQuery.Run(user).Returns((MaskedPaymentMethod?)null); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); From 88b272c5b8414eb933026a62097af2470445fe48 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 24 Mar 2026 13:27:13 -0400 Subject: [PATCH 18/24] feat(billing): prohibit bank accounts for organization upgrades --- .../UpgradePremiumToOrganizationCommand.cs | 17 ++++--- ...pgradePremiumToOrganizationCommandTests.cs | 45 +++++-------------- 2 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 56d1ae0e7989..c4059a021dc0 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -85,6 +85,17 @@ public Task> Run( return new BadRequest("User does not have an active Premium subscription."); } + var paymentMethod = await getPaymentMethodQuery.Run(user); + if (paymentMethod is null) + { + return new BadRequest("No payment method found for the user. Please add a payment method to upgrade to Organization plan."); + } + + if (paymentMethod.IsBankAccount) + { + return new BadRequest("Bank accounts are not supported for upgrading to an Organization plan. Please use a card or PayPal."); + } + // Fetch the current Premium subscription from Stripe var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); @@ -115,12 +126,6 @@ public Task> Run( var organization = BuildOrganization( organizationId, user, organizationName, publicKey, encryptedPrivateKey, targetPlan, currentSubscription.Id); - var hasPaymentMethod = await hasPaymentMethodQuery.Run(user); - if (!hasPaymentMethod) - { - return new BadRequest("No payment method found for the user. Please add a payment method to upgrade to Organization plan."); - } - // Update customer billing address for tax calculation var customer = await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 402d46405252..3d083b2e9e15 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; @@ -1528,53 +1529,27 @@ public async Task Run_WithNoPaymentMethod_ReturnsBadRequest(User user) } [Theory, BitAutoData] - public async Task Run_WithUnverifiedBankAccount_SetsDefaultIncompleteAndDoesNotPayViaBraintree(User user) + public async Task Run_WithBankAccount_ReturnsBadRequest(User user) { // Arrange user.Premium = true; user.GatewaySubscriptionId = "sub_123"; user.GatewayCustomerId = "cus_123"; - var mockSubscription = new Subscription - { - Id = "sub_123", - Items = new StripeList - { - Data = - [ - new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } - ] - }, - Metadata = new Dictionary() - }; - - // Customer with no default payment method and no PayPal — unverified bank account - var bankAccountCustomer = new Customer + _getPaymentMethodQuery.Run(user).Returns(new MaskedPaymentMethod(new MaskedBankAccount { - Metadata = new Dictionary(), - InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = null } - }; - - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); - _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); - _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); - _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(bankAccountCustomer); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); - _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); - _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + BankName = "Chase", + Last4 = "6789" + })); // Act var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert - Assert.True(result.Success); - await _stripeAdapter.Received(1).UpdateSubscriptionAsync( - "sub_123", - Arg.Is(opts => opts.PaymentBehavior == "default_incomplete")); - await _braintreeService.DidNotReceive().PayInvoice(Arg.Any(), Arg.Any()); + Assert.True(result.IsT1); + Assert.Equal("Bank accounts are not supported for upgrading to an Organization plan. Please use a card or PayPal.", result.AsT1.Response); + await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } } From 827d36155b9c95ba7a4998d8cf570432eda014d5 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 24 Mar 2026 13:27:13 -0400 Subject: [PATCH 19/24] refactor(billing): simplify subscription payment behavior logic --- .../Premium/Commands/UpgradePremiumToOrganizationCommand.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index c4059a021dc0..c2254de3582b 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -276,9 +276,6 @@ private async Task UpdateSubscriptionAsync( List subscriptionItemOptions) { var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false; - var hasDefaultPaymentMethod = !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) - || !string.IsNullOrEmpty(customer.DefaultSourceId); - var requiresIncomplete = usingPayPal || !hasDefaultPaymentMethod; // Build the subscription update options var subscriptionUpdateOptions = new SubscriptionUpdateOptions @@ -292,7 +289,7 @@ private async Task UpdateSubscriptionAsync( [MetadataKeys.OrganizationId] = organizationId.ToString(), [MetadataKeys.UserId] = string.Empty // Remove userId to unlink the subscription from User }, - PaymentBehavior = requiresIncomplete ? PaymentBehavior.DefaultIncomplete : null + PaymentBehavior = usingPayPal ? PaymentBehavior.DefaultIncomplete : null }; // Update the subscription in Stripe From d910e40a5c8e2a75cc3f713ae33208e25a6946cc Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 25 Mar 2026 17:45:34 -0400 Subject: [PATCH 20/24] test(billing): update premium organization upgrade bank account test --- .../Commands/UpgradePremiumToOrganizationCommandTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 3d083b2e9e15..fa79d0d1f03f 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1529,7 +1529,7 @@ public async Task Run_WithNoPaymentMethod_ReturnsBadRequest(User user) } [Theory, BitAutoData] - public async Task Run_WithBankAccount_ReturnsBadRequest(User user) + public async Task Run_WithUnverifiedBankAccount_ReturnsBadRequest(User user) { // Arrange user.Premium = true; @@ -1539,7 +1539,8 @@ public async Task Run_WithBankAccount_ReturnsBadRequest(User user) _getPaymentMethodQuery.Run(user).Returns(new MaskedPaymentMethod(new MaskedBankAccount { BankName = "Chase", - Last4 = "6789" + Last4 = "6789", + HostedVerificationUrl = "https://verify.stripe.com/abc" })); // Act From 3e54f7ce457d3c147282a194b96428c5e26441cd Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 25 Mar 2026 17:45:34 -0400 Subject: [PATCH 21/24] feat(billing): allow premium organization upgrade with verified bank accounts --- .../Premium/Commands/UpgradePremiumToOrganizationCommand.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index c2254de3582b..609bdbf5d6ca 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -91,9 +91,9 @@ public Task> Run( return new BadRequest("No payment method found for the user. Please add a payment method to upgrade to Organization plan."); } - if (paymentMethod.IsBankAccount) + if (paymentMethod.IsBankAccount && paymentMethod.AsT0.HostedVerificationUrl is not null) { - return new BadRequest("Bank accounts are not supported for upgrading to an Organization plan. Please use a card or PayPal."); + return new BadRequest("Unverified bank accounts are not supported for upgrading to an Organization plan. Please use a card or PayPal."); } // Fetch the current Premium subscription from Stripe From 1b1a33aef4ca8b1303ccc9ddd3fa3bae9761fca6 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 25 Mar 2026 17:45:34 -0400 Subject: [PATCH 22/24] test(billing): add test for premium organization upgrade with verified bank account --- ...pgradePremiumToOrganizationCommandTests.cs | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index fa79d0d1f03f..8bac53112833 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1548,9 +1548,65 @@ public async Task Run_WithUnverifiedBankAccount_ReturnsBadRequest(User user) // Assert Assert.True(result.IsT1); - Assert.Equal("Bank accounts are not supported for upgrading to an Organization plan. Please use a card or PayPal.", result.AsT1.Response); + Assert.Equal("Unverified bank accounts are not supported for upgrading to an Organization plan. Please use a card or PayPal.", result.AsT1.Response); await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any()); await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task Run_WithVerifiedBankAccount_Succeeds(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + _getPaymentMethodQuery.Run(user).Returns(new MaskedPaymentMethod(new MaskedBankAccount + { + BankName = "Chase", + Last4 = "6789" + // No HostedVerificationUrl = verified bank account + })); + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + ] + }, + Metadata = new Dictionary() + }; + + var mockCustomer = new Customer + { + Metadata = new Dictionary(), + InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = "pm_bank" } + }; + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(CreateTestPremiumPlansList()); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually")); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", null, PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => opts.PaymentBehavior == null)); + await _braintreeService.DidNotReceive().PayInvoice(Arg.Any(), Arg.Any()); + } + } From 7d167653c2da7bef4631a1f6e0884ab838ede231 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 26 Mar 2026 18:14:18 -0400 Subject: [PATCH 23/24] style(files): remove byte order mark from files --- .../Premium/Commands/UpgradePremiumToOrganizationCommand.cs | 2 +- .../Commands/UpgradePremiumToOrganizationCommandTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 5f1d2ffc69f1..b0f368494c50 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index ca86a1299671..4c15e6af8283 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; From 64d360b4632628973cde03392bf48e52d7ad6811 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 26 Mar 2026 18:14:18 -0400 Subject: [PATCH 24/24] refactor(billing): centralize premium upgrade billing logic --- .../Commands/UpgradePremiumToOrganizationCommand.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index b0f368494c50..fd94ae6bb2d1 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -139,14 +139,17 @@ public Task> Run( TaxExempt = TaxHelpers.DetermineTaxExemptStatus(billingAddress.Country), }); - await UpdateSubscriptionAsync(currentSubscription.Id, organizationId, customer, subscriptionItemOptions); - // Add tax ID to the customer for accurate tax calculation if provided if (billingAddress.TaxId != null) { await AddTaxIdToCustomerAsync(user.GatewayCustomerId!, billingAddress.TaxId); } + // Release any scheduled price increase before updating subscription + await priceIncreaseScheduler.Release(user.GatewayCustomerId, currentSubscription.Id); + + await UpdateSubscriptionAsync(currentSubscription.Id, organizationId, customer, subscriptionItemOptions); + var organizationUser = await SaveOrganizationAsync(organization, user, key); // Create a default collection if a collection name is provided @@ -308,11 +311,7 @@ private async Task SaveOrganizationAsync( User user, string key) { - await priceIncreaseScheduler.Release(user.GatewayCustomerId, currentSubscription.Id); - // Update the subscription in Stripe - await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); - } // Save the organization await organizationRepository.CreateAsync(organization);