diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index e352297f1e37..8d6988c3f07f 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -796,6 +796,44 @@ await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, } } + public async Task UpdateProviderNameAndEmail(Provider provider) + { + if (string.IsNullOrWhiteSpace(provider.GatewayCustomerId)) + { + logger.LogWarning( + "Provider ({ProviderId}) has no Stripe customer to update", + provider.Id); + return; + } + + var newDisplayName = provider.DisplayName(); + + // Provider.DisplayName() can return null - handle gracefully + if (string.IsNullOrWhiteSpace(newDisplayName)) + { + logger.LogWarning( + "Provider ({ProviderId}) has no name to update in Stripe", + provider.Id); + return; + } + + await stripeAdapter.CustomerUpdateAsync(provider.GatewayCustomerId, + new CustomerUpdateOptions + { + Email = provider.BillingEmail, + Description = newDisplayName, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = provider.SubscriberType(), + Value = newDisplayName + }] + }, + }); + } + private Func CurrySeatScalingUpdate( Provider provider, ProviderPlan providerPlan, diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index daf35e7ae93b..111fd46ca400 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -2151,4 +2151,151 @@ await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscrip } #endregion + + #region UpdateProviderNameAndEmail + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullGatewayCustomerId_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.GatewayCustomerId = null; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_EmptyGatewayCustomerId_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.GatewayCustomerId = ""; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullProviderName_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = null; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_EmptyProviderName_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = ""; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_ValidProvider_CallsStripeWithCorrectParameters( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = "Test Provider"; + provider.BillingEmail = "billing@test.com"; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).CustomerUpdateAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.Email == provider.BillingEmail && + options.Description == provider.Name && + options.InvoiceSettings.CustomFields.Count == 1 && + options.InvoiceSettings.CustomFields[0].Name == "Provider" && + options.InvoiceSettings.CustomFields[0].Value == provider.Name)); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_LongProviderName_UsesFullName( + Provider provider, + SutProvider sutProvider) + { + // Arrange + var longName = new string('A', 50); // 50 characters + provider.Name = longName; + provider.BillingEmail = "billing@test.com"; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).CustomerUpdateAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.InvoiceSettings.CustomFields[0].Value == longName)); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullBillingEmail_UpdatesWithNull( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = "Test Provider"; + provider.BillingEmail = null; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).CustomerUpdateAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.Email == null && + options.Description == provider.Name)); + } + + #endregion } diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 2ea539f39feb..3a2973e00631 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Enums; @@ -56,6 +57,7 @@ public class OrganizationsController : Controller private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IOrganizationBillingService _organizationBillingService; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -80,7 +82,8 @@ public OrganizationsController( IProviderBillingService providerBillingService, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, IPricingClient pricingClient, - IResendOrganizationInviteCommand resendOrganizationInviteCommand) + IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IOrganizationBillingService organizationBillingService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -105,6 +108,7 @@ public OrganizationsController( _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _organizationBillingService = organizationBillingService; } [RequirePermission(Permission.Org_List_View)] @@ -241,6 +245,8 @@ public async Task Edit(Guid id, OrganizationEditModel model) var existingOrganizationData = new Organization { Id = organization.Id, + Name = organization.Name, + BillingEmail = organization.BillingEmail, Status = organization.Status, PlanType = organization.PlanType, Seats = organization.Seats @@ -286,6 +292,22 @@ await HandlePotentialProviderSeatScalingAsync( await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + // Sync name/email changes to Stripe + if (existingOrganizationData.Name != organization.Name || existingOrganizationData.BillingEmail != organization.BillingEmail) + { + try + { + await _organizationBillingService.UpdateOrganizationNameAndEmail(organization); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update Stripe customer for organization {OrganizationId}. Database was updated successfully.", + organization.Id); + TempData["Warning"] = "Organization updated successfully, but Stripe customer name/email synchronization failed."; + } + } + return RedirectToAction("Edit", new { id }); } diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 9344179a77fb..0db3467a26f4 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -56,6 +56,7 @@ public class ProvidersController : Controller private readonly IStripeAdapter _stripeAdapter; private readonly IAccessControlService _accessControlService; private readonly ISubscriberService _subscriberService; + private readonly ILogger _logger; public ProvidersController(IOrganizationRepository organizationRepository, IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, @@ -72,7 +73,8 @@ public ProvidersController(IOrganizationRepository organizationRepository, IPricingClient pricingClient, IStripeAdapter stripeAdapter, IAccessControlService accessControlService, - ISubscriberService subscriberService) + ISubscriberService subscriberService, + ILogger logger) { _organizationRepository = organizationRepository; _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; @@ -92,6 +94,7 @@ public ProvidersController(IOrganizationRepository organizationRepository, _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; _subscriberService = subscriberService; + _logger = logger; } [RequirePermission(Permission.Provider_List_View)] @@ -296,6 +299,9 @@ public async Task Edit(Guid id, ProviderEditModel model) var originalProviderStatus = provider.Enabled; + // Capture original billing email before modifications for Stripe sync + var originalBillingEmail = provider.BillingEmail; + model.ToProvider(provider); // validate the stripe ids to prevent saving a bad one @@ -321,6 +327,22 @@ public async Task Edit(Guid id, ProviderEditModel model) await _providerService.UpdateAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); + // Sync billing email changes to Stripe + if (!string.IsNullOrEmpty(provider.GatewayCustomerId) && originalBillingEmail != provider.BillingEmail) + { + try + { + await _providerBillingService.UpdateProviderNameAndEmail(provider); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.", + provider.Id); + TempData["Warning"] = "Provider updated successfully, but Stripe customer email synchronization failed."; + } + } + if (!provider.IsBillable()) { return RedirectToAction("Edit", new { id }); diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index aa87bf9c745f..515404e8a990 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Services; @@ -23,15 +24,20 @@ public class ProvidersController : Controller private readonly IProviderService _providerService; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; + private readonly IProviderBillingService _providerBillingService; + private readonly ILogger _logger; public ProvidersController(IUserService userService, IProviderRepository providerRepository, - IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings) + IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings, + IProviderBillingService providerBillingService, ILogger logger) { _userService = userService; _providerRepository = providerRepository; _providerService = providerService; _currentContext = currentContext; _globalSettings = globalSettings; + _providerBillingService = providerBillingService; + _logger = logger; } [HttpGet("{id:guid}")] @@ -65,7 +71,27 @@ public async Task Put(Guid id, [FromBody] ProviderUpdateR throw new NotFoundException(); } + // Capture original values before modifications for Stripe sync + var originalName = provider.Name; + var originalBillingEmail = provider.BillingEmail; + await _providerService.UpdateAsync(model.ToProvider(provider, _globalSettings)); + + // Sync name/email changes to Stripe + if (originalName != provider.Name || originalBillingEmail != provider.BillingEmail) + { + try + { + await _providerBillingService.UpdateProviderNameAndEmail(provider); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.", + provider.Id); + } + } + return new ProviderResponseModel(provider); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs index 64358f3048e3..83318fd1e62d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs @@ -67,7 +67,7 @@ private async Task UpdateBillingAsync(Organization organization, string original var shouldUpdateBilling = originalName != organization.Name || originalBillingEmail != organization.BillingEmail; - if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + if (!shouldUpdateBilling) { return; } diff --git a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index 6c7f087ffaa2..39d2a789e634 100644 --- a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -61,10 +61,6 @@ Task UpdatePaymentMethod( /// Updates the organization name and email on the Stripe customer entry. /// This only updates Stripe, not the Bitwarden database. /// - /// - /// The caller should ensure that the organization has a GatewayCustomerId before calling this method. - /// /// The organization to update in Stripe. - /// Thrown when the organization does not have a GatewayCustomerId. Task UpdateOrganizationNameAndEmail(Organization organization); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 65c339fad46e..8baf5b7a6eb8 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -178,13 +178,25 @@ public async Task UpdateSubscriptionPlanFrequency( public async Task UpdateOrganizationNameAndEmail(Organization organization) { - if (organization.GatewayCustomerId is null) + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { - throw new BillingException("Cannot update an organization in Stripe without a GatewayCustomerId."); + logger.LogWarning( + "Organization ({OrganizationId}) has no Stripe customer to update", + organization.Id); + return; } var newDisplayName = organization.DisplayName(); + // Organization.DisplayName() can return null - handle gracefully + if (string.IsNullOrWhiteSpace(newDisplayName)) + { + logger.LogWarning( + "Organization ({OrganizationId}) has no name to update in Stripe", + organization.Id); + return; + } + await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { @@ -197,9 +209,7 @@ await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerInvoiceSettingsCustomFieldOptions { Name = organization.SubscriberType(), - Value = newDisplayName.Length <= 30 - ? newDisplayName - : newDisplayName[..30] + Value = newDisplayName }] }, }); diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index 57d68db0387f..3f5a48e817ce 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -113,4 +113,11 @@ Task UpdatePaymentMethod( TaxInformation taxInformation); Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); + + /// + /// Updates the provider name and email on the Stripe customer entry. + /// This only updates Stripe, not the Bitwarden database. + /// + /// The provider to update in Stripe. + Task UpdateProviderNameAndEmail(Provider provider); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs index 3a60a6ffd2ca..d547d80aed08 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs @@ -30,7 +30,7 @@ public async Task UpdateAsync_WhenValidOrganization_UpdatesOrganization( var organizationBillingService = sutProvider.GetDependency(); organization.Id = organizationId; - organization.GatewayCustomerId = null; // No Stripe customer, so no billing update + organization.GatewayCustomerId = null; // No Stripe customer, but billing update is still called organizationRepository .GetByIdAsync(organizationId) @@ -61,8 +61,8 @@ await organizationService result, EventType.Organization_Updated); await organizationBillingService - .DidNotReceiveWithAnyArgs() - .UpdateOrganizationNameAndEmail(Arg.Any()); + .Received(1) + .UpdateOrganizationNameAndEmail(result); } [Theory, BitAutoData] @@ -93,7 +93,7 @@ public async Task UpdateAsync_WhenOrganizationNotFound_ThrowsNotFoundException( [Theory] [BitAutoData("")] [BitAutoData((string)null)] - public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate( + public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_CallsBillingUpdateButHandledGracefully( string gatewayCustomerId, Guid organizationId, Organization organization, @@ -133,8 +133,8 @@ await organizationService result, EventType.Organization_Updated); await organizationBillingService - .DidNotReceiveWithAnyArgs() - .UpdateOrganizationNameAndEmail(Arg.Any()); + .Received(1) + .UpdateOrganizationNameAndEmail(result); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 4060b45528b2..acfa53e3af9e 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; @@ -392,12 +391,13 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_TruncatesTo30Characters( + public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_UsesFullName( Organization organization, SutProvider sutProvider) { // Arrange - organization.Name = "This is a very long organization name that exceeds thirty characters"; + var longName = "This is a very long organization name that exceeds thirty characters"; + organization.Name = longName; CustomerUpdateOptions capturedOptions = null; sutProvider.GetDependency() @@ -421,14 +421,11 @@ await sutProvider.GetDependency() Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields); var customField = capturedOptions.InvoiceSettings.CustomFields.First(); - Assert.Equal(30, customField.Value.Length); - - var expectedCustomFieldDisplayName = "This is a very long organizati"; - Assert.Equal(expectedCustomFieldDisplayName, customField.Value); + Assert.Equal(longName, customField.Value); } [Theory, BitAutoData] - public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_ThrowsBillingException( + public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_LogsWarningAndReturns( Organization organization, SutProvider sutProvider) { @@ -436,15 +433,93 @@ public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_Thr organization.GatewayCustomerId = null; organization.Name = "Test Organization"; organization.BillingEmail = "billing@example.com"; + var stripeAdapter = sutProvider.GetDependency(); - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization)); + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); - Assert.Contains("Cannot update an organization in Stripe without a GatewayCustomerId.", exception.Response); + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsEmpty_LogsWarningAndReturns( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.GatewayCustomerId = ""; + organization.Name = "Test Organization"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenNameIsNull_LogsWarningAndReturns( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = null; + organization.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenNameIsEmpty_LogsWarningAndReturns( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = ""; + organization.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.DidNotReceive().CustomerUpdateAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenBillingEmailIsNull_UpdatesWithNull( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = "Test Organization"; + organization.BillingEmail = null; + organization.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.Received(1).CustomerUpdateAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Email == null && + options.Description == organization.Name)); } }