1
0
mirror of https://github.com/bitwarden/server synced 2026-02-21 11:53:42 +00:00

[PM-31040] Replace ISetupIntentCache with customer-based approach (#6954)

* docs(billing): add design document for replacing SetupIntent cache

* docs(billing): add implementation plan for replacing SetupIntent cache

* feat(db): add gateway lookup stored procedures for Organization, Provider, and User

* feat(db): add gateway lookup indexes to Organization, Provider, and User table definitions

* chore(db): add SQL Server migration for gateway lookup indexes and stored procedures

* feat(repos): add gateway lookup methods to IOrganizationRepository and Dapper implementation

* feat(repos): add gateway lookup methods to IProviderRepository and Dapper implementation

* feat(repos): add gateway lookup methods to IUserRepository and Dapper implementation

* feat(repos): add EF OrganizationRepository gateway lookup methods and index configuration

* feat(repos): add EF ProviderRepository gateway lookup methods and index configuration

* feat(repos): add EF UserRepository gateway lookup methods and index configuration

* chore(db): add EF migrations for gateway lookup indexes

* refactor(billing): update SetupIntentSucceededHandler to use repository instead of cache

* refactor(billing): simplify StripeEventService by expanding customer on SetupIntent

* refactor(billing): query Stripe for SetupIntents by customer ID in GetPaymentMethodQuery

* refactor(billing): query Stripe for SetupIntents by customer ID in HasPaymentMethodQuery

* refactor(billing): update OrganizationBillingService to set customer on SetupIntent

* refactor(billing): update ProviderBillingService to set customer on SetupIntent and query by customer

* refactor(billing): update UpdatePaymentMethodCommand to set customer on SetupIntent

* refactor(billing): remove bank account support from CreatePremiumCloudHostedSubscriptionCommand

* refactor(billing): remove OrganizationBillingService.UpdatePaymentMethod dead code

* refactor(billing): remove ProviderBillingService.UpdatePaymentMethod

* refactor(billing): remove PremiumUserBillingService.UpdatePaymentMethod and UserService.ReplacePaymentMethodAsync

* refactor(billing): remove SubscriberService.UpdatePaymentSource and related dead code

* refactor(billing): update SubscriberService.GetPaymentSourceAsync to query Stripe by customer ID

Add Task 15a to plan - this was a missed requirement for updating
GetPaymentSourceAsync which still used the cache.

* refactor(billing): complete removal of PremiumUserBillingService.Finalize and UserService.SignUpPremiumAsync

* refactor(billing): remove ISetupIntentCache and SetupIntentDistributedCache

* chore: remove temporary planning documents

* chore: run dotnet format

* fix(billing): add MaxLength(50) to Provider gateway ID properties

* chore(db): add EF migrations for Provider gateway column lengths

* chore: run dotnet format

* chore: rename SQL migration for chronological order
This commit is contained in:
Alex Morask
2026-02-18 13:20:25 -06:00
committed by GitHub
parent 2ce98277b4
commit cfd5bedae0
69 changed files with 22548 additions and 1892 deletions

View File

@@ -3,9 +3,9 @@ using Bit.Billing.Services.Implementations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
@@ -18,28 +18,28 @@ public class SetupIntentSucceededHandlerTests
private static readonly Event _mockEvent = new() { Id = "evt_test", Type = "setup_intent.succeeded" };
private static readonly string[] _expand = ["payment_method"];
private readonly ILogger<SetupIntentSucceededHandler> _logger;
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderRepository _providerRepository;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
private readonly ISetupIntentCache _setupIntentCache;
private readonly IStripeAdapter _stripeAdapter;
private readonly IStripeEventService _stripeEventService;
private readonly SetupIntentSucceededHandler _handler;
public SetupIntentSucceededHandlerTests()
{
_logger = Substitute.For<ILogger<SetupIntentSucceededHandler>>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_providerRepository = Substitute.For<IProviderRepository>();
_pushNotificationAdapter = Substitute.For<IPushNotificationAdapter>();
_setupIntentCache = Substitute.For<ISetupIntentCache>();
_stripeAdapter = Substitute.For<IStripeAdapter>();
_stripeEventService = Substitute.For<IStripeEventService>();
_handler = new SetupIntentSucceededHandler(
_logger,
_organizationRepository,
_providerRepository,
_pushNotificationAdapter,
_setupIntentCache,
_stripeAdapter,
_stripeEventService);
}
@@ -60,7 +60,7 @@ public class SetupIntentSucceededHandlerTests
await _handler.HandleAsync(_mockEvent);
// Assert
await _setupIntentCache.DidNotReceiveWithAnyArgs().GetSubscriberIdForSetupIntent(Arg.Any<string>());
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByGatewayCustomerIdAsync(Arg.Any<string>());
await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync(
Arg.Any<string>(), Arg.Any<PaymentMethodAttachOptions>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Organization>());
@@ -68,10 +68,10 @@ public class SetupIntentSucceededHandlerTests
}
[Fact]
public async Task HandleAsync_NoSubscriberIdInCache_Returns()
public async Task HandleAsync_NoCustomerIdOnSetupIntent_Returns()
{
// Arrange
var setupIntent = CreateSetupIntent();
var setupIntent = CreateSetupIntent(customerId: null);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -79,8 +79,35 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns((Guid?)null);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByGatewayCustomerIdAsync(Arg.Any<string>());
await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync(
Arg.Any<string>(), Arg.Any<PaymentMethodAttachOptions>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Organization>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
}
[Fact]
public async Task HandleAsync_NoOrganizationOrProviderFound_LogsErrorAndReturns()
{
// Arrange
var customerId = "cus_test";
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Organization?)null);
_providerRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Provider?)null);
// Act
await _handler.HandleAsync(_mockEvent);
@@ -96,9 +123,9 @@ public class SetupIntentSucceededHandlerTests
public async Task HandleAsync_ValidOrganization_AttachesPaymentMethodAndSendsNotification()
{
// Arrange
var organizationId = Guid.NewGuid();
var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = "cus_test" };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var organization = new Organization { Id = Guid.NewGuid(), Name = "Test Org", GatewayCustomerId = customerId };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -106,10 +133,7 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(organization);
// Act
@@ -122,15 +146,18 @@ public class SetupIntentSucceededHandlerTests
await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(organization);
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
// Provider should not be queried when organization is found
await _providerRepository.DidNotReceiveWithAnyArgs().GetByGatewayCustomerIdAsync(Arg.Any<string>());
}
[Fact]
public async Task HandleAsync_ValidProvider_AttachesPaymentMethodAndSendsNotification()
{
// Arrange
var providerId = Guid.NewGuid();
var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = "cus_test" };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var provider = new Provider { Id = Guid.NewGuid(), Name = "Test Provider", GatewayCustomerId = customerId };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -138,13 +165,10 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Organization?)null);
_providerRepository.GetByIdAsync(providerId)
_providerRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(provider);
// Act
@@ -163,9 +187,9 @@ public class SetupIntentSucceededHandlerTests
public async Task HandleAsync_OrganizationWithoutGatewayCustomerId_DoesNotAttachPaymentMethod()
{
// Arrange
var organizationId = Guid.NewGuid();
var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var organization = new Organization { Id = Guid.NewGuid(), Name = "Test Org", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -173,10 +197,7 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(organization);
// Act
@@ -193,9 +214,9 @@ public class SetupIntentSucceededHandlerTests
public async Task HandleAsync_ProviderWithoutGatewayCustomerId_DoesNotAttachPaymentMethod()
{
// Arrange
var providerId = Guid.NewGuid();
var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var provider = new Provider { Id = Guid.NewGuid(), Name = "Test Provider", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -203,13 +224,10 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Organization?)null);
_providerRepository.GetByIdAsync(providerId)
_providerRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(provider);
// Act
@@ -222,7 +240,7 @@ public class SetupIntentSucceededHandlerTests
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
}
private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true)
private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true, string? customerId = "cus_default")
{
var paymentMethod = new PaymentMethod
{
@@ -234,6 +252,7 @@ public class SetupIntentSucceededHandlerTests
var setupIntent = new SetupIntent
{
Id = "seti_test",
CustomerId = customerId,
PaymentMethod = paymentMethod
};

View File

@@ -1,10 +1,6 @@
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
@@ -13,9 +9,6 @@ namespace Bit.Billing.Test.Services;
public class StripeEventServiceTests
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderRepository _providerRepository;
private readonly ISetupIntentCache _setupIntentCache;
private readonly IStripeFacade _stripeFacade;
private readonly StripeEventService _stripeEventService;
@@ -25,16 +18,9 @@ public class StripeEventServiceTests
var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" };
globalSettings.BaseServiceUri = baseServiceUriSettings;
_organizationRepository = Substitute.For<IOrganizationRepository>();
_providerRepository = Substitute.For<IProviderRepository>();
_setupIntentCache = Substitute.For<ISetupIntentCache>();
_stripeFacade = Substitute.For<IStripeFacade>();
_stripeEventService = new StripeEventService(
globalSettings,
Substitute.For<ILogger<StripeEventService>>(),
_organizationRepository,
_providerRepository,
_setupIntentCache,
_stripeFacade);
}
@@ -658,29 +644,17 @@ public class StripeEventServiceTests
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationCustomer_Success()
public async Task ValidateCloudRegion_SetupIntentSucceeded_WithCustomer_Success()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var organizationId = Guid.NewGuid();
var organizationCustomerId = "cus_org_test";
var mockOrganization = new Core.AdminConsole.Entities.Organization
{
Id = organizationId,
GatewayCustomerId = organizationCustomerId
};
var customer = CreateMockCustomer();
var mockSetupIntent = new SetupIntent { Id = "seti_test", Customer = customer };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(mockOrganization);
_stripeFacade.GetCustomer(organizationCustomerId)
.Returns(customer);
_stripeFacade.GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>())
.Returns(mockSetupIntent);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -688,60 +662,22 @@ public class StripeEventServiceTests
// Assert
Assert.True(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(organizationId);
await _stripeFacade.Received(1).GetCustomer(organizationCustomerId);
await _stripeFacade.Received(1).GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderCustomer_Success()
public async Task ValidateCloudRegion_SetupIntentSucceeded_NoCustomer_ReturnsFalse()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var providerId = Guid.NewGuid();
var providerCustomerId = "cus_provider_test";
var mockProvider = new Core.AdminConsole.Entities.Provider.Provider
{
Id = providerId,
GatewayCustomerId = providerCustomerId
};
var customer = CreateMockCustomer();
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
.Returns((Core.AdminConsole.Entities.Organization?)null);
_providerRepository.GetByIdAsync(providerId)
.Returns(mockProvider);
_stripeFacade.GetCustomer(providerCustomerId)
.Returns(customer);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
Assert.True(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(providerId);
await _providerRepository.Received(1).GetByIdAsync(providerId);
await _stripeFacade.Received(1).GetCustomer(providerCustomerId);
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_NoSubscriberIdInCache_ReturnsFalse()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var mockSetupIntent = new SetupIntent { Id = "seti_test", Customer = null };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns((Guid?)null);
_stripeFacade.GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>())
.Returns(mockSetupIntent);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -749,91 +685,9 @@ public class StripeEventServiceTests
// Assert
Assert.False(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await _providerRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any<string>());
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationWithoutGatewayCustomerId_ChecksProvider()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var subscriberId = Guid.NewGuid();
var providerCustomerId = "cus_provider_test";
var mockOrganizationWithoutCustomerId = new Core.AdminConsole.Entities.Organization
{
Id = subscriberId,
GatewayCustomerId = null
};
var mockProvider = new Core.AdminConsole.Entities.Provider.Provider
{
Id = subscriberId,
GatewayCustomerId = providerCustomerId
};
var customer = CreateMockCustomer();
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(subscriberId);
_organizationRepository.GetByIdAsync(subscriberId)
.Returns(mockOrganizationWithoutCustomerId);
_providerRepository.GetByIdAsync(subscriberId)
.Returns(mockProvider);
_stripeFacade.GetCustomer(providerCustomerId)
.Returns(customer);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
Assert.True(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(subscriberId);
await _providerRepository.Received(1).GetByIdAsync(subscriberId);
await _stripeFacade.Received(1).GetCustomer(providerCustomerId);
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderWithoutGatewayCustomerId_ReturnsFalse()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var subscriberId = Guid.NewGuid();
var mockProviderWithoutCustomerId = new Core.AdminConsole.Entities.Provider.Provider
{
Id = subscriberId,
GatewayCustomerId = null
};
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(subscriberId);
_organizationRepository.GetByIdAsync(subscriberId)
.Returns((Core.AdminConsole.Entities.Organization?)null);
_providerRepository.GetByIdAsync(subscriberId)
.Returns(mockProviderWithoutCustomerId);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
Assert.False(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(subscriberId);
await _providerRepository.Received(1).GetByIdAsync(subscriberId);
await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any<string>());
await _stripeFacade.Received(1).GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>());
}
#endregion