1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-21881] Manage payment details outside of checkout (#6032)

* Add feature flag

* Further establish billing command pattern and use in PreviewTaxAmountCommand

* Add billing address models/commands/queries/tests

* Update TypeReadingJsonConverter to account for new union types

* Add payment method models/commands/queries/tests

* Add credit models/commands/queries/tests

* Add command/query registrations

* Add new endpoints to support new command model and payment functionality

* Run dotnet format

* Add InjectUserAttribute for easier AccountBillilngVNextController handling

* Add InjectOrganizationAttribute for easier OrganizationBillingVNextController handling

* Add InjectProviderAttribute for easier ProviderBillingVNextController handling

* Add XML documentation for billing command pipeline

* Fix StripeConstants post-nullability

* More nullability cleanup

* Run dotnet format
This commit is contained in:
Alex Morask
2025-07-10 08:32:25 -05:00
committed by GitHub
parent 3bfc24523e
commit 7f65a655d4
52 changed files with 3736 additions and 215 deletions

View File

@@ -0,0 +1,132 @@
using Bit.Api.Billing.Attributes;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Api;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Billing.Attributes;
public class InjectOrganizationAttributeTests
{
private readonly IOrganizationRepository _organizationRepository;
private readonly ActionExecutionDelegate _next;
private readonly ActionExecutingContext _context;
private readonly Organization _organization;
private readonly Guid _organizationId;
public InjectOrganizationAttributeTests()
{
_organizationRepository = Substitute.For<IOrganizationRepository>();
_organizationId = Guid.NewGuid();
_organization = new Organization { Id = _organizationId };
var httpContext = new DefaultHttpContext();
var services = new ServiceCollection();
services.AddScoped(_ => _organizationRepository);
httpContext.RequestServices = services.BuildServiceProvider();
var routeData = new RouteData { Values = { ["organizationId"] = _organizationId.ToString() } };
var actionContext = new ActionContext(
httpContext,
routeData,
new ActionDescriptor(),
new ModelStateDictionary()
);
_next = () => Task.FromResult(new ActionExecutedContext(
actionContext,
new List<IFilterMetadata>(),
new object()));
_context = new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object>(),
new object());
}
[Fact]
public async Task OnActionExecutionAsync_WithExistingOrganization_InjectsOrganization()
{
var attribute = new InjectOrganizationAttribute();
_organizationRepository.GetByIdAsync(_organizationId)
.Returns(_organization);
var parameter = new ParameterDescriptor
{
Name = "organization",
ParameterType = typeof(Organization)
};
_context.ActionDescriptor.Parameters = [parameter];
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Equal(_organization, _context.ActionArguments["organization"]);
}
[Fact]
public async Task OnActionExecutionAsync_WithNonExistentOrganization_ReturnsNotFound()
{
var attribute = new InjectOrganizationAttribute();
_organizationRepository.GetByIdAsync(_organizationId)
.Returns((Organization)null);
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<NotFoundObjectResult>(_context.Result);
var result = (NotFoundObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Organization not found.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_WithInvalidOrganizationId_ReturnsBadRequest()
{
var attribute = new InjectOrganizationAttribute();
_context.RouteData.Values["organizationId"] = "not-a-guid";
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<BadRequestObjectResult>(_context.Result);
var result = (BadRequestObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Route parameter 'organizationId' is missing or invalid.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_WithMissingOrganizationId_ReturnsBadRequest()
{
var attribute = new InjectOrganizationAttribute();
_context.RouteData.Values.Clear();
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<BadRequestObjectResult>(_context.Result);
var result = (BadRequestObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Route parameter 'organizationId' is missing or invalid.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_WithoutOrganizationParameter_ContinuesExecution()
{
var attribute = new InjectOrganizationAttribute();
_organizationRepository.GetByIdAsync(_organizationId)
.Returns(_organization);
_context.ActionDescriptor.Parameters = Array.Empty<ParameterDescriptor>();
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Empty(_context.ActionArguments);
}
}

View File

@@ -0,0 +1,190 @@
using Bit.Api.Billing.Attributes;
using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Billing.Attributes;
public class InjectProviderAttributeTests
{
private readonly IProviderRepository _providerRepository;
private readonly ICurrentContext _currentContext;
private readonly ActionExecutionDelegate _next;
private readonly ActionExecutingContext _context;
private readonly Provider _provider;
private readonly Guid _providerId;
public InjectProviderAttributeTests()
{
_providerRepository = Substitute.For<IProviderRepository>();
_currentContext = Substitute.For<ICurrentContext>();
_providerId = Guid.NewGuid();
_provider = new Provider { Id = _providerId };
var httpContext = new DefaultHttpContext();
var services = new ServiceCollection();
services.AddScoped(_ => _providerRepository);
services.AddScoped(_ => _currentContext);
httpContext.RequestServices = services.BuildServiceProvider();
var routeData = new RouteData { Values = { ["providerId"] = _providerId.ToString() } };
var actionContext = new ActionContext(
httpContext,
routeData,
new ActionDescriptor(),
new ModelStateDictionary()
);
_next = () => Task.FromResult(new ActionExecutedContext(
actionContext,
new List<IFilterMetadata>(),
new object()));
_context = new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object>(),
new object());
}
[Fact]
public async Task OnActionExecutionAsync_WithExistingProvider_InjectsProvider()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ProviderAdmin);
_providerRepository.GetByIdAsync(_providerId).Returns(_provider);
_currentContext.ProviderProviderAdmin(_providerId).Returns(true);
var parameter = new ParameterDescriptor
{
Name = "provider",
ParameterType = typeof(Provider)
};
_context.ActionDescriptor.Parameters = [parameter];
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Equal(_provider, _context.ActionArguments["provider"]);
}
[Fact]
public async Task OnActionExecutionAsync_WithNonExistentProvider_ReturnsNotFound()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ProviderAdmin);
_providerRepository.GetByIdAsync(_providerId).Returns((Provider)null);
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<NotFoundObjectResult>(_context.Result);
var result = (NotFoundObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Provider not found.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_WithInvalidProviderId_ReturnsBadRequest()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ProviderAdmin);
_context.RouteData.Values["providerId"] = "not-a-guid";
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<BadRequestObjectResult>(_context.Result);
var result = (BadRequestObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Route parameter 'providerId' is missing or invalid.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_WithMissingProviderId_ReturnsBadRequest()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ProviderAdmin);
_context.RouteData.Values.Clear();
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<BadRequestObjectResult>(_context.Result);
var result = (BadRequestObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Route parameter 'providerId' is missing or invalid.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_WithoutProviderParameter_ContinuesExecution()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ProviderAdmin);
_providerRepository.GetByIdAsync(_providerId).Returns(_provider);
_currentContext.ProviderProviderAdmin(_providerId).Returns(true);
_context.ActionDescriptor.Parameters = Array.Empty<ParameterDescriptor>();
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Empty(_context.ActionArguments);
}
[Fact]
public async Task OnActionExecutionAsync_UnauthorizedProviderAdmin_ReturnsUnauthorized()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ProviderAdmin);
_providerRepository.GetByIdAsync(_providerId).Returns(_provider);
_currentContext.ProviderProviderAdmin(_providerId).Returns(false);
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<UnauthorizedObjectResult>(_context.Result);
var result = (UnauthorizedObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Unauthorized.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_UnauthorizedServiceUser_ReturnsUnauthorized()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ServiceUser);
_providerRepository.GetByIdAsync(_providerId).Returns(_provider);
_currentContext.ProviderUser(_providerId).Returns(false);
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<UnauthorizedObjectResult>(_context.Result);
var result = (UnauthorizedObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Unauthorized.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_AuthorizedProviderAdmin_Succeeds()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ProviderAdmin);
_providerRepository.GetByIdAsync(_providerId).Returns(_provider);
_currentContext.ProviderProviderAdmin(_providerId).Returns(true);
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Null(_context.Result);
}
[Fact]
public async Task OnActionExecutionAsync_AuthorizedServiceUser_Succeeds()
{
var attribute = new InjectProviderAttribute(ProviderUserType.ServiceUser);
_providerRepository.GetByIdAsync(_providerId).Returns(_provider);
_currentContext.ProviderUser(_providerId).Returns(true);
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Null(_context.Result);
}
}

View File

@@ -0,0 +1,129 @@
using System.Security.Claims;
using Bit.Api.Billing.Attributes;
using Bit.Core.Entities;
using Bit.Core.Models.Api;
using Bit.Core.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Billing.Attributes;
public class InjectUserAttributesTests
{
private readonly IUserService _userService;
private readonly ActionExecutionDelegate _next;
private readonly ActionExecutingContext _context;
private readonly User _user;
public InjectUserAttributesTests()
{
_userService = Substitute.For<IUserService>();
_user = new User { Id = Guid.NewGuid() };
var httpContext = new DefaultHttpContext();
var services = new ServiceCollection();
services.AddScoped(_ => _userService);
httpContext.RequestServices = services.BuildServiceProvider();
var actionContext = new ActionContext(
httpContext,
new RouteData(),
new ActionDescriptor(),
new ModelStateDictionary()
);
_next = () => Task.FromResult(new ActionExecutedContext(
actionContext,
new List<IFilterMetadata>(),
new object()));
_context = new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object>(),
new object());
}
[Fact]
public async Task OnActionExecutionAsync_WithAuthorizedUser_InjectsUser()
{
var attribute = new InjectUserAttribute();
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(_user);
var parameter = new ParameterDescriptor
{
Name = "user",
ParameterType = typeof(User)
};
_context.ActionDescriptor.Parameters = [parameter];
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Equal(_user, _context.ActionArguments["user"]);
}
[Fact]
public async Task OnActionExecutionAsync_WithUnauthorizedUser_ReturnsUnauthorized()
{
var attribute = new InjectUserAttribute();
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns((User)null);
await attribute.OnActionExecutionAsync(_context, _next);
Assert.IsType<UnauthorizedObjectResult>(_context.Result);
var result = (UnauthorizedObjectResult)_context.Result;
Assert.IsType<ErrorResponseModel>(result.Value);
Assert.Equal("Unauthorized.", ((ErrorResponseModel)result.Value).Message);
}
[Fact]
public async Task OnActionExecutionAsync_WithoutUserParameter_ContinuesExecution()
{
var attribute = new InjectUserAttribute();
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(_user);
_context.ActionDescriptor.Parameters = Array.Empty<ParameterDescriptor>();
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Empty(_context.ActionArguments);
}
[Fact]
public async Task OnActionExecutionAsync_WithMultipleParameters_InjectsUserCorrectly()
{
var attribute = new InjectUserAttribute();
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(_user);
var parameters = new[]
{
new ParameterDescriptor
{
Name = "otherParam",
ParameterType = typeof(string)
},
new ParameterDescriptor
{
Name = "user",
ParameterType = typeof(User)
}
};
_context.ActionDescriptor.Parameters = parameters;
await attribute.OnActionExecutionAsync(_context, _next);
Assert.Single(_context.ActionArguments);
Assert.Equal(_user, _context.ActionArguments["user"]);
}
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.Billing.Payment.Models;
using Stripe;
namespace Bit.Core.Test.Billing.Extensions;
public static class StripeExtensions
{
public static bool HasExpansions(this BaseOptions options, params string[] expansions)
=> expansions.All(expansion => options.Expand.Contains(expansion));
public static bool Matches(this AddressOptions address, BillingAddress billingAddress) =>
address.Country == billingAddress.Country &&
address.PostalCode == billingAddress.PostalCode &&
address.Line1 == billingAddress.Line1 &&
address.Line2 == billingAddress.Line2 &&
address.City == billingAddress.City &&
address.State == billingAddress.State;
}

View File

@@ -0,0 +1,94 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Invoice = BitPayLight.Models.Invoice.Invoice;
namespace Bit.Core.Test.Billing.Payment.Commands;
public class CreateBitPayInvoiceForCreditCommandTests
{
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
private readonly GlobalSettings _globalSettings = new()
{
BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" }
};
private const string _redirectUrl = "https://bitwarden.com/redirect";
private readonly CreateBitPayInvoiceForCreditCommand _command;
public CreateBitPayInvoiceForCreditCommandTests()
{
_command = new CreateBitPayInvoiceForCreditCommand(
_bitPayClient,
_globalSettings,
Substitute.For<ILogger<CreateBitPayInvoiceForCreditCommand>>());
}
[Fact]
public async Task Run_User_CreatesInvoice_ReturnsInvoiceUrl()
{
var user = new User { Id = Guid.NewGuid(), Email = "user@gmail.com" };
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == user.Email &&
options.Buyer.Name == user.Email &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
options.PosData == $"userId:{user.Id},accountCredit:1" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
var result = await _command.Run(user, 10M, _redirectUrl);
Assert.True(result.IsT0);
var invoiceUrl = result.AsT0;
Assert.Equal("https://bitpay.com/invoice/123", invoiceUrl);
}
[Fact]
public async Task Run_Organization_CreatesInvoice_ReturnsInvoiceUrl()
{
var organization = new Organization { Id = Guid.NewGuid(), BillingEmail = "organization@example.com", Name = "Organization" };
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == organization.BillingEmail &&
options.Buyer.Name == organization.Name &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
options.PosData == $"organizationId:{organization.Id},accountCredit:1" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
var result = await _command.Run(organization, 10M, _redirectUrl);
Assert.True(result.IsT0);
var invoiceUrl = result.AsT0;
Assert.Equal("https://bitpay.com/invoice/123", invoiceUrl);
}
[Fact]
public async Task Run_Provider_CreatesInvoice_ReturnsInvoiceUrl()
{
var provider = new Provider { Id = Guid.NewGuid(), BillingEmail = "organization@example.com", Name = "Provider" };
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == provider.BillingEmail &&
options.Buyer.Name == provider.Name &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
options.PosData == $"providerId:{provider.Id},accountCredit:1" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
var result = await _command.Run(provider, 10M, _redirectUrl);
Assert.True(result.IsT0);
var invoiceUrl = result.AsT0;
Assert.Equal("https://bitpay.com/invoice/123", invoiceUrl);
}
}

View File

@@ -0,0 +1,349 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Extensions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Commands;
using static StripeConstants;
public class UpdateBillingAddressCommandTests
{
private readonly IStripeAdapter _stripeAdapter;
private readonly UpdateBillingAddressCommand _command;
public UpdateBillingAddressCommandTests()
{
_stripeAdapter = Substitute.For<IStripeAdapter>();
_command = new UpdateBillingAddressCommand(
Substitute.For<ILogger<UpdateBillingAddressCommand>>(),
_stripeAdapter);
}
[Fact]
public async Task Run_PersonalOrganization_MakesCorrectInvocations_ReturnsBillingAddress()
{
var organization = new Organization
{
PlanType = PlanType.FamiliesAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
},
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
}
};
_stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions")
)).Returns(customer);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
}
[Fact]
public async Task Run_BusinessOrganization_MakesCorrectInvocations_ReturnsBillingAddress()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
},
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
}
};
_stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.None
)).Returns(customer);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
}
[Fact]
public async Task Run_BusinessOrganization_RemovingTaxId_MakesCorrectInvocations_ReturnsBillingAddress()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
},
Id = organization.GatewayCustomerId,
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
},
TaxIds = new StripeList<TaxId>
{
Data =
[
new TaxId { Id = "tax_id_123", Type = "us_ein", Value = "123456789" }
]
}
};
_stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.None
)).Returns(customer);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
await _stripeAdapter.Received(1).TaxIdDeleteAsync(customer.Id, "tax_id_123");
}
[Fact]
public async Task Run_NonUSBusinessOrganization_MakesCorrectInvocations_ReturnsBillingAddress()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "DE",
PostalCode = "10115",
Line1 = "Friedrichstraße 123",
Line2 = "Stock 3",
City = "Berlin",
State = "Berlin"
};
var customer = new Customer
{
Address = new Address
{
Country = "DE",
PostalCode = "10115",
Line1 = "Friedrichstraße 123",
Line2 = "Stock 3",
City = "Berlin",
State = "Berlin"
},
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
}
};
_stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.Reverse
)).Returns(customer);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
}
[Fact]
public async Task Run_BusinessOrganizationWithSpanishCIF_MakesCorrectInvocations_ReturnsBillingAddress()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "ES",
PostalCode = "28001",
Line1 = "Calle de Serrano 41",
Line2 = "Planta 3",
City = "Madrid",
State = "Madrid",
TaxId = new TaxID(TaxIdType.SpanishNIF, "A12345678")
};
var customer = new Customer
{
Address = new Address
{
Country = "ES",
PostalCode = "28001",
Line1 = "Calle de Serrano 41",
Line2 = "Planta 3",
City = "Madrid",
State = "Madrid"
},
Id = organization.GatewayCustomerId,
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
}
};
_stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.Reverse
)).Returns(customer);
_stripeAdapter
.TaxIdCreateAsync(customer.Id,
Arg.Is<TaxIdCreateOptions>(options => options.Type == TaxIdType.EUVAT))
.Returns(new TaxId { Type = TaxIdType.EUVAT, Value = "ESA12345678" });
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input with { TaxId = new TaxID(TaxIdType.EUVAT, "ESA12345678") }, output);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => options.AutomaticTax.Enabled == true));
await _stripeAdapter.Received(1).TaxIdCreateAsync(organization.GatewayCustomerId, Arg.Is<TaxIdCreateOptions>(
options => options.Type == TaxIdType.SpanishNIF &&
options.Value == input.TaxId.Value));
}
}

View File

@@ -0,0 +1,399 @@
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;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Billing.Extensions;
using Braintree;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
using Address = Stripe.Address;
using Customer = Stripe.Customer;
using PaymentMethod = Stripe.PaymentMethod;
namespace Bit.Core.Test.Billing.Payment.Commands;
using static StripeConstants;
public class UpdatePaymentMethodCommandTests
{
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
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;
public UpdatePaymentMethodCommandTests()
{
_command = new UpdatePaymentMethodCommand(
_braintreeGateway,
_globalSettings,
Substitute.For<ILogger<UpdatePaymentMethodCommand>>(),
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
[Fact]
public async Task Run_BankAccount_MakesCorrectInvocations_ReturnsMaskedBankAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345"
},
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
const string token = "TOKEN";
var setupIntent = new SetupIntent
{
Id = "seti_123",
PaymentMethod =
new PaymentMethod
{
Type = "us_bank_account",
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
Status = "requires_action"
};
_stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == token && options.HasExpansions("data.payment_method"))).Returns([setupIntent]);
var result = await _command.Run(organization,
new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = token }, new BillingAddress
{
Country = "US",
PostalCode = "12345"
});
Assert.True(result.IsT0);
var maskedPaymentMethod = result.AsT0;
Assert.True(maskedPaymentMethod.IsT0);
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
}
[Fact]
public async Task Run_BankAccount_StripeToPayPal_MakesCorrectInvocations_ReturnsMaskedBankAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345"
},
Id = "cus_123",
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "braintree_customer_id"
}
};
_subscriberService.GetCustomer(organization).Returns(customer);
const string token = "TOKEN";
var setupIntent = new SetupIntent
{
Id = "seti_123",
PaymentMethod =
new PaymentMethod
{
Type = "us_bank_account",
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
Status = "requires_action"
};
_stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == token && options.HasExpansions("data.payment_method"))).Returns([setupIntent]);
var result = await _command.Run(organization,
new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = token }, new BillingAddress
{
Country = "US",
PostalCode = "12345"
});
Assert.True(result.IsT0);
var maskedPaymentMethod = result.AsT0;
Assert.True(maskedPaymentMethod.IsT0);
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, Arg.Is<CustomerUpdateOptions>(options =>
options.Metadata[MetadataKeys.BraintreeCustomerId] == string.Empty &&
options.Metadata[MetadataKeys.RetiredBraintreeCustomerId] == "braintree_customer_id"));
}
[Fact]
public async Task Run_Card_MakesCorrectInvocations_ReturnsMaskedCard()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345"
},
Id = "cus_123",
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
const string token = "TOKEN";
_stripeAdapter
.PaymentMethodAttachAsync(token,
Arg.Is<PaymentMethodAttachOptions>(options => options.Customer == customer.Id))
.Returns(new PaymentMethod
{
Type = "card",
Card = new PaymentMethodCard
{
Brand = "visa",
Last4 = "9999",
ExpMonth = 1,
ExpYear = 2028
}
});
var result = await _command.Run(organization,
new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = token }, new BillingAddress
{
Country = "US",
PostalCode = "12345"
});
Assert.True(result.IsT0);
var maskedPaymentMethod = result.AsT0;
Assert.True(maskedPaymentMethod.IsT1);
var maskedCard = maskedPaymentMethod.AsT1;
Assert.Equal("visa", maskedCard.Brand);
Assert.Equal("9999", maskedCard.Last4);
Assert.Equal("01/2028", maskedCard.Expiration);
await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id,
Arg.Is<CustomerUpdateOptions>(options => options.InvoiceSettings.DefaultPaymentMethod == token));
}
[Fact]
public async Task Run_Card_PropagateBillingAddress_MakesCorrectInvocations_ReturnsMaskedCard()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
Id = "cus_123",
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
const string token = "TOKEN";
_stripeAdapter
.PaymentMethodAttachAsync(token,
Arg.Is<PaymentMethodAttachOptions>(options => options.Customer == customer.Id))
.Returns(new PaymentMethod
{
Type = "card",
Card = new PaymentMethodCard
{
Brand = "visa",
Last4 = "9999",
ExpMonth = 1,
ExpYear = 2028
}
});
var result = await _command.Run(organization,
new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = token }, new BillingAddress
{
Country = "US",
PostalCode = "12345"
});
Assert.True(result.IsT0);
var maskedPaymentMethod = result.AsT0;
Assert.True(maskedPaymentMethod.IsT1);
var maskedCard = maskedPaymentMethod.AsT1;
Assert.Equal("visa", maskedCard.Brand);
Assert.Equal("9999", maskedCard.Last4);
Assert.Equal("01/2028", maskedCard.Expiration);
await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id,
Arg.Is<CustomerUpdateOptions>(options => options.InvoiceSettings.DefaultPaymentMethod == token));
await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id,
Arg.Is<CustomerUpdateOptions>(options => options.Address.Country == "US" && options.Address.PostalCode == "12345"));
}
[Fact]
public async Task Run_PayPal_ExistingBraintreeCustomer_MakesCorrectInvocations_ReturnsMaskedPayPalAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345"
},
Id = "cus_123",
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "braintree_customer_id"
}
};
_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>();
existing.Email.Returns("user@gmail.com");
existing.IsDefault.Returns(true);
existing.Token.Returns("EXISTING");
braintreeCustomer.PaymentMethods.Returns([existing]);
customerGateway.FindAsync("braintree_customer_id").Returns(braintreeCustomer);
_braintreeGateway.Customer.Returns(customerGateway);
var paymentMethodGateway = Substitute.For<IPaymentMethodGateway>();
var updated = Substitute.For<PayPalAccount>();
updated.Email.Returns("user@gmail.com");
updated.Token.Returns("UPDATED");
var updatedResult = Substitute.For<Result<Braintree.PaymentMethod>>();
updatedResult.Target.Returns(updated);
paymentMethodGateway.CreateAsync(Arg.Is<PaymentMethodRequest>(options =>
options.CustomerId == braintreeCustomer.Id && options.PaymentMethodNonce == "TOKEN"))
.Returns(updatedResult);
_braintreeGateway.PaymentMethod.Returns(paymentMethodGateway);
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);
await customerGateway.Received(1).UpdateAsync(braintreeCustomer.Id,
Arg.Is<CustomerRequest>(options => options.DefaultPaymentMethodToken == updated.Token));
await paymentMethodGateway.Received(1).DeleteAsync(existing.Token);
}
[Fact]
public async Task Run_PayPal_NewBraintreeCustomer_MakesCorrectInvocations_ReturnsMaskedPayPalAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345"
},
Id = "cus_123",
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_globalSettings.BaseServiceUri.Returns(new GlobalSettings.BaseServiceUriSettings(new GlobalSettings())
{
CloudRegion = "US"
});
var customerGateway = Substitute.For<ICustomerGateway>();
var braintreeCustomer = Substitute.For<Braintree.Customer>();
braintreeCustomer.Id.Returns("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);
await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id,
Arg.Is<CustomerUpdateOptions>(options =>
options.Metadata[MetadataKeys.BraintreeCustomerId] == "braintree_customer_id"));
}
}

View File

@@ -0,0 +1,81 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Extensions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Commands;
public class VerifyBankAccountCommandTests
{
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly VerifyBankAccountCommand _command;
public VerifyBankAccountCommandTests()
{
_command = new VerifyBankAccountCommand(
Substitute.For<ILogger<VerifyBankAccountCommand>>(),
_setupIntentCache,
_stripeAdapter);
}
[Fact]
public async Task Run_MakesCorrectInvocations_ReturnsMaskedBankAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewayCustomerId = "cus_123"
};
const string setupIntentId = "seti_123";
_setupIntentCache.Get(organization.Id).Returns(setupIntentId);
var setupIntent = new SetupIntent
{
Id = setupIntentId,
PaymentMethodId = "pm_123",
PaymentMethod =
new PaymentMethod
{
Id = "pm_123",
Type = "us_bank_account",
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
Status = "requires_action"
};
_stripeAdapter.SetupIntentGet(setupIntentId,
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method"))).Returns(setupIntent);
_stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
Arg.Is<PaymentMethodAttachOptions>(options => options.Customer == organization.GatewayCustomerId))
.Returns(setupIntent.PaymentMethod);
var result = await _command.Run(organization, "DESCRIPTOR_CODE");
Assert.True(result.IsT0);
var maskedPaymentMethod = result.AsT0;
Assert.True(maskedPaymentMethod.IsT0);
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified);
await _stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id,
Arg.Is<SetupIntentVerifyMicrodepositsOptions>(options => options.DescriptorCode == "DESCRIPTOR_CODE"));
await _stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId));
}
}

View File

@@ -0,0 +1,63 @@
using System.Text.Json;
using Bit.Core.Billing.Payment.Models;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Models;
public class MaskedPaymentMethodTests
{
[Fact]
public void Write_Read_BankAccount_Succeeds()
{
MaskedPaymentMethod input = new MaskedBankAccount
{
BankName = "Chase",
Last4 = "9999",
Verified = true
};
var json = JsonSerializer.Serialize(input);
var output = JsonSerializer.Deserialize<MaskedPaymentMethod>(json);
Assert.NotNull(output);
Assert.True(output.IsT0);
Assert.Equivalent(input.AsT0, output.AsT0);
}
[Fact]
public void Write_Read_Card_Succeeds()
{
MaskedPaymentMethod input = new MaskedCard
{
Brand = "visa",
Last4 = "9999",
Expiration = "01/2028"
};
var json = JsonSerializer.Serialize(input);
var output = JsonSerializer.Deserialize<MaskedPaymentMethod>(json);
Assert.NotNull(output);
Assert.True(output.IsT1);
Assert.Equivalent(input.AsT1, output.AsT1);
}
[Fact]
public void Write_Read_PayPal_Succeeds()
{
MaskedPaymentMethod input = new MaskedPayPalAccount
{
Email = "paypal-user@gmail.com"
};
var json = JsonSerializer.Serialize(input);
var output = JsonSerializer.Deserialize<MaskedPaymentMethod>(json);
Assert.NotNull(output);
Assert.True(output.IsT2);
Assert.Equivalent(input.AsT2, output.AsT2);
}
}

View File

@@ -0,0 +1,204 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Test.Billing.Extensions;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Queries;
public class GetBillingAddressQueryTests
{
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly GetBillingAddressQuery _query;
public GetBillingAddressQueryTests()
{
_query = new GetBillingAddressQuery(_subscriberService);
}
[Fact]
public async Task Run_ForUserWithNoAddress_ReturnsNull()
{
var user = new User();
var customer = new Customer();
_subscriberService.GetCustomer(user, Arg.Is<CustomerGetOptions>(
options => options.Expand == null)).Returns(customer);
var billingAddress = await _query.Run(user);
Assert.Null(billingAddress);
}
[Fact]
public async Task Run_ForUserWithAddress_ReturnsBillingAddress()
{
var user = new User();
var address = GetAddress();
var customer = new Customer
{
Address = address
};
_subscriberService.GetCustomer(user, Arg.Is<CustomerGetOptions>(
options => options.Expand == null)).Returns(customer);
var billingAddress = await _query.Run(user);
AssertEquality(address, billingAddress);
}
[Fact]
public async Task Run_ForPersonalOrganizationWithNoAddress_ReturnsNull()
{
var organization = new Organization
{
PlanType = PlanType.FamiliesAnnually
};
var customer = new Customer();
_subscriberService.GetCustomer(organization, Arg.Is<CustomerGetOptions>(
options => options.Expand == null)).Returns(customer);
var billingAddress = await _query.Run(organization);
Assert.Null(billingAddress);
}
[Fact]
public async Task Run_ForPersonalOrganizationWithAddress_ReturnsBillingAddress()
{
var organization = new Organization
{
PlanType = PlanType.FamiliesAnnually
};
var address = GetAddress();
var customer = new Customer
{
Address = address
};
_subscriberService.GetCustomer(organization, Arg.Is<CustomerGetOptions>(
options => options.Expand == null)).Returns(customer);
var billingAddress = await _query.Run(organization);
AssertEquality(customer.Address, billingAddress);
}
[Fact]
public async Task Run_ForBusinessOrganizationWithNoAddress_ReturnsNull()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually
};
var customer = new Customer();
_subscriberService.GetCustomer(organization, Arg.Is<CustomerGetOptions>(
options => options.HasExpansions("tax_ids"))).Returns(customer);
var billingAddress = await _query.Run(organization);
Assert.Null(billingAddress);
}
[Fact]
public async Task Run_ForBusinessOrganizationWithAddressAndTaxId_ReturnsBillingAddressWithTaxId()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually
};
var address = GetAddress();
var taxId = GetTaxId();
var customer = new Customer
{
Address = address,
TaxIds = new StripeList<TaxId>
{
Data = [taxId]
}
};
_subscriberService.GetCustomer(organization, Arg.Is<CustomerGetOptions>(
options => options.HasExpansions("tax_ids"))).Returns(customer);
var billingAddress = await _query.Run(organization);
AssertEquality(address, taxId, billingAddress);
}
[Fact]
public async Task Run_ForProviderWithAddressAndTaxId_ReturnsBillingAddressWithTaxId()
{
var provider = new Provider();
var address = GetAddress();
var taxId = GetTaxId();
var customer = new Customer
{
Address = address,
TaxIds = new StripeList<TaxId>
{
Data = [taxId]
}
};
_subscriberService.GetCustomer(provider, Arg.Is<CustomerGetOptions>(
options => options.HasExpansions("tax_ids"))).Returns(customer);
var billingAddress = await _query.Run(provider);
AssertEquality(address, taxId, billingAddress);
}
private static void AssertEquality(Address address, BillingAddress? billingAddress)
{
Assert.NotNull(billingAddress);
Assert.Equal(address.Country, billingAddress.Country);
Assert.Equal(address.PostalCode, billingAddress.PostalCode);
Assert.Equal(address.Line1, billingAddress.Line1);
Assert.Equal(address.Line2, billingAddress.Line2);
Assert.Equal(address.City, billingAddress.City);
Assert.Equal(address.State, billingAddress.State);
}
private static void AssertEquality(Address address, TaxId taxId, BillingAddress? billingAddress)
{
AssertEquality(address, billingAddress);
Assert.NotNull(billingAddress!.TaxId);
Assert.Equal(taxId.Type, billingAddress.TaxId!.Code);
Assert.Equal(taxId.Value, billingAddress.TaxId!.Value);
}
private static Address GetAddress() => new()
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
};
private static TaxId GetTaxId() => new() { Type = "us_ein", Value = "123456789" };
}

View File

@@ -0,0 +1,41 @@
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Queries;
public class GetCreditQueryTests
{
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly GetCreditQuery _query;
public GetCreditQueryTests()
{
_query = new GetCreditQuery(_subscriberService);
}
[Fact]
public async Task Run_NoCustomer_ReturnsNull()
{
_subscriberService.GetCustomer(Arg.Any<ISubscriber>()).ReturnsNull();
var credit = await _query.Run(Substitute.For<ISubscriber>());
Assert.Null(credit);
}
[Fact]
public async Task Run_ReturnsCredit()
{
_subscriberService.GetCustomer(Arg.Any<ISubscriber>()).Returns(new Customer { Balance = -1000 });
var credit = await _query.Run(Substitute.For<ISubscriber>());
Assert.NotNull(credit);
Assert.Equal(10M, credit);
}
}

View File

@@ -0,0 +1,327 @@
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;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Extensions;
using Braintree;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
using Customer = Stripe.Customer;
using PaymentMethod = Stripe.PaymentMethod;
namespace Bit.Core.Test.Billing.Payment.Queries;
using static StripeConstants;
public class GetPaymentMethodQueryTests
{
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
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;
public GetPaymentMethodQueryTests()
{
_query = new GetPaymentMethodQuery(
_braintreeGateway,
Substitute.For<ILogger<GetPaymentMethodQuery>>(),
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
[Fact]
public async Task Run_NoPaymentMethod_ReturnsNull()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var maskedPaymentMethod = await _query.Run(organization);
Assert.Null(maskedPaymentMethod);
}
[Fact]
public async Task Run_BankAccount_FromPaymentMethod_ReturnsMaskedBankAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethod = new PaymentMethod
{
Type = "us_bank_account",
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
}
},
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var maskedPaymentMethod = await _query.Run(organization);
Assert.NotNull(maskedPaymentMethod);
Assert.True(maskedPaymentMethod.IsT0);
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified);
}
[Fact]
public async Task Run_BankAccount_FromSource_ReturnsMaskedBankAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
DefaultSource = new BankAccount
{
BankName = "Chase",
Last4 = "9999",
Status = "verified"
},
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var maskedPaymentMethod = await _query.Run(organization);
Assert.NotNull(maskedPaymentMethod);
Assert.True(maskedPaymentMethod.IsT0);
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified);
}
[Fact]
public async Task Run_BankAccount_FromSetupIntent_ReturnsMaskedBankAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
_setupIntentCache.Get(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method"))).Returns(
new SetupIntent
{
PaymentMethod = new PaymentMethod
{
Type = "us_bank_account",
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
Status = "requires_action"
});
var maskedPaymentMethod = await _query.Run(organization);
Assert.NotNull(maskedPaymentMethod);
Assert.True(maskedPaymentMethod.IsT0);
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified);
}
[Fact]
public async Task Run_Card_FromPaymentMethod_ReturnsMaskedCard()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethod = new PaymentMethod
{
Type = "card",
Card = new PaymentMethodCard
{
Brand = "visa",
Last4 = "9999",
ExpMonth = 1,
ExpYear = 2028
}
}
},
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var maskedPaymentMethod = await _query.Run(organization);
Assert.NotNull(maskedPaymentMethod);
Assert.True(maskedPaymentMethod.IsT1);
var maskedCard = maskedPaymentMethod.AsT1;
Assert.Equal("visa", maskedCard.Brand);
Assert.Equal("9999", maskedCard.Last4);
Assert.Equal("01/2028", maskedCard.Expiration);
}
[Fact]
public async Task Run_Card_FromSource_ReturnsMaskedCard()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
DefaultSource = new Card
{
Brand = "visa",
Last4 = "9999",
ExpMonth = 1,
ExpYear = 2028
},
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var maskedPaymentMethod = await _query.Run(organization);
Assert.NotNull(maskedPaymentMethod);
Assert.True(maskedPaymentMethod.IsT1);
var maskedCard = maskedPaymentMethod.AsT1;
Assert.Equal("visa", maskedCard.Brand);
Assert.Equal("9999", maskedCard.Last4);
Assert.Equal("01/2028", maskedCard.Expiration);
}
[Fact]
public async Task Run_Card_FromSourceCard_ReturnsMaskedCard()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
DefaultSource = new Source
{
Card = new SourceCard
{
Brand = "Visa",
Last4 = "9999",
ExpMonth = 1,
ExpYear = 2028
}
},
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization,
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
var maskedPaymentMethod = await _query.Run(organization);
Assert.NotNull(maskedPaymentMethod);
Assert.True(maskedPaymentMethod.IsT1);
var maskedCard = maskedPaymentMethod.AsT1;
Assert.Equal("visa", maskedCard.Brand);
Assert.Equal("9999", maskedCard.Last4);
Assert.Equal("01/2028", maskedCard.Expiration);
}
[Fact]
public async Task Run_PayPalAccount_ReturnsMaskedPayPalAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "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>();
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);
var maskedPaymentMethod = await _query.Run(organization);
Assert.NotNull(maskedPaymentMethod);
Assert.True(maskedPaymentMethod.IsT2);
var maskedPayPalAccount = maskedPaymentMethod.AsT2;
Assert.Equal("user@gmail.com", maskedPayPalAccount.Email);
}
}

View File

@@ -1,6 +1,5 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Commands;
using Bit.Core.Billing.Tax.Services;
@@ -8,7 +7,6 @@ using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Stripe;
using Xunit;
using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters;
@@ -273,74 +271,6 @@ public class PreviewTaxAmountCommandTests
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal(BillingErrorTranslationKeys.UnknownTaxIdType, badRequest.TranslationKey);
}
[Fact]
public async Task Run_CustomerTaxLocationInvalid_BadRequest()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "US",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Throws(new StripeException
{
StripeError = new StripeError { Code = StripeConstants.ErrorCodes.CustomerTaxLocationInvalid }
});
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal(BillingErrorTranslationKeys.CustomerTaxLocationInvalid, badRequest.TranslationKey);
}
[Fact]
public async Task Run_TaxIdInvalid_BadRequest()
{
// Arrange
var parameters = new OrganizationTrialParameters
{
PlanType = PlanType.EnterpriseAnnually,
ProductType = ProductType.PasswordManager,
TaxInformation = new TaxInformationDTO
{
Country = "US",
PostalCode = "12345"
}
};
var plan = StaticStore.GetPlan(parameters.PlanType);
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Throws(new StripeException
{
StripeError = new StripeError { Code = StripeConstants.ErrorCodes.TaxIdInvalid }
});
// Act
var result = await _command.Run(parameters);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal(BillingErrorTranslationKeys.TaxIdInvalid, badRequest.TranslationKey);
Assert.Equal("We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.", badRequest.Response);
}
}