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); diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 4be3ab7b39f0..609bdbf5d6ca 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -1,11 +1,12 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; 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; using Bit.Core.Billing.Tax.Utilities; using Bit.Core.Entities; using Bit.Core.Enums; @@ -16,8 +17,12 @@ using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; +using static Bit.Core.Billing.Constants.StripeConstants; +using static Bit.Core.Billing.Utilities; +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. @@ -56,6 +61,8 @@ public class UpgradePremiumToOrganizationCommand( IOrganizationUserRepository organizationUserRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, ICollectionRepository collectionRepository, + IBraintreeService braintreeService, + IGetPaymentMethodQuery getPaymentMethodQuery, IApplicationCacheService applicationCacheService, IPushNotificationService pushNotificationService) : BaseBillingCommand(logger), IUpgradePremiumToOrganizationCommand @@ -78,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 && paymentMethod.AsT0.HostedVerificationUrl is not null) + { + 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 var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); @@ -98,13 +116,82 @@ public Task> Run( // Get the target organization plan var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); - var isNonSeatBasedPmPlan = targetPlan.HasNonSeatBasedPasswordManagerPlan(); + var subscriptionItemOptions = BuildSubscriptionItemOptions( + currentSubscription, usersPremiumPlan, targetPlan, passwordManagerItem); - // 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; + // 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 = 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); + } + + 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 a user + user.Premium = false; + user.PremiumExpirationDate = null; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = null; + user.RevisionDate = DateTime.UtcNow; + + await userService.SaveUserAsync(user); + await SendPremiumStatusNotificationAsync(user); + + return organization.Id; + + }); + + private async Task SendPremiumStatusNotificationAsync(User user) => + await pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.PremiumStatusChanged, + Target = NotificationTarget.User, + TargetId = user.Id, + Payload = new PremiumStatusPushNotification + { + UserId = user.Id, + Premium = user.Premium, + }, + ExcludeCurrentContext = false, + }); + + private List BuildSubscriptionItemOptions( + Subscription currentSubscription, + PremiumPlan usersPremiumPlan, + Core.Models.StaticStore.Plan targetPlan, + SubscriptionItem passwordManagerItem) + { + var isNonSeatBasedPmPlan = targetPlan.HasNonSeatBasedPasswordManagerPlan(); // 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 => @@ -112,52 +199,37 @@ 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 - { - Id = passwordManagerItem.Id, - Price = targetPlan.PasswordManager.StripePlanId, - Quantity = 1 - }); - } - else + options.Add(new SubscriptionItemOptions { - subscriptionItemOptions.Add(new SubscriptionItemOptions - { - Id = passwordManagerItem.Id, - Price = targetPlan.PasswordManager.StripeSeatPlanId, - Quantity = initialSeats - }); - } + Id = passwordManagerItem.Id, + Price = isNonSeatBasedPmPlan + ? targetPlan.PasswordManager.StripePlanId + : targetPlan.PasswordManager.StripeSeatPlanId, + Quantity = 1 + }); - // 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, @@ -193,33 +265,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 = TaxHelpers.DetermineTaxExemptStatus(billingAddress.Country) - }); + 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, pay the invoice via 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, @@ -246,76 +337,61 @@ 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); } - - // Remove subscription from user - user.Premium = false; - user.PremiumExpirationDate = null; - user.GatewaySubscriptionId = null; - user.GatewayCustomerId = null; - user.RevisionDate = DateTime.UtcNow; - await userService.SaveUserAsync(user); - - await pushNotificationService.PushAsync(new PushNotification + catch (Exception ex) { - Type = PushType.PremiumStatusChanged, - Target = NotificationTarget.User, - TargetId = user.Id, - Payload = new PremiumStatusPushNotification - { - UserId = user.Id, - Premium = user.Premium, - }, - ExcludeCurrentContext = false, - }); + _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 + } + } - return organization.Id; - }); + private async Task PayInvoiceUsingPayPalAsync(Subscription subscription, Guid organizationId) + { + var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, + new InvoiceUpdateOptions { AutoAdvance = false, Expand = ["customer"] }); + + await braintreeService.PayInvoice(new OrganizationId(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, - new TaxIdCreateOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{taxId.Value}" - }); + await stripeAdapter.CreateTaxIdAsync(customerId, + new TaxIdCreateOptions { Type = TaxIdType.EUVAT, Value = $"ES{taxId.Value}" }); } } } 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); diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index e6f8b72ff47a..8bac53112833 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1,9 +1,12 @@ 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; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -119,13 +122,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) - }; + ]; } @@ -137,12 +139,28 @@ 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 IGetPaymentMethodQuery _getPaymentMethodQuery = Substitute.For(); private readonly IPushNotificationService _pushNotificationService = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly UpgradePremiumToOrganizationCommand _command; public UpgradePremiumToOrganizationCommandTests() { + // 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(), + InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = "pm_card_123" } + })); + _getPaymentMethodQuery.Run(Arg.Any()).Returns(new MaskedPaymentMethod(new MaskedCard + { + Brand = "visa", + Last4 = "4242", + Expiration = "12/25" + })); + _command = new UpgradePremiumToOrganizationCommand( _logger, _pricingClient, @@ -152,6 +170,8 @@ public UpgradePremiumToOrganizationCommandTests() _organizationUserRepository, _organizationApiKeyRepository, _collectionRepository, + _braintreeService, + _getPaymentMethodQuery, _applicationCacheService, _pushNotificationService); } @@ -221,15 +241,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() }; @@ -244,6 +264,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())); @@ -256,7 +277,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); @@ -297,15 +318,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() }; @@ -322,6 +343,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())); @@ -334,7 +356,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); @@ -365,15 +387,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 { @@ -391,6 +413,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); @@ -399,7 +422,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); @@ -425,21 +448,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() }; @@ -454,6 +478,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())); @@ -466,7 +491,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); @@ -493,21 +518,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() }; @@ -522,6 +548,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())); @@ -534,7 +561,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); @@ -560,15 +587,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() }; @@ -601,14 +628,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() }; @@ -633,7 +656,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); @@ -657,14 +680,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() }; @@ -687,7 +706,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); @@ -711,14 +730,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() }; @@ -741,7 +756,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); @@ -764,14 +779,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() }; @@ -794,7 +805,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); @@ -823,14 +834,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() }; @@ -853,7 +860,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); @@ -882,14 +889,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() }; @@ -912,7 +915,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); @@ -935,14 +938,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() }; @@ -965,7 +964,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); @@ -989,14 +988,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() }; @@ -1019,7 +1014,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 => @@ -1040,14 +1035,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() }; @@ -1071,7 +1062,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); @@ -1098,14 +1089,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() }; @@ -1128,7 +1115,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); @@ -1151,14 +1138,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() }; @@ -1189,7 +1172,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); @@ -1220,14 +1203,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() }; @@ -1257,7 +1236,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 => @@ -1278,14 +1257,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() }; @@ -1316,7 +1291,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 => @@ -1328,6 +1303,72 @@ 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 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.Success); + 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.IsOrganizationId && id.AsT1.Value == organizationId), + mockInvoice); + } + [Theory, BitAutoData] public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNIFAndEUVAT(User user) { @@ -1341,14 +1382,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() }; @@ -1379,7 +1416,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", @@ -1455,4 +1492,121 @@ 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")); + _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()); + + // 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_ReturnsBadRequest(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", + HostedVerificationUrl = "https://verify.stripe.com/abc" + })); + + // 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("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()); + } + }