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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user