1
0
mirror of https://github.com/bitwarden/server synced 2026-02-19 10:53:34 +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

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Models;
@@ -25,7 +24,6 @@ public class UpdatePaymentMethodCommandTests
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
private readonly IGlobalSettings _globalSettings = Substitute.For<IGlobalSettings>();
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly UpdatePaymentMethodCommand _command;
@@ -37,7 +35,6 @@ public class UpdatePaymentMethodCommandTests
_braintreeService,
_globalSettings,
Substitute.For<ILogger<UpdatePaymentMethodCommand>>(),
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
@@ -102,7 +99,8 @@ public class UpdatePaymentMethodCommandTests
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).UpdateSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == customer.Id));
}
[Fact]
@@ -166,7 +164,8 @@ public class UpdatePaymentMethodCommandTests
await _subscriberService.Received(1).CreateStripeCustomer(organization);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).UpdateSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == customer.Id));
}
[Fact]
@@ -233,7 +232,8 @@ public class UpdatePaymentMethodCommandTests
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).UpdateSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == customer.Id));
await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, Arg.Is<CustomerUpdateOptions>(options =>
options.Metadata[MetadataKeys.BraintreeCustomerId] == string.Empty &&
options.Metadata[MetadataKeys.RetiredBraintreeCustomerId] == "braintree_customer_id"));

View File

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
@@ -20,7 +19,6 @@ using static StripeConstants;
public class GetPaymentMethodQueryTests
{
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly GetPaymentMethodQuery _query;
@@ -29,7 +27,6 @@ public class GetPaymentMethodQueryTests
{
_query = new GetPaymentMethodQuery(
_braintreeService,
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
@@ -181,6 +178,7 @@ public class GetPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
@@ -189,11 +187,12 @@ public class GetPaymentMethodQueryTests
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.GetSetupIntentAsync("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method"))).Returns(
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(
[
new SetupIntent
{
PaymentMethod = new PaymentMethod
@@ -209,7 +208,8 @@ public class GetPaymentMethodQueryTests
}
},
Status = "requires_action"
});
}
]);
var maskedPaymentMethod = await _query.Run(organization);

View File

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
@@ -15,7 +14,6 @@ using static StripeConstants;
public class HasPaymentMethodQueryTests
{
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly HasPaymentMethodQuery _query;
@@ -23,7 +21,6 @@ public class HasPaymentMethodQueryTests
public HasPaymentMethodQueryTests()
{
_query = new HasPaymentMethodQuery(
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
@@ -37,45 +34,12 @@ public class HasPaymentMethodQueryTests
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_NoCustomer_WithUnverifiedBankAccount_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.GetSetupIntentAsync("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_NoPaymentMethod_ReturnsFalse()
{
@@ -86,6 +50,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
@@ -107,6 +72,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethodId = "pm_123"
@@ -131,6 +97,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
DefaultSourceId = "card_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
@@ -153,28 +120,32 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.GetSetupIntentAsync("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(
[
new SetupIntent
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
}
});
]);
var hasPaymentMethod = await _query.Run(organization);
@@ -191,6 +162,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>
{
@@ -206,7 +178,7 @@ public class HasPaymentMethodQueryTests
}
[Fact]
public async Task Run_NoSetupIntentId_ReturnsFalse()
public async Task Run_NoSetupIntents_ReturnsFalse()
{
var organization = new Organization
{
@@ -215,12 +187,18 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
_stripeAdapter
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(new List<SetupIntent>());
var hasPaymentMethod = await _query.Run(organization);
@@ -237,24 +215,28 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.GetSetupIntentAsync("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
PaymentMethod = new PaymentMethod
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(
[
new SetupIntent
{
Type = "card"
},
Status = "succeeded"
});
PaymentMethod = new PaymentMethod
{
Type = "card"
},
Status = "succeeded"
}
]);
var hasPaymentMethod = await _query.Run(organization);

View File

@@ -1,5 +1,4 @@
using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
@@ -32,7 +31,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
private readonly IGlobalSettings _globalSettings = Substitute.For<IGlobalSettings>();
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly IUserService _userService = Substitute.For<IUserService>();
@@ -63,7 +61,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
_braintreeGateway,
_braintreeService,
_globalSettings,
_setupIntentCache,
_stripeAdapter,
_subscriberService,
_userService,
@@ -110,63 +107,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.Equal("Additional storage must be greater than 0.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_ValidPaymentMethodTypes_BankAccount_Success(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null; // Ensure no existing customer ID
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.BankAccount;
paymentMethod.Token = "bank_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
var mockSetupIntent = Substitute.For<SetupIntent>();
mockSetupIntent.Id = "seti_123";
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateInvoiceAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
_stripeAdapter.ListSetupIntentsAsync(Arg.Any<SetupIntentListOptions>()).Returns(Task.FromResult(new List<SetupIntent> { mockSetupIntent }));
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any<CustomerCreateOptions>());
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.Received(1).SaveUserAsync(user);
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
}
[Theory, BitAutoData]
public async Task Run_ValidPaymentMethodTypes_Card_Success(
User user,
@@ -625,60 +565,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
}
[Theory, BitAutoData]
public async Task Run_BankAccountWithNoSetupIntentFound_ReturnsUnhandled(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null;
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.BankAccount;
paymentMethod.Token = "bank_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "incomplete";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateInvoiceAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.ListSetupIntentsAsync(Arg.Any<SetupIntentListOptions>())
.Returns(Task.FromResult(new List<SetupIntent>())); // Empty list - no setup intent found
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT3);
var unhandled = result.AsT3;
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
}
[Theory, BitAutoData]
public async Task Run_AccountCredit_WithExistingCustomer_Success(
User user,

View File

@@ -1,13 +1,11 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Enums;
using Bit.Core.Settings;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Braintree;
@@ -20,7 +18,6 @@ using Xunit;
using static Bit.Core.Test.Billing.Utilities;
using Address = Stripe.Address;
using Customer = Stripe.Customer;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
using PaymentMethod = Stripe.PaymentMethod;
using Subscription = Stripe.Subscription;
@@ -519,10 +516,11 @@ public class SubscriberServiceTests
}
};
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id);
sutProvider.GetDependency<IStripeAdapter>().GetSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentGetOptions>(options => options.Expand.Contains("payment_method"))).Returns(setupIntent);
sutProvider.GetDependency<IStripeAdapter>().ListSetupIntentsAsync(
Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.Expand.Contains("data.payment_method")))
.Returns([setupIntent]);
var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider);
@@ -1032,423 +1030,6 @@ public class SubscriberServiceTests
}
#endregion
#region UpdatePaymentMethod
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_NullSubscriber_ThrowsArgumentNullException(
SutProvider<SubscriberService> sutProvider)
=> await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdatePaymentSource(null, null));
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_NullTokenizedPaymentMethod_ThrowsArgumentNullException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
=> await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdatePaymentSource(provider, null));
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_NoToken_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer());
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.Card, null)));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_UnsupportedPaymentMethod_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer());
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.BitPay, "TOKEN")));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_BankAccount_IncorrectNumberOfSetupIntentsForToken_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer());
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options => options.PaymentMethod == "TOKEN"))
.Returns([new SetupIntent(), new SetupIntent()]);
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.BankAccount, "TOKEN")));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_BankAccount_Succeeds(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "braintree_customer_id"
}
});
var matchingSetupIntent = new SetupIntent { Id = "setup_intent_1" };
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options => options.PaymentMethod == "TOKEN"))
.Returns([matchingSetupIntent]);
stripeAdapter.ListCustomerPaymentMethodsAsync(provider.GatewayCustomerId).Returns([
new PaymentMethod { Id = "payment_method_1" }
]);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.BankAccount, "TOKEN"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_1");
await stripeAdapter.DidNotReceive().CancelSetupIntentAsync(Arg.Any<string>(),
Arg.Any<SetupIntentCancelOptions>());
await stripeAdapter.Received(1).DetachPaymentMethodAsync("payment_method_1");
await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Card_Succeeds(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))
)
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "braintree_customer_id"
}
});
stripeAdapter.ListCustomerPaymentMethodsAsync(provider.GatewayCustomerId).Returns([
new PaymentMethod { Id = "payment_method_1" }
]);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.Card, "TOKEN"));
await stripeAdapter.DidNotReceive().CancelSetupIntentAsync(Arg.Any<string>(),
Arg.Any<SetupIntentCancelOptions>());
await stripeAdapter.Received(1).DetachPaymentMethodAsync("payment_method_1");
await stripeAdapter.Received(1).AttachPaymentMethodAsync("TOKEN", Arg.Is<PaymentMethodAttachOptions>(
options => options.Customer == provider.GatewayCustomerId));
await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options =>
options.InvoiceSettings.DefaultPaymentMethod == "TOKEN" &&
options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_NullCustomer_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
customerGateway.FindAsync(braintreeCustomerId).ReturnsNull();
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await paymentMethodGateway.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<PaymentMethodRequest>());
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_CreatePaymentMethodFails_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var customer = Substitute.For<Braintree.Customer>();
customer.Id.Returns(braintreeCustomerId);
customerGateway.FindAsync(braintreeCustomerId).Returns(customer);
var createPaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
createPaymentMethodResult.IsSuccess().Returns(false);
paymentMethodGateway.CreateAsync(Arg.Is<PaymentMethodRequest>(
options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN"))
.Returns(createPaymentMethodResult);
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any<string>(), Arg.Any<CustomerRequest>());
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_UpdateCustomerFails_DeletePaymentMethod_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var customer = Substitute.For<Braintree.Customer>();
customer.Id.Returns(braintreeCustomerId);
customerGateway.FindAsync(braintreeCustomerId).Returns(customer);
var createPaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
var createdPaymentMethod = Substitute.For<Braintree.PaymentMethod>();
createdPaymentMethod.Token.Returns("TOKEN");
createPaymentMethodResult.IsSuccess().Returns(true);
createPaymentMethodResult.Target.Returns(createdPaymentMethod);
paymentMethodGateway.CreateAsync(Arg.Is<PaymentMethodRequest>(
options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN"))
.Returns(createPaymentMethodResult);
var updateCustomerResult = Substitute.For<Result<Braintree.Customer>>();
updateCustomerResult.IsSuccess().Returns(false);
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(options =>
options.DefaultPaymentMethodToken == createPaymentMethodResult.Target.Token))
.Returns(updateCustomerResult);
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await paymentMethodGateway.Received(1).DeleteAsync(createPaymentMethodResult.Target.Token);
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_Success(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var customer = Substitute.For<Braintree.Customer>();
var existingPaymentMethod = Substitute.For<Braintree.PaymentMethod>();
existingPaymentMethod.Token.Returns("OLD_TOKEN");
existingPaymentMethod.IsDefault.Returns(true);
customer.PaymentMethods.Returns([existingPaymentMethod]);
customer.Id.Returns(braintreeCustomerId);
customerGateway.FindAsync(braintreeCustomerId).Returns(customer);
var createPaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
var updatedPaymentMethod = Substitute.For<Braintree.PaymentMethod>();
updatedPaymentMethod.Token.Returns("TOKEN");
createPaymentMethodResult.IsSuccess().Returns(true);
createPaymentMethodResult.Target.Returns(updatedPaymentMethod);
paymentMethodGateway.CreateAsync(Arg.Is<PaymentMethodRequest>(
options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN"))
.Returns(createPaymentMethodResult);
var updateCustomerResult = Substitute.For<Result<Braintree.Customer>>();
updateCustomerResult.IsSuccess().Returns(true);
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(options =>
options.DefaultPaymentMethodToken == createPaymentMethodResult.Target.Token))
.Returns(updateCustomerResult);
var deletePaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
deletePaymentMethodResult.IsSuccess().Returns(true);
paymentMethodGateway.DeleteAsync(existingPaymentMethod.Token).Returns(deletePaymentMethodResult);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN"));
await paymentMethodGateway.Received(1).DeleteAsync(existingPaymentMethod.Token);
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_CreateCustomer_CustomerUpdateFails_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer
{
Id = provider.GatewayCustomerId
});
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
.Returns(new GlobalSettings.BaseServiceUriSettings(new GlobalSettings())
{
CloudRegion = "US"
});
var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var createCustomerResult = Substitute.For<Result<Braintree.Customer>>();
createCustomerResult.IsSuccess().Returns(false);
customerGateway.CreateAsync(Arg.Is<CustomerRequest>(
options =>
options.Id == braintreeCustomerId &&
options.CustomFields[provider.BraintreeIdField()] == provider.Id.ToString() &&
options.CustomFields[provider.BraintreeCloudRegionField()] == "US" &&
options.Email == provider.BillingEmailAddress() &&
options.PaymentMethodNonce == "TOKEN"))
.Returns(createCustomerResult);
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_CreateCustomer_Succeeds(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId
});
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
.Returns(new GlobalSettings.BaseServiceUriSettings(new GlobalSettings())
{
CloudRegion = "US"
});
var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var createCustomerResult = Substitute.For<Result<Braintree.Customer>>();
var createdCustomer = Substitute.For<Braintree.Customer>();
createdCustomer.Id.Returns(braintreeCustomerId);
createCustomerResult.IsSuccess().Returns(true);
createCustomerResult.Target.Returns(createdCustomer);
customerGateway.CreateAsync(Arg.Is<CustomerRequest>(
options =>
options.CustomFields[provider.BraintreeIdField()] == provider.Id.ToString() &&
options.CustomFields[provider.BraintreeCloudRegionField()] == "US" &&
options.Email == provider.BillingEmailAddress() &&
options.PaymentMethodNonce == "TOKEN"))
.Returns(createCustomerResult);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN"));
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateCustomerAsync(provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(
options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == braintreeCustomerId));
}
#endregion
#region UpdateTaxInformation
[Theory, BitAutoData]