1
0
mirror of https://github.com/bitwarden/server synced 2025-12-24 04:03:25 +00:00

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

This commit is contained in:
Alex Morask
2025-12-03 09:19:14 -06:00
parent 28e9c24f33
commit 7d60297d6f
9 changed files with 373 additions and 27 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.CustomerUpdateAsync(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(
Provider provider,
ProviderPlan providerPlan,

View File

@@ -2151,4 +2151,151 @@ public class ProviderBillingServiceTests
}
#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().CustomerUpdateAsync(
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().CustomerUpdateAsync(
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().CustomerUpdateAsync(
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().CustomerUpdateAsync(
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).CustomerUpdateAsync(
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).CustomerUpdateAsync(
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).CustomerUpdateAsync(
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.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 class OrganizationsController : Controller
IProviderBillingService providerBillingService,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IOrganizationBillingService organizationBillingService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -105,6 +108,7 @@ public class OrganizationsController : Controller
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_organizationBillingService = organizationBillingService;
}
[RequirePermission(Permission.Org_List_View)]
@@ -241,6 +245,8 @@ public class OrganizationsController : Controller
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,23 @@ public class OrganizationsController : Controller
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
// Sync name/email changes to Stripe
if (!string.IsNullOrEmpty(organization.GatewayCustomerId) &
(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 });
}

View File

@@ -56,6 +56,7 @@ public class ProvidersController : Controller
private readonly IStripeAdapter _stripeAdapter;
private readonly IAccessControlService _accessControlService;
private readonly ISubscriberService _subscriberService;
private readonly ILogger<ProvidersController> _logger;
public ProvidersController(IOrganizationRepository organizationRepository,
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
@@ -72,7 +73,8 @@ public class ProvidersController : Controller
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
IAccessControlService accessControlService,
ISubscriberService subscriberService)
ISubscriberService subscriberService,
ILogger<ProvidersController> logger)
{
_organizationRepository = organizationRepository;
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
@@ -92,6 +94,7 @@ public class ProvidersController : Controller
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
_subscriberService = subscriberService;
_logger = logger;
}
[RequirePermission(Permission.Provider_List_View)]
@@ -296,6 +299,9 @@ public class ProvidersController : Controller
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 class ProvidersController : Controller
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 });

View File

@@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Request.Providers;
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<ProvidersController> _logger;
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;
_providerRepository = providerRepository;
_providerService = providerService;
_currentContext = currentContext;
_globalSettings = globalSettings;
_providerBillingService = providerBillingService;
_logger = logger;
}
[HttpGet("{id:guid}")]
@@ -65,7 +71,29 @@ public class ProvidersController : Controller
throw new NotFoundException();
}
// Capture original values before modifications for Stripe sync
var originalName = provider.Name;
var originalBillingEmail = provider.BillingEmail;
var hasGatewayCustomerId = !string.IsNullOrWhiteSpace(provider.GatewayCustomerId);
await _providerService.UpdateAsync(model.ToProvider(provider, _globalSettings));
// Sync name/email changes to Stripe
if (hasGatewayCustomerId &&
(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);
}

View File

@@ -61,10 +61,6 @@ public interface IOrganizationBillingService
/// Updates the organization name and email on the Stripe customer entry.
/// This only updates Stripe, not the Bitwarden database.
/// </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>
/// <exception cref="BillingException">Thrown when the organization does not have a GatewayCustomerId.</exception>
Task UpdateOrganizationNameAndEmail(Organization organization);
}

View File

@@ -178,13 +178,25 @@ public class OrganizationBillingService(
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 @@ public class OrganizationBillingService(
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization.SubscriberType(),
Value = newDisplayName.Length <= 30
? newDisplayName
: newDisplayName[..30]
Value = newDisplayName
}]
},
});

View File

@@ -113,4 +113,11 @@ public interface IProviderBillingService
TaxInformation taxInformation);
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

@@ -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 @@ public class OrganizationBillingServiceTests
}
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_TruncatesTo30Characters(
public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_UsesFullName(
Organization organization,
SutProvider<OrganizationBillingService> 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<IStripeAdapter>()
@@ -421,14 +421,11 @@ public class OrganizationBillingServiceTests
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<OrganizationBillingService> sutProvider)
{
@@ -436,15 +433,93 @@ public class OrganizationBillingServiceTests
organization.GatewayCustomerId = null;
organization.Name = "Test Organization";
organization.BillingEmail = "billing@example.com";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act & Assert
var exception = await Assert.ThrowsAsync<BillingException>(
() => 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<string>(),
Arg.Any<CustomerUpdateOptions>());
}
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsEmpty_LogsWarningAndReturns(
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().CustomerUpdateAsync(
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().CustomerUpdateAsync(
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().CustomerUpdateAsync(
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).CustomerUpdateAsync(
organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Email == null &&
options.Description == organization.Name));
}
}