diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index a7bf3e20cea5..c2eee69cc5d3 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.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; @@ -13,10 +13,13 @@ 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 TaxExempt = Bit.Core.Billing.Constants.StripeConstants.TaxExempt; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; 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}" }); } diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 181a5e6d33e5..450c56119158 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,126 @@ await _stripeAdapter.Received(1).CreateTaxIdAsync( options.Value == "DE123456789")); } + [Theory, BitAutoData] + public async Task Run_WithPayPalCustomer_SetsDefaultIncompletePaymentBehaviorAndPaysInvoiceViaBraintree(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" }; + + _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(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 _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + 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_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 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() }; + + _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.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_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNIFAndEUVAT(User user) {