1
0
mirror of https://github.com/bitwarden/server synced 2026-01-05 10:03:23 +00:00

[PM-27117] Sync Stripe Customer details for Organizations and Providers in API & Admin (#6679)

* Sync Stripe customer details for Provider / Organization in API & Admin

* Remove unnecessary var

* Fix logical operator

* Remove customer ID check from callers

* Fix failing tests

* Missed conflicts
This commit is contained in:
Alex Morask
2025-12-16 07:59:05 -06:00
committed by GitHub
parent 2ecd6c8d5f
commit 39a6719361
11 changed files with 377 additions and 34 deletions

View File

@@ -796,6 +796,44 @@ public class ProviderBillingService(
} }
} }
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.UpdateCustomerAsync(provider.GatewayCustomerId,
new CustomerUpdateOptions
{
Email = provider.BillingEmail,
Description = newDisplayName,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider.SubscriberType(),
Value = newDisplayName
}]
},
});
}
private Func<int, Task> CurrySeatScalingUpdate( private Func<int, Task> CurrySeatScalingUpdate(
Provider provider, Provider provider,
ProviderPlan providerPlan, ProviderPlan providerPlan,

View File

@@ -2150,4 +2150,151 @@ public class ProviderBillingServiceTests
} }
#endregion #endregion
#region UpdateProviderNameAndEmail
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullGatewayCustomerId_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.GatewayCustomerId = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_EmptyGatewayCustomerId_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.GatewayCustomerId = "";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullProviderName_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = null;
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_EmptyProviderName_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_ValidProvider_CallsStripeWithCorrectParameters(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "Test Provider";
provider.BillingEmail = "billing@test.com";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(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<ProviderBillingService> 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<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.InvoiceSettings.CustomFields[0].Value == longName));
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullBillingEmail_UpdatesWithNull(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "Test Provider";
provider.BillingEmail = null;
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Email == null &&
options.Description == provider.Name));
}
#endregion
} }

View File

@@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
@@ -57,6 +58,7 @@ public class OrganizationsController : Controller
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IOrganizationBillingService _organizationBillingService;
public OrganizationsController( public OrganizationsController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@@ -81,7 +83,8 @@ public class OrganizationsController : Controller
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient, IPricingClient pricingClient,
IResendOrganizationInviteCommand resendOrganizationInviteCommand) IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IOrganizationBillingService organizationBillingService)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@@ -106,6 +109,7 @@ public class OrganizationsController : Controller
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand; _resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_organizationBillingService = organizationBillingService;
} }
[RequirePermission(Permission.Org_List_View)] [RequirePermission(Permission.Org_List_View)]
@@ -242,6 +246,8 @@ public class OrganizationsController : Controller
var existingOrganizationData = new Organization var existingOrganizationData = new Organization
{ {
Id = organization.Id, Id = organization.Id,
Name = organization.Name,
BillingEmail = organization.BillingEmail,
Status = organization.Status, Status = organization.Status,
PlanType = organization.PlanType, PlanType = organization.PlanType,
Seats = organization.Seats Seats = organization.Seats
@@ -287,6 +293,22 @@ public class OrganizationsController : Controller
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); 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 }); return RedirectToAction("Edit", new { id });
} }

View File

@@ -56,6 +56,7 @@ public class ProvidersController : Controller
private readonly IStripeAdapter _stripeAdapter; private readonly IStripeAdapter _stripeAdapter;
private readonly IAccessControlService _accessControlService; private readonly IAccessControlService _accessControlService;
private readonly ISubscriberService _subscriberService; private readonly ISubscriberService _subscriberService;
private readonly ILogger<ProvidersController> _logger;
public ProvidersController(IOrganizationRepository organizationRepository, public ProvidersController(IOrganizationRepository organizationRepository,
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
@@ -72,7 +73,8 @@ public class ProvidersController : Controller
IPricingClient pricingClient, IPricingClient pricingClient,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IAccessControlService accessControlService, IAccessControlService accessControlService,
ISubscriberService subscriberService) ISubscriberService subscriberService,
ILogger<ProvidersController> logger)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
@@ -92,6 +94,7 @@ public class ProvidersController : Controller
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId; _braintreeMerchantId = globalSettings.Braintree.MerchantId;
_subscriberService = subscriberService; _subscriberService = subscriberService;
_logger = logger;
} }
[RequirePermission(Permission.Provider_List_View)] [RequirePermission(Permission.Provider_List_View)]
@@ -296,6 +299,9 @@ public class ProvidersController : Controller
var originalProviderStatus = provider.Enabled; var originalProviderStatus = provider.Enabled;
// Capture original billing email before modifications for Stripe sync
var originalBillingEmail = provider.BillingEmail;
model.ToProvider(provider); model.ToProvider(provider);
// validate the stripe ids to prevent saving a bad one // validate the stripe ids to prevent saving a bad one
@@ -321,6 +327,22 @@ public class ProvidersController : Controller
await _providerService.UpdateAsync(provider); await _providerService.UpdateAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(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()) if (!provider.IsBillable())
{ {
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });

View File

@@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Request.Providers;
using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.AdminConsole.Models.Response.Providers;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Services; using Bit.Core.Services;
@@ -23,15 +24,20 @@ public class ProvidersController : Controller
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IProviderBillingService _providerBillingService;
private readonly ILogger<ProvidersController> _logger;
public ProvidersController(IUserService userService, IProviderRepository providerRepository, public ProvidersController(IUserService userService, IProviderRepository providerRepository,
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings) IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings,
IProviderBillingService providerBillingService, ILogger<ProvidersController> logger)
{ {
_userService = userService; _userService = userService;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerService = providerService; _providerService = providerService;
_currentContext = currentContext; _currentContext = currentContext;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_providerBillingService = providerBillingService;
_logger = logger;
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@@ -65,7 +71,27 @@ public class ProvidersController : Controller
throw new NotFoundException(); 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)); 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); return new ProviderResponseModel(provider);
} }

View File

@@ -67,7 +67,7 @@ public class OrganizationUpdateCommand(
var shouldUpdateBilling = originalName != organization.Name || var shouldUpdateBilling = originalName != organization.Name ||
originalBillingEmail != organization.BillingEmail; originalBillingEmail != organization.BillingEmail;
if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) if (!shouldUpdateBilling)
{ {
return; return;
} }

View File

@@ -61,10 +61,6 @@ public interface IOrganizationBillingService
/// Updates the organization name and email on the Stripe customer entry. /// Updates the organization name and email on the Stripe customer entry.
/// This only updates Stripe, not the Bitwarden database. /// This only updates Stripe, not the Bitwarden database.
/// </summary> /// </summary>
/// <remarks>
/// The caller should ensure that the organization has a GatewayCustomerId before calling this method.
/// </remarks>
/// <param name="organization">The organization to update in Stripe.</param> /// <param name="organization">The organization to update in Stripe.</param>
/// <exception cref="BillingException">Thrown when the organization does not have a GatewayCustomerId.</exception>
Task UpdateOrganizationNameAndEmail(Organization organization); Task UpdateOrganizationNameAndEmail(Organization organization);
} }

View File

@@ -177,13 +177,25 @@ public class OrganizationBillingService(
public async Task UpdateOrganizationNameAndEmail(Organization organization) 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(); 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.UpdateCustomerAsync(organization.GatewayCustomerId, await stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId,
new CustomerUpdateOptions new CustomerUpdateOptions
{ {
@@ -196,9 +208,7 @@ public class OrganizationBillingService(
new CustomerInvoiceSettingsCustomFieldOptions new CustomerInvoiceSettingsCustomFieldOptions
{ {
Name = organization.SubscriberType(), Name = organization.SubscriberType(),
Value = newDisplayName.Length <= 30 Value = newDisplayName
? newDisplayName
: newDisplayName[..30]
}] }]
}, },
}); });

View File

@@ -113,4 +113,11 @@ public interface IProviderBillingService
TaxInformation taxInformation); TaxInformation taxInformation);
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
/// <summary>
/// Updates the provider name and email on the Stripe customer entry.
/// This only updates Stripe, not the Bitwarden database.
/// </summary>
/// <param name="provider">The provider to update in Stripe.</param>
Task UpdateProviderNameAndEmail(Provider provider);
} }

View File

@@ -30,7 +30,7 @@ public class OrganizationUpdateCommandTests
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>(); var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>();
organization.Id = organizationId; 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 organizationRepository
.GetByIdAsync(organizationId) .GetByIdAsync(organizationId)
@@ -61,8 +61,8 @@ public class OrganizationUpdateCommandTests
result, result,
EventType.Organization_Updated); EventType.Organization_Updated);
await organizationBillingService await organizationBillingService
.DidNotReceiveWithAnyArgs() .Received(1)
.UpdateOrganizationNameAndEmail(Arg.Any<Organization>()); .UpdateOrganizationNameAndEmail(result);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
@@ -93,7 +93,7 @@ public class OrganizationUpdateCommandTests
[Theory] [Theory]
[BitAutoData("")] [BitAutoData("")]
[BitAutoData((string)null)] [BitAutoData((string)null)]
public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate( public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_CallsBillingUpdateButHandledGracefully(
string gatewayCustomerId, string gatewayCustomerId,
Guid organizationId, Guid organizationId,
Organization organization, Organization organization,
@@ -133,8 +133,8 @@ public class OrganizationUpdateCommandTests
result, result,
EventType.Organization_Updated); EventType.Organization_Updated);
await organizationBillingService await organizationBillingService
.DidNotReceiveWithAnyArgs() .Received(1)
.UpdateOrganizationNameAndEmail(Arg.Any<Organization>()); .UpdateOrganizationNameAndEmail(result);
} }
[Theory, BitAutoData] [Theory, BitAutoData]

View File

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
@@ -391,12 +390,13 @@ public class OrganizationBillingServiceTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_TruncatesTo30Characters( public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_UsesFullName(
Organization organization, Organization organization,
SutProvider<OrganizationBillingService> sutProvider) SutProvider<OrganizationBillingService> sutProvider)
{ {
// Arrange // 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; CustomerUpdateOptions capturedOptions = null;
sutProvider.GetDependency<IStripeAdapter>() sutProvider.GetDependency<IStripeAdapter>()
@@ -420,14 +420,11 @@ public class OrganizationBillingServiceTests
Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields); Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields);
var customField = capturedOptions.InvoiceSettings.CustomFields.First(); var customField = capturedOptions.InvoiceSettings.CustomFields.First();
Assert.Equal(30, customField.Value.Length); Assert.Equal(longName, customField.Value);
var expectedCustomFieldDisplayName = "This is a very long organizati";
Assert.Equal(expectedCustomFieldDisplayName, customField.Value);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_ThrowsBillingException( public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_LogsWarningAndReturns(
Organization organization, Organization organization,
SutProvider<OrganizationBillingService> sutProvider) SutProvider<OrganizationBillingService> sutProvider)
{ {
@@ -435,15 +432,93 @@ public class OrganizationBillingServiceTests
organization.GatewayCustomerId = null; organization.GatewayCustomerId = null;
organization.Name = "Test Organization"; organization.Name = "Test Organization";
organization.BillingEmail = "billing@example.com"; organization.BillingEmail = "billing@example.com";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act & Assert // Act
var exception = await Assert.ThrowsAsync<BillingException>( await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
() => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization));
Assert.Contains("Cannot update an organization in Stripe without a GatewayCustomerId.", exception.Response); // Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
await sutProvider.GetDependency<IStripeAdapter>() [Theory, BitAutoData]
.DidNotReceiveWithAnyArgs() public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsEmpty_LogsWarningAndReturns(
.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()); Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
organization.GatewayCustomerId = "";
organization.Name = "Test Organization";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenNameIsNull_LogsWarningAndReturns(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
organization.Name = null;
organization.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenNameIsEmpty_LogsWarningAndReturns(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
organization.Name = "";
organization.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenBillingEmailIsNull_UpdatesWithNull(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
organization.Name = "Test Organization";
organization.BillingEmail = null;
organization.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Email == null &&
options.Description == organization.Name));
} }
} }