Skip to content

Commit cfd5bed

Browse files
[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
1 parent 2ce9827 commit cfd5bed

File tree

69 files changed

+22547
-1891
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+22547
-1891
lines changed

bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,16 @@
99
using Bit.Core.AdminConsole.Enums.Provider;
1010
using Bit.Core.AdminConsole.Repositories;
1111
using Bit.Core.Billing;
12-
using Bit.Core.Billing.Caches;
1312
using Bit.Core.Billing.Constants;
1413
using Bit.Core.Billing.Enums;
1514
using Bit.Core.Billing.Extensions;
16-
using Bit.Core.Billing.Models;
1715
using Bit.Core.Billing.Payment.Models;
1816
using Bit.Core.Billing.Pricing;
1917
using Bit.Core.Billing.Providers.Entities;
2018
using Bit.Core.Billing.Providers.Models;
2119
using Bit.Core.Billing.Providers.Repositories;
2220
using Bit.Core.Billing.Providers.Services;
2321
using Bit.Core.Billing.Services;
24-
using Bit.Core.Billing.Tax.Models;
2522
using Bit.Core.Enums;
2623
using Bit.Core.Exceptions;
2724
using Bit.Core.Repositories;
@@ -51,7 +48,6 @@ public class ProviderBillingService(
5148
IProviderOrganizationRepository providerOrganizationRepository,
5249
IProviderPlanRepository providerPlanRepository,
5350
IProviderUserRepository providerUserRepository,
54-
ISetupIntentCache setupIntentCache,
5551
IStripeAdapter stripeAdapter,
5652
ISubscriberService subscriberService)
5753
: IProviderBillingService
@@ -518,6 +514,7 @@ public async Task<Customer> SetupCustomer(
518514
}
519515

520516
var braintreeCustomerId = "";
517+
var setupIntentId = "";
521518

522519
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
523520
switch (paymentMethod.Type)
@@ -539,7 +536,7 @@ public async Task<Customer> SetupCustomer(
539536
throw new BillingException();
540537
}
541538

542-
await setupIntentCache.Set(provider.Id, setupIntent.Id);
539+
setupIntentId = setupIntent.Id;
543540
break;
544541
}
545542
case TokenizablePaymentMethodType.Card:
@@ -558,7 +555,15 @@ public async Task<Customer> SetupCustomer(
558555

559556
try
560557
{
561-
return await stripeAdapter.CreateCustomerAsync(options);
558+
var customer = await stripeAdapter.CreateCustomerAsync(options);
559+
560+
if (!string.IsNullOrEmpty(setupIntentId))
561+
{
562+
await stripeAdapter.UpdateSetupIntentAsync(setupIntentId,
563+
new SetupIntentUpdateOptions { Customer = customer.Id });
564+
}
565+
566+
return customer;
562567
}
563568
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
564569
{
@@ -577,12 +582,10 @@ async Task Revert()
577582
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
578583
switch (paymentMethod.Type)
579584
{
580-
case TokenizablePaymentMethodType.BankAccount:
585+
case TokenizablePaymentMethodType.BankAccount when !string.IsNullOrEmpty(setupIntentId):
581586
{
582-
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
583587
await stripeAdapter.CancelSetupIntentAsync(setupIntentId,
584588
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
585-
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
586589
break;
587590
}
588591
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
@@ -635,17 +638,18 @@ public async Task<Subscription> SetupSubscription(
635638
});
636639
}
637640

638-
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
641+
var setupIntents = await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
642+
{
643+
Customer = customer.Id,
644+
Expand = ["data.payment_method"]
645+
});
639646

640-
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
641-
? await stripeAdapter.GetSetupIntentAsync(setupIntentId,
642-
new SetupIntentGetOptions { Expand = ["payment_method"] })
643-
: null;
647+
var hasUnverifiedBankAccount = setupIntents?.Any(si => si.IsUnverifiedBankAccount()) ?? false;
644648

645649
var usePaymentMethod =
646650
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
647651
customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
648-
setupIntent?.IsUnverifiedBankAccount() == true;
652+
hasUnverifiedBankAccount;
649653

650654
int? trialPeriodDays = provider.Type switch
651655
{
@@ -699,19 +703,6 @@ public async Task<Subscription> SetupSubscription(
699703
}
700704
}
701705

702-
public async Task UpdatePaymentMethod(
703-
Provider provider,
704-
TokenizedPaymentSource tokenizedPaymentSource,
705-
TaxInformation taxInformation)
706-
{
707-
await Task.WhenAll(
708-
subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource),
709-
subscriberService.UpdateTaxInformation(provider, taxInformation));
710-
711-
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
712-
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
713-
}
714-
715706
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
716707
{
717708
var (provider, updatedPlanConfigurations) = command;

bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using Bit.Core.AdminConsole.Enums.Provider;
77
using Bit.Core.AdminConsole.Models.Data.Provider;
88
using Bit.Core.AdminConsole.Repositories;
9-
using Bit.Core.Billing.Caches;
109
using Bit.Core.Billing.Constants;
1110
using Bit.Core.Billing.Enums;
1211
using Bit.Core.Billing.Payment.Models;
@@ -934,17 +933,11 @@ public async Task SetupCustomer_WithBankAccount_Error_Reverts(
934933
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
935934
.Throws<StripeException>();
936935

937-
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id");
938-
939936
await Assert.ThrowsAsync<StripeException>(() =>
940937
sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
941938

942-
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
943-
944939
await stripeAdapter.Received(1).CancelSetupIntentAsync("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
945940
options.CancellationReason == "abandoned"));
946-
947-
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
948941
}
949942

950943
[Theory, BitAutoData]
@@ -1031,7 +1024,8 @@ public async Task SetupCustomer_WithBankAccount_Success(
10311024

10321025
Assert.Equivalent(expected, actual);
10331026

1034-
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
1027+
await stripeAdapter.Received(1).UpdateSetupIntentAsync("setup_intent_id",
1028+
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == expected.Id));
10351029
}
10361030

10371031
[Theory, BitAutoData]
@@ -1532,15 +1526,12 @@ public async Task SetupSubscription_ChargeAutomatically_HasBankAccount_Succeeds(
15321526

15331527
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
15341528

1535-
1536-
const string setupIntentId = "seti_123";
1537-
1538-
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
1539-
1540-
sutProvider.GetDependency<IStripeAdapter>().GetSetupIntentAsync(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
1541-
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
1529+
sutProvider.GetDependency<IStripeAdapter>().ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
1530+
options.Customer == customer.Id &&
1531+
options.Expand.Contains("data.payment_method"))).Returns([
1532+
new SetupIntent
15421533
{
1543-
Id = setupIntentId,
1534+
Id = "seti_123",
15441535
Status = "requires_action",
15451536
NextAction = new SetupIntentNextAction
15461537
{
@@ -1550,7 +1541,8 @@ public async Task SetupSubscription_ChargeAutomatically_HasBankAccount_Succeeds(
15501541
{
15511542
UsBankAccount = new PaymentMethodUsBankAccount()
15521543
}
1553-
});
1544+
}
1545+
]);
15541546

15551547
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
15561548
sub =>

src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using Bit.Core.AdminConsole.Entities;
22
using Bit.Core.AdminConsole.Entities.Provider;
33
using Bit.Core.AdminConsole.Repositories;
4-
using Bit.Core.Billing.Caches;
54
using Bit.Core.Billing.Services;
65
using Bit.Core.Repositories;
76
using OneOf;
@@ -11,10 +10,10 @@
1110
namespace Bit.Billing.Services.Implementations;
1211

1312
public class SetupIntentSucceededHandler(
13+
ILogger<SetupIntentSucceededHandler> logger,
1414
IOrganizationRepository organizationRepository,
1515
IProviderRepository providerRepository,
1616
IPushNotificationAdapter pushNotificationAdapter,
17-
ISetupIntentCache setupIntentCache,
1817
IStripeAdapter stripeAdapter,
1918
IStripeEventService stripeEventService) : ISetupIntentSucceededHandler
2019
{
@@ -27,23 +26,29 @@ public async Task HandleAsync(Event parsedEvent)
2726

2827
if (setupIntent is not
2928
{
29+
CustomerId: not null,
3030
PaymentMethod.UsBankAccount: not null
3131
})
3232
{
33+
logger.LogWarning("SetupIntent {SetupIntentId} has no customer ID or is not a US bank account", setupIntent.Id);
3334
return;
3435
}
3536

36-
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
37-
if (subscriberId == null)
37+
var organization = await organizationRepository.GetByGatewayCustomerIdAsync(setupIntent.CustomerId);
38+
if (organization != null)
3839
{
40+
await SetPaymentMethodAsync(organization, setupIntent.PaymentMethod);
3941
return;
4042
}
4143

42-
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
43-
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
44+
var provider = await providerRepository.GetByGatewayCustomerIdAsync(setupIntent.CustomerId);
45+
if (provider != null)
46+
{
47+
await SetPaymentMethodAsync(provider, setupIntent.PaymentMethod);
48+
return;
49+
}
4450

45-
OneOf<Organization, Provider> entity = organization != null ? organization : provider!;
46-
await SetPaymentMethodAsync(entity, setupIntent.PaymentMethod);
51+
logger.LogError("No organization or provider found for customer {CustomerId}", setupIntent.CustomerId);
4752
}
4853

4954
private async Task SetPaymentMethodAsync(

src/Billing/Services/Implementations/StripeEventService.cs

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
using Bit.Billing.Constants;
2-
using Bit.Core.AdminConsole.Repositories;
3-
using Bit.Core.Billing.Caches;
4-
using Bit.Core.Repositories;
52
using Bit.Core.Settings;
63
using Stripe;
74

85
namespace Bit.Billing.Services.Implementations;
96

107
public class StripeEventService(
118
GlobalSettings globalSettings,
12-
ILogger<StripeEventService> logger,
13-
IOrganizationRepository organizationRepository,
14-
IProviderRepository providerRepository,
15-
ISetupIntentCache setupIntentCache,
169
IStripeFacade stripeFacade)
1710
: IStripeEventService
1811
{
@@ -117,7 +110,7 @@ HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed
117110
(await GetCustomer(stripeEvent, true)).Metadata,
118111

119112
HandledStripeWebhook.SetupIntentSucceeded =>
120-
await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent),
113+
(await GetSetupIntent(stripeEvent, true, customerExpansion)).Customer?.Metadata,
121114

122115
_ => null
123116
};
@@ -144,43 +137,6 @@ await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent),
144137

145138
return customer?.Metadata;
146139
}
147-
148-
async Task<Dictionary<string, string>?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent)
149-
{
150-
var setupIntent = await GetSetupIntent(localStripeEvent);
151-
152-
logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id);
153-
154-
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
155-
156-
logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id);
157-
158-
if (subscriberId == null)
159-
{
160-
logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id);
161-
return null;
162-
}
163-
164-
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
165-
logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id);
166-
if (organization is { GatewayCustomerId: not null })
167-
{
168-
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
169-
logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id);
170-
return organizationCustomer.Metadata;
171-
}
172-
173-
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
174-
logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id);
175-
if (provider is not { GatewayCustomerId: not null })
176-
{
177-
return null;
178-
}
179-
180-
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
181-
logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id);
182-
return providerCustomer.Metadata;
183-
}
184140
}
185141

186142
private static T Extract<T>(Event stripeEvent)

src/Core/AdminConsole/Entities/Provider/Provider.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Net;
1+
using System.ComponentModel.DataAnnotations;
2+
using System.Net;
23
using Bit.Core.AdminConsole.Enums.Provider;
34
using Bit.Core.Entities;
45
using Bit.Core.Enums;
@@ -33,7 +34,9 @@ public class Provider : ITableObject<Guid>, ISubscriber
3334
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
3435
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
3536
public GatewayType? Gateway { get; set; }
37+
[MaxLength(50)]
3638
public string? GatewayCustomerId { get; set; }
39+
[MaxLength(50)]
3740
public string? GatewaySubscriptionId { get; set; }
3841
public string? DiscountId { get; set; }
3942

src/Core/AdminConsole/Repositories/IOrganizationRepository.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ namespace Bit.Core.Repositories;
99

1010
public interface IOrganizationRepository : IRepository<Organization, Guid>
1111
{
12+
Task<Organization?> GetByGatewayCustomerIdAsync(string gatewayCustomerId);
13+
Task<Organization?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId);
1214
Task<Organization?> GetByIdentifierAsync(string identifier);
1315
Task<ICollection<Organization>> GetManyByEnabledAsync();
1416
Task<ICollection<Organization>> GetManyByUserIdAsync(Guid userId);

src/Core/AdminConsole/Repositories/IProviderRepository.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ namespace Bit.Core.AdminConsole.Repositories;
88

99
public interface IProviderRepository : IRepository<Provider, Guid>
1010
{
11+
Task<Provider?> GetByGatewayCustomerIdAsync(string gatewayCustomerId);
12+
Task<Provider?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId);
1113
Task<Provider?> GetByOrganizationIdAsync(Guid organizationId);
1214
Task<ICollection<Provider>> SearchAsync(string name, string userEmail, int skip, int take);
1315
Task<ICollection<ProviderAbility>> GetManyAbilitiesAsync();

src/Core/Billing/Caches/ISetupIntentCache.cs

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)