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:
@@ -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);
|
||||
}
|
||||
}
|
||||
190
test/Api.Test/Billing/Attributes/InjectProviderAttributeTests.cs
Normal file
190
test/Api.Test/Billing/Attributes/InjectProviderAttributeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
129
test/Api.Test/Billing/Attributes/InjectUserAttributesTests.cs
Normal file
129
test/Api.Test/Billing/Attributes/InjectUserAttributesTests.cs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
18
test/Core.Test/Billing/Extensions/StripeExtensions.cs
Normal file
18
test/Core.Test/Billing/Extensions/StripeExtensions.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user