Allow addition of PayPal payment method when bad Braintree customer ID is linked

This commit is contained in:
Alex Morask 2026-02-03 11:25:18 -06:00
parent 8cc906ec5f
commit bcf483d967
No known key found for this signature in database
GPG Key ID: 23E38285B743E3A8
7 changed files with 272 additions and 82 deletions

View File

@ -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<UpdatePaymentMethodCommand> 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

View File

@ -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<GetPaymentMethodQuery> 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

View File

@ -1,11 +1,14 @@
using Bit.Core.Billing.Subscriptions.Models;
using Stripe;
using Braintree;
namespace Bit.Core.Services;
public interface IBraintreeService
{
Task<Customer?> GetCustomer(
Stripe.Customer customer);
Task PayInvoice(
SubscriberId subscriberId,
Invoice invoice);
Stripe.Invoice invoice);
}

View File

@ -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<Customer?> 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<string, string>
{
@ -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 });
}
}

View File

@ -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<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>();
@ -32,6 +34,7 @@ public class UpdatePaymentMethodCommandTests
{
_command = new UpdatePaymentMethodCommand(
_braintreeGateway,
_braintreeService,
_globalSettings,
Substitute.For<ILogger<UpdatePaymentMethodCommand>>(),
_setupIntentCache,
@ -375,7 +378,6 @@ public class UpdatePaymentMethodCommandTests
_subscriberService.GetCustomer(organization).Returns(customer);
var customerGateway = Substitute.For<ICustomerGateway>();
var braintreeCustomer = Substitute.For<Braintree.Customer>();
braintreeCustomer.Id.Returns("braintree_customer_id");
var existing = Substitute.For<PayPalAccount>();
@ -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<ICustomerGateway>();
_braintreeGateway.Customer.Returns(customerGateway);
var paymentMethodGateway = Substitute.For<IPaymentMethodGateway>();
@ -471,4 +476,75 @@ public class UpdatePaymentMethodCommandTests
Arg.Is<CustomerUpdateOptions>(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<string, string>
{
[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<ICustomerGateway>();
var braintreeCustomer = Substitute.For<Braintree.Customer>();
braintreeCustomer.Id.Returns("new_braintree_customer_id");
var payPalAccount = Substitute.For<PayPalAccount>();
payPalAccount.Email.Returns("user@gmail.com");
payPalAccount.IsDefault.Returns(true);
payPalAccount.Token.Returns("NONCE");
braintreeCustomer.PaymentMethods.Returns([payPalAccount]);
var createResult = Substitute.For<Result<Braintree.Customer>>();
createResult.Target.Returns(braintreeCustomer);
customerGateway.CreateAsync(Arg.Is<CustomerRequest>(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<string>());
await customerGateway.Received(1).CreateAsync(Arg.Any<CustomerRequest>());
// Verify Stripe metadata was updated with the new Braintree customer ID
await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id,
Arg.Is<CustomerUpdateOptions>(options =>
options.Metadata[MetadataKeys.BraintreeCustomerId] == "new_braintree_customer_id"));
}
}

View File

@ -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<IBraintreeGateway>();
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>();
@ -29,8 +28,7 @@ public class GetPaymentMethodQueryTests
public GetPaymentMethodQueryTests()
{
_query = new GetPaymentMethodQuery(
_braintreeGateway,
Substitute.For<ILogger<GetPaymentMethodQuery>>(),
_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<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "non_existent_braintree_customer_id"
}
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(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<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var customerGateway = Substitute.For<ICustomerGateway>();
var braintreeCustomer = Substitute.For<Braintree.Customer>();
var payPalAccount = Substitute.For<PayPalAccount>();
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<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "non_existent_braintree_customer_id"
}
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var customerGateway = Substitute.For<ICustomerGateway>();
customerGateway.FindAsync("non_existent_braintree_customer_id").Returns<Braintree.Customer>(_ => throw new NotFoundException());
_braintreeGateway.Customer.Returns(customerGateway);
var maskedPaymentMethod = await _query.Run(organization);
Assert.Null(maskedPaymentMethod);
}
}

View File

@ -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<IBraintreeGateway>();
_customerGateway = Substitute.For<ICustomerGateway>();
braintreeGateway.Customer.Returns(_customerGateway);
var globalSettings = Substitute.For<IGlobalSettings>();
var logger = Substitute.For<ILogger<BraintreeService>>();
var mailService = Substitute.For<IMailService>();
var stripeAdapter = Substitute.For<IStripeAdapter>();
_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<string, string>()
};
// Act
var result = await _sut.GetCustomer(stripeCustomer);
// Assert
Assert.Null(result);
await _customerGateway.DidNotReceiveWithAnyArgs().FindAsync(Arg.Any<string>());
}
[Fact]
public async Task GetCustomer_BraintreeCustomerFound_ReturnsCustomer()
{
// Arrange
const string braintreeCustomerId = "bt_customer_123";
var stripeCustomer = new Customer
{
Id = "cus_123",
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomerId
}
};
var braintreeCustomer = Substitute.For<Braintree.Customer>();
_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<string, string>
{
[StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomerId
}
};
_customerGateway
.FindAsync(braintreeCustomerId)
.Returns<Braintree.Customer>(_ => throw new NotFoundException());
// Act
var result = await _sut.GetCustomer(stripeCustomer);
// Assert
Assert.Null(result);
await _customerGateway.Received(1).FindAsync(braintreeCustomerId);
}
#endregion
}