diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs index 2166c4318c..5734babc31 100644 --- a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -23,6 +24,7 @@ public interface IUpdatePaymentMethodCommand public class UpdatePaymentMethodCommand( IBraintreeGateway braintreeGateway, + IBraintreeService braintreeService, IGlobalSettings globalSettings, ILogger logger, ISetupIntentCache setupIntentCache, @@ -123,12 +125,10 @@ public class UpdatePaymentMethodCommand( Customer customer, string token) { - Braintree.Customer braintreeCustomer; + var braintreeCustomer = await braintreeService.GetCustomer(customer); - if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + if (braintreeCustomer != null) { - braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); - await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token); } else diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs index 45ab8aff74..58e9930b87 100644 --- a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -1,12 +1,10 @@ using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; +using Bit.Core.Services; using Braintree; -using Braintree.Exceptions; -using Microsoft.Extensions.Logging; using Stripe; namespace Bit.Core.Billing.Payment.Queries; @@ -17,8 +15,7 @@ public interface IGetPaymentMethodQuery } public class GetPaymentMethodQuery( - IBraintreeGateway braintreeGateway, - ILogger logger, + IBraintreeService braintreeService, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IGetPaymentMethodQuery @@ -33,32 +30,12 @@ public class GetPaymentMethodQuery( return null; } - // First check for PayPal - if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + // First check for a PayPal account + var braintreeCustomer = await braintreeService.GetCustomer(customer); + + if (braintreeCustomer is { DefaultPaymentMethod: PayPalAccount payPalAccount }) { - try - { - var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); - - if (braintreeCustomer.DefaultPaymentMethod is PayPalAccount payPalAccount) - { - return new MaskedPayPalAccount { Email = payPalAccount.Email }; - } - - logger.LogWarning( - "Subscriber ({SubscriberID}) has a linked Braintree customer ({BraintreeCustomerId}) with no PayPal account.", - subscriber.Id, - braintreeCustomerId); - } - catch (NotFoundException) - { - logger.LogWarning( - "Subscriber ({SubscriberID}) is linked to a Braintree Customer ({BraintreeCustomerId}) that does not exist.", - subscriber.Id, - braintreeCustomerId); - } - - return null; + return new MaskedPayPalAccount { Email = payPalAccount.Email }; } // Then check for a bank account pending verification diff --git a/src/Core/Services/IBraintreeService.cs b/src/Core/Services/IBraintreeService.cs index 166d285908..d4f5809f41 100644 --- a/src/Core/Services/IBraintreeService.cs +++ b/src/Core/Services/IBraintreeService.cs @@ -1,11 +1,14 @@ using Bit.Core.Billing.Subscriptions.Models; -using Stripe; +using Braintree; namespace Bit.Core.Services; public interface IBraintreeService { + Task GetCustomer( + Stripe.Customer customer); + Task PayInvoice( SubscriberId subscriberId, - Invoice invoice); + Stripe.Invoice invoice); } diff --git a/src/Core/Services/Implementations/BraintreeService.cs b/src/Core/Services/Implementations/BraintreeService.cs index e3630ed888..6ff3f5ce59 100644 --- a/src/Core/Services/Implementations/BraintreeService.cs +++ b/src/Core/Services/Implementations/BraintreeService.cs @@ -1,11 +1,10 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; -using Bit.Core.Exceptions; using Bit.Core.Settings; using Braintree; +using Braintree.Exceptions; using Microsoft.Extensions.Logging; -using Stripe; namespace Bit.Core.Services.Implementations; @@ -18,11 +17,34 @@ public class BraintreeService( IMailService mailService, IStripeAdapter stripeAdapter) : IBraintreeService { - private readonly ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support."); + private readonly Exceptions.ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support."); + + public async Task GetCustomer( + Stripe.Customer customer) + { + if (!customer.Metadata.TryGetValue(MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + return null; + } + + try + { + return await braintreeGateway.Customer.FindAsync(braintreeCustomerId); + } + catch (NotFoundException) + { + logger.LogWarning( + "Stripe customer ({CustomerId}) is linked to a Braintree Customer ({BraintreeCustomerId}) that does not exist.", + customer.Id, + braintreeCustomerId); + + return null; + } + } public async Task PayInvoice( SubscriberId subscriberId, - Invoice invoice) + Stripe.Invoice invoice) { if (invoice.Customer == null) { @@ -93,7 +115,7 @@ public class BraintreeService( return; } - await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions + await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new Stripe.InvoiceUpdateOptions { Metadata = new Dictionary { @@ -102,6 +124,6 @@ public class BraintreeService( } }); - await stripeAdapter.PayInvoiceAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + await stripeAdapter.PayInvoiceAsync(invoice.Id, new Stripe.InvoicePayOptions { PaidOutOfBand = true }); } } diff --git a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs index da42127f33..7643510e74 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Test.Billing.Extensions; using Braintree; @@ -22,6 +23,7 @@ using static StripeConstants; public class UpdatePaymentMethodCommandTests { private readonly IBraintreeGateway _braintreeGateway = Substitute.For(); + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly IGlobalSettings _globalSettings = Substitute.For(); private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); @@ -32,6 +34,7 @@ public class UpdatePaymentMethodCommandTests { _command = new UpdatePaymentMethodCommand( _braintreeGateway, + _braintreeService, _globalSettings, Substitute.For>(), _setupIntentCache, @@ -375,7 +378,6 @@ public class UpdatePaymentMethodCommandTests _subscriberService.GetCustomer(organization).Returns(customer); - var customerGateway = Substitute.For(); var braintreeCustomer = Substitute.For(); braintreeCustomer.Id.Returns("braintree_customer_id"); var existing = Substitute.For(); @@ -383,7 +385,10 @@ public class UpdatePaymentMethodCommandTests existing.IsDefault.Returns(true); existing.Token.Returns("EXISTING"); braintreeCustomer.PaymentMethods.Returns([existing]); - customerGateway.FindAsync("braintree_customer_id").Returns(braintreeCustomer); + + _braintreeService.GetCustomer(customer).Returns(braintreeCustomer); + + var customerGateway = Substitute.For(); _braintreeGateway.Customer.Returns(customerGateway); var paymentMethodGateway = Substitute.For(); @@ -471,4 +476,75 @@ public class UpdatePaymentMethodCommandTests Arg.Is(options => options.Metadata[MetadataKeys.BraintreeCustomerId] == "braintree_customer_id")); } + + [Fact] + public async Task Run_PayPal_MissingBraintreeCustomer_CreatesNewBraintreeCustomer_ReturnsMaskedPayPalAccount() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + GatewayCustomerId = "cus_123" + }; + + var customer = new Customer + { + Address = new Address + { + Country = "US", + PostalCode = "12345" + }, + Id = "cus_123", + Metadata = new Dictionary + { + [MetadataKeys.BraintreeCustomerId] = "missing_braintree_customer_id" + } + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + // BraintreeService.GetCustomer returns null when the Braintree customer doesn't exist + _braintreeService.GetCustomer(customer).Returns((Braintree.Customer?)null); + + _globalSettings.BaseServiceUri.Returns(new GlobalSettings.BaseServiceUriSettings(new GlobalSettings()) + { + CloudRegion = "US" + }); + + var customerGateway = Substitute.For(); + var braintreeCustomer = Substitute.For(); + braintreeCustomer.Id.Returns("new_braintree_customer_id"); + var payPalAccount = Substitute.For(); + payPalAccount.Email.Returns("user@gmail.com"); + payPalAccount.IsDefault.Returns(true); + payPalAccount.Token.Returns("NONCE"); + braintreeCustomer.PaymentMethods.Returns([payPalAccount]); + var createResult = Substitute.For>(); + createResult.Target.Returns(braintreeCustomer); + customerGateway.CreateAsync(Arg.Is(options => + options.Id.StartsWith(organization.BraintreeCustomerIdPrefix() + organization.Id.ToString("N").ToLower()) && + options.CustomFields[organization.BraintreeIdField()] == organization.Id.ToString() && + options.CustomFields[organization.BraintreeCloudRegionField()] == "US" && + options.Email == organization.BillingEmailAddress() && + options.PaymentMethodNonce == "TOKEN")).Returns(createResult); + _braintreeGateway.Customer.Returns(customerGateway); + + var result = await _command.Run(organization, + new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "TOKEN" }, + new BillingAddress { Country = "US", PostalCode = "12345" }); + + Assert.True(result.IsT0); + var maskedPaymentMethod = result.AsT0; + Assert.True(maskedPaymentMethod.IsT2); + var maskedPayPalAccount = maskedPaymentMethod.AsT2; + Assert.Equal("user@gmail.com", maskedPayPalAccount.Email); + + // Verify a new Braintree customer was created (not FindAsync called) + await customerGateway.DidNotReceive().FindAsync(Arg.Any()); + await customerGateway.Received(1).CreateAsync(Arg.Any()); + + // Verify Stripe metadata was updated with the new Braintree customer ID + await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, + Arg.Is(options => + options.Metadata[MetadataKeys.BraintreeCustomerId] == "new_braintree_customer_id")); + } } diff --git a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs index 17f29e38dd..8c65bf68be 100644 --- a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs +++ b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs @@ -3,10 +3,9 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; +using Bit.Core.Services; using Bit.Core.Test.Billing.Extensions; using Braintree; -using Braintree.Exceptions; -using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ReturnsExtensions; using Stripe; @@ -20,7 +19,7 @@ using static StripeConstants; public class GetPaymentMethodQueryTests { - private readonly IBraintreeGateway _braintreeGateway = Substitute.For(); + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly ISubscriberService _subscriberService = Substitute.For(); @@ -29,8 +28,7 @@ public class GetPaymentMethodQueryTests public GetPaymentMethodQueryTests() { _query = new GetPaymentMethodQuery( - _braintreeGateway, - Substitute.For>(), + _braintreeService, _setupIntentCache, _stripeAdapter, _subscriberService); @@ -76,6 +74,34 @@ public class GetPaymentMethodQueryTests Assert.Null(maskedPaymentMethod); } + [Fact] + public async Task Run_NoPaymentMethod_BraintreeCustomerNotFound_ReturnsNull() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary + { + [MetadataKeys.BraintreeCustomerId] = "non_existent_braintree_customer_id" + } + }; + + _subscriberService.GetCustomer(organization, + Arg.Is(options => + options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer); + + _braintreeService.GetCustomer(customer).ReturnsNull(); + + var maskedPaymentMethod = await _query.Run(organization); + + Assert.Null(maskedPaymentMethod); + } + [Fact] public async Task Run_BankAccount_FromPaymentMethod_ReturnsMaskedBankAccount() { @@ -329,14 +355,12 @@ public class GetPaymentMethodQueryTests Arg.Is(options => options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer); - var customerGateway = Substitute.For(); var braintreeCustomer = Substitute.For(); var payPalAccount = Substitute.For(); payPalAccount.Email.Returns("user@gmail.com"); payPalAccount.IsDefault.Returns(true); braintreeCustomer.PaymentMethods.Returns([payPalAccount]); - customerGateway.FindAsync("braintree_customer_id").Returns(braintreeCustomer); - _braintreeGateway.Customer.Returns(customerGateway); + _braintreeService.GetCustomer(customer).Returns(braintreeCustomer); var maskedPaymentMethod = await _query.Run(organization); @@ -345,34 +369,4 @@ public class GetPaymentMethodQueryTests var maskedPayPalAccount = maskedPaymentMethod.AsT2; Assert.Equal("user@gmail.com", maskedPayPalAccount.Email); } - - [Fact] - public async Task Run_BraintreeCustomerNotFound_ReturnsNull() - { - var organization = new Organization - { - Id = Guid.NewGuid() - }; - - var customer = new Customer - { - InvoiceSettings = new CustomerInvoiceSettings(), - Metadata = new Dictionary - { - [MetadataKeys.BraintreeCustomerId] = "non_existent_braintree_customer_id" - } - }; - - _subscriberService.GetCustomer(organization, - Arg.Is(options => - options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer); - - var customerGateway = Substitute.For(); - customerGateway.FindAsync("non_existent_braintree_customer_id").Returns(_ => throw new NotFoundException()); - _braintreeGateway.Customer.Returns(customerGateway); - - var maskedPaymentMethod = await _query.Run(organization); - - Assert.Null(maskedPaymentMethod); - } } diff --git a/test/Core.Test/Services/Implementations/BraintreeServiceTests.cs b/test/Core.Test/Services/Implementations/BraintreeServiceTests.cs new file mode 100644 index 0000000000..e0d04ae1ab --- /dev/null +++ b/test/Core.Test/Services/Implementations/BraintreeServiceTests.cs @@ -0,0 +1,118 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Services; +using Bit.Core.Settings; +using Braintree; +using Braintree.Exceptions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +using BraintreeService = Bit.Core.Services.Implementations.BraintreeService; +using Customer = Stripe.Customer; + +namespace Bit.Core.Test.Services.Implementations; + +public class BraintreeServiceTests +{ + private readonly ICustomerGateway _customerGateway; + private readonly BraintreeService _sut; + + public BraintreeServiceTests() + { + var braintreeGateway = Substitute.For(); + _customerGateway = Substitute.For(); + braintreeGateway.Customer.Returns(_customerGateway); + + var globalSettings = Substitute.For(); + var logger = Substitute.For>(); + var mailService = Substitute.For(); + var stripeAdapter = Substitute.For(); + + _sut = new BraintreeService( + braintreeGateway, + globalSettings, + logger, + mailService, + stripeAdapter); + } + + #region GetCustomer + + [Fact] + public async Task GetCustomer_NoBraintreeCustomerIdInMetadata_ReturnsNull() + { + // Arrange + var stripeCustomer = new Customer + { + Id = "cus_123", + Metadata = new Dictionary() + }; + + // Act + var result = await _sut.GetCustomer(stripeCustomer); + + // Assert + Assert.Null(result); + await _customerGateway.DidNotReceiveWithAnyArgs().FindAsync(Arg.Any()); + } + + [Fact] + public async Task GetCustomer_BraintreeCustomerFound_ReturnsCustomer() + { + // Arrange + const string braintreeCustomerId = "bt_customer_123"; + + var stripeCustomer = new Customer + { + Id = "cus_123", + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomerId + } + }; + + var braintreeCustomer = Substitute.For(); + + _customerGateway + .FindAsync(braintreeCustomerId) + .Returns(braintreeCustomer); + + // Act + var result = await _sut.GetCustomer(stripeCustomer); + + // Assert + Assert.NotNull(result); + Assert.Same(braintreeCustomer, result); + await _customerGateway.Received(1).FindAsync(braintreeCustomerId); + } + + [Fact] + public async Task GetCustomer_BraintreeCustomerNotFound_LogsWarningAndReturnsNull() + { + // Arrange + const string braintreeCustomerId = "bt_non_existent_customer"; + + var stripeCustomer = new Customer + { + Id = "cus_123", + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomerId + } + }; + + _customerGateway + .FindAsync(braintreeCustomerId) + .Returns(_ => throw new NotFoundException()); + + // Act + var result = await _sut.GetCustomer(stripeCustomer); + + // Assert + Assert.Null(result); + await _customerGateway.Received(1).FindAsync(braintreeCustomerId); + } + + #endregion +}